Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fe6cb72ab | ||
|
|
7a585afb9e | ||
|
|
ecffa5bb0e | ||
|
|
cfe1609df2 | ||
|
|
5c74034f81 | ||
|
|
1d247fbca1 | ||
|
|
6108755f0c |
@@ -73,6 +73,18 @@ def _validate_sort_order(value: object) -> int:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_child_id(value: object) -> int:
|
||||||
|
"""Reject non-int child ids (bools included) before any lookup.
|
||||||
|
|
||||||
|
SQLite binds Python bools as integers, so ``True`` would silently
|
||||||
|
address child 1 and a float would round — malformed service input
|
||||||
|
must never mutate the wrong profile.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
async def _warn_on_duplicate_name(
|
async def _warn_on_duplicate_name(
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
@@ -166,6 +178,7 @@ async def edit_child(
|
|||||||
and its readback — the returned record is exactly the one this
|
and its readback — the returned record is exactly the one this
|
||||||
edit produced, not a later writer's.
|
edit produced, not a later writer's.
|
||||||
"""
|
"""
|
||||||
|
_validate_child_id(child_id)
|
||||||
async with _connection_lock(database):
|
async with _connection_lock(database):
|
||||||
dao = ChildrenDao(database)
|
dao = ChildrenDao(database)
|
||||||
existing = await dao.get(child_id)
|
existing = await dao.get(child_id)
|
||||||
@@ -213,22 +226,50 @@ async def set_child_active(
|
|||||||
|
|
||||||
Deactivation hides the child from the panel without deleting their
|
Deactivation hides the child from the panel without deleting their
|
||||||
history (feature guardrail); this is the only removal path.
|
history (feature guardrail); this is the only removal path.
|
||||||
Raises ValueError when the child does not exist.
|
``is_active`` must be a real bool: the DAO's ``int()`` would
|
||||||
|
otherwise silently coerce strings and numerics ("1", 0) into a
|
||||||
|
state the caller never asked for. Raises ValueError when the
|
||||||
|
child does not exist.
|
||||||
|
|
||||||
Atomicity: existence check, UPDATE and readback run inside the
|
Atomicity: existence check, UPDATE and readback run inside the
|
||||||
connection-scoped lock, so a concurrent opposite transition cannot
|
connection-scoped lock, so a concurrent opposite transition cannot
|
||||||
make this call report the other caller's value.
|
make this call report the other caller's value.
|
||||||
"""
|
"""
|
||||||
|
_validate_child_id(child_id)
|
||||||
async with _connection_lock(database):
|
async with _connection_lock(database):
|
||||||
dao = ChildrenDao(database)
|
dao = ChildrenDao(database)
|
||||||
if await dao.get(child_id) is None:
|
if await dao.get(child_id) is None:
|
||||||
raise ValueError(f"child {child_id} does not exist")
|
raise ValueError(f"child {child_id} does not exist")
|
||||||
|
if not isinstance(is_active, bool):
|
||||||
|
raise ValueError(
|
||||||
|
f"is_active must be a real bool, got {is_active!r}"
|
||||||
|
)
|
||||||
await dao.set_active(child_id, is_active)
|
await dao.set_active(child_id, is_active)
|
||||||
updated = await dao.get(child_id)
|
updated = await dao.get(child_id)
|
||||||
assert updated is not None
|
assert updated is not None
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
async def reorder_children(
|
||||||
|
database: NestQuestDatabase, ordered_ids: list[int]
|
||||||
|
) -> None:
|
||||||
|
"""Rewrite the children's panel display order in one transaction.
|
||||||
|
|
||||||
|
``ordered_ids`` must be a complete permutation of the children
|
||||||
|
table — every child id exactly once. A partial list (missing an
|
||||||
|
existing child), an unknown id, or a duplicate is rejected with
|
||||||
|
ValueError before any write, so the current order survives a
|
||||||
|
rejected call. The DAO enforces this inside its own transaction
|
||||||
|
(check and write are atomic under the connection lock); this
|
||||||
|
wrapper additionally rejects non-integer ids up front, since
|
||||||
|
``bool`` and floats would silently compare against stored integer
|
||||||
|
ids at the SQL layer.
|
||||||
|
"""
|
||||||
|
for child_id in ordered_ids:
|
||||||
|
_validate_child_id(child_id)
|
||||||
|
await ChildrenDao(database).reorder(ordered_ids)
|
||||||
|
|
||||||
|
|
||||||
async def list_children(
|
async def list_children(
|
||||||
database: NestQuestDatabase, *, active_only: bool = False
|
database: NestQuestDatabase, *, active_only: bool = False
|
||||||
) -> list[ChildRecord]:
|
) -> list[ChildRecord]:
|
||||||
|
|||||||
@@ -191,13 +191,20 @@ class ChildrenDao:
|
|||||||
async def reorder(self, ordered_ids: list[int]) -> None:
|
async def reorder(self, ordered_ids: list[int]) -> None:
|
||||||
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
|
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
|
||||||
|
|
||||||
|
The list must be a COMPLETE permutation of the children table:
|
||||||
|
every existing child id appears exactly once. Partial lists
|
||||||
|
(missing an existing child) and unknown ids (naming a child
|
||||||
|
that does not exist) are rejected with ValueError BEFORE any
|
||||||
|
write, leaving the current order untouched — a silent partial
|
||||||
|
reorder would leave unlisted children with stale positions and
|
||||||
|
hide a caller bug. Duplicate ids are rejected up front: they
|
||||||
|
cannot all hold distinct positions, and silently assigning the
|
||||||
|
last occurrence's index would hide a caller bug.
|
||||||
|
|
||||||
Runs inside one transaction; positions are staged at offset
|
Runs inside one transaction; positions are staged at offset
|
||||||
-len (all negative, so any permutation of the same ids never
|
-len (all negative, so any permutation of the same ids never
|
||||||
collides mid-reorder under the wrapper's single-connection
|
collides mid-reorder under the wrapper's single-connection
|
||||||
serialization), then finalized 0..n-1. Duplicate ids are
|
serialization), then finalized 0..n-1.
|
||||||
rejected up front: they cannot all hold distinct positions, and
|
|
||||||
silently assigning the last occurrence's index would hide a
|
|
||||||
caller bug.
|
|
||||||
|
|
||||||
Concurrent reorder() calls on one connection are serialized by
|
Concurrent reorder() calls on one connection are serialized by
|
||||||
a connection-scoped asyncio lock (shared across every ChildrenDao
|
a connection-scoped asyncio lock (shared across every ChildrenDao
|
||||||
@@ -205,10 +212,10 @@ class ChildrenDao:
|
|||||||
transaction() rejects a second concurrent transaction with
|
transaction() rejects a second concurrent transaction with
|
||||||
RuntimeError, so racing callers would crash instead of
|
RuntimeError, so racing callers would crash instead of
|
||||||
queueing. The lock makes the second call wait, then run
|
queueing. The lock makes the second call wait, then run
|
||||||
against the first call's final state.
|
against the first call's final state — which also keeps the
|
||||||
|
completeness check honest: the re-read of the table happens
|
||||||
|
inside the same lock and transaction as the writes.
|
||||||
"""
|
"""
|
||||||
if not ordered_ids:
|
|
||||||
return
|
|
||||||
if len(set(ordered_ids)) != len(ordered_ids):
|
if len(set(ordered_ids)) != len(ordered_ids):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"reorder() received duplicate child ids; every id must "
|
"reorder() received duplicate child ids; every id must "
|
||||||
@@ -216,6 +223,29 @@ class ChildrenDao:
|
|||||||
)
|
)
|
||||||
async with _connection_lock(self._database):
|
async with _connection_lock(self._database):
|
||||||
async with self._database.transaction():
|
async with self._database.transaction():
|
||||||
|
rows = await self._database.fetch_all(
|
||||||
|
f"SELECT id FROM children"
|
||||||
|
)
|
||||||
|
existing = {row[0] for row in rows}
|
||||||
|
listed = set(ordered_ids)
|
||||||
|
missing = sorted(existing - listed)
|
||||||
|
unknown = sorted(listed - existing)
|
||||||
|
if missing or unknown:
|
||||||
|
details = []
|
||||||
|
if missing:
|
||||||
|
details.append(
|
||||||
|
f"missing child ids {missing} (a partial "
|
||||||
|
"reorder would leave them with stale "
|
||||||
|
"positions)"
|
||||||
|
)
|
||||||
|
if unknown:
|
||||||
|
details.append(
|
||||||
|
f"unknown child ids {unknown}"
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"reorder() must list every child exactly once; "
|
||||||
|
+ "; ".join(details)
|
||||||
|
)
|
||||||
offset = len(ordered_ids)
|
offset = len(ordered_ids)
|
||||||
await self._database.execute_many(
|
await self._database.execute_many(
|
||||||
"UPDATE children SET sort_order = ? WHERE id = ?",
|
"UPDATE children SET sort_order = ? WHERE id = ?",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from custom_components.nestquest.children import (
|
|||||||
create_child,
|
create_child,
|
||||||
edit_child,
|
edit_child,
|
||||||
list_children,
|
list_children,
|
||||||
|
reorder_children,
|
||||||
set_child_active,
|
set_child_active,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao
|
from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao
|
||||||
@@ -241,6 +242,33 @@ def test_edit_child_missing_raises(tmp_path) -> None:
|
|||||||
_with_db(tmp_path, "edit-missing.db")(_body)
|
_with_db(tmp_path, "edit-missing.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [True, False, 1.0, "1", None])
|
||||||
|
def test_edit_child_non_int_id_rejected(tmp_path, bad) -> None:
|
||||||
|
"""SQLite binds bools as integers: True must not address child 1,
|
||||||
|
and floats must not round onto a real profile."""
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
await edit_child(database, bad, display_name="X")
|
||||||
|
# The refused edit left the profile untouched.
|
||||||
|
assert (await list_children(database))[0].display_name == "Ada"
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-bad-id.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [True, False, 1.0, "1", None])
|
||||||
|
def test_set_child_active_non_int_id_rejected(tmp_path, bad) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
await set_child_active(database, bad, False)
|
||||||
|
assert (await list_children(database))[0].is_active is True
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-active-bad-id.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
def test_edit_child_name_validation(tmp_path) -> None:
|
def test_edit_child_name_validation(tmp_path) -> None:
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
child = await create_child(database, "Ada")
|
child = await create_child(database, "Ada")
|
||||||
@@ -441,6 +469,129 @@ def test_set_child_active_missing_raises(tmp_path) -> None:
|
|||||||
_with_db(tmp_path, "set-active-missing.db")(_body)
|
_with_db(tmp_path, "set-active-missing.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# reorder_children: complete permutation or nothing changes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_persists_display_order(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
third = await create_child(database, "Cleo", sort_order=2)
|
||||||
|
await reorder_children(database, [third.id, first.id, second.id])
|
||||||
|
# The active list returns children in the new sort order.
|
||||||
|
listed = await list_children(database, active_only=True)
|
||||||
|
assert [c.id for c in listed] == [third.id, first.id, second.id]
|
||||||
|
assert [c.sort_order for c in listed] == [0, 1, 2]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-round-trip.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_partial_list_rejected_order_unchanged(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
third = await create_child(database, "Cleo", sort_order=2)
|
||||||
|
with pytest.raises(ValueError, match="missing child ids"):
|
||||||
|
await reorder_children(database, [third.id, first.id])
|
||||||
|
listed = await list_children(database)
|
||||||
|
# Nothing changed: rejection happens before any write.
|
||||||
|
assert [c.id for c in listed] == [first.id, second.id, third.id]
|
||||||
|
assert [c.sort_order for c in listed] == [0, 1, 2]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-partial.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_unknown_id_rejected_order_unchanged(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
with pytest.raises(ValueError, match="unknown child ids"):
|
||||||
|
await reorder_children(database, [second.id, first.id, 999])
|
||||||
|
listed = await list_children(database)
|
||||||
|
assert [c.id for c in listed] == [first.id, second.id]
|
||||||
|
assert [c.sort_order for c in listed] == [0, 1]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-unknown.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_empty_list_rejected_when_children_exist(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
"""An empty list is the maximally partial reorder: rejected while
|
||||||
|
any child exists (against an empty table it is a complete
|
||||||
|
permutation and stays a harmless no-op)."""
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
with pytest.raises(ValueError, match="missing child ids"):
|
||||||
|
await reorder_children(database, [])
|
||||||
|
assert [c.sort_order for c in await list_children(database)] == [0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-empty.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_duplicate_and_non_int_rejected(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
with pytest.raises(ValueError, match="duplicate child ids"):
|
||||||
|
await reorder_children(database, [first.id, first.id, second.id])
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
await reorder_children(database, [first.id, second.id, True])
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
await reorder_children(database, [first.id, second.id, "3"])
|
||||||
|
listed = await list_children(database)
|
||||||
|
assert [c.sort_order for c in listed] == [0, 1]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-bad-inputs.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reorder_children_inactive_children_included(tmp_path) -> None:
|
||||||
|
"""Inactive children keep their display position: the permutation
|
||||||
|
covers the whole table, not just the active list."""
|
||||||
|
async def _body(database):
|
||||||
|
first = await create_child(database, "Ada", sort_order=0)
|
||||||
|
second = await create_child(database, "Bo", sort_order=1)
|
||||||
|
await set_child_active(database, second.id, False)
|
||||||
|
# Both ids are required even though one is inactive.
|
||||||
|
await reorder_children(database, [second.id, first.id])
|
||||||
|
listed = await list_children(database)
|
||||||
|
assert [c.id for c in listed] == [second.id, first.id]
|
||||||
|
assert [c.sort_order for c in listed] == [0, 1]
|
||||||
|
# The active list skips the inactive child but keeps position.
|
||||||
|
assert [c.id for c in await list_children(database, active_only=True)] == [
|
||||||
|
first.id
|
||||||
|
]
|
||||||
|
return listed
|
||||||
|
|
||||||
|
_with_db(tmp_path, "reorder-inactive.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["1", "0", 1, 0, 1.0, None, "true"])
|
||||||
|
def test_set_child_active_rejects_non_bool(tmp_path, bad) -> None:
|
||||||
|
"""The DAO's int() would silently coerce "1"/0/1.0 into a state the
|
||||||
|
caller never asked for; the business layer refuses non-bools."""
|
||||||
|
async def _body(database):
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
with pytest.raises(ValueError, match="is_active must be a real bool"):
|
||||||
|
await set_child_active(database, child.id, bad)
|
||||||
|
# The refused transition left the child untouched.
|
||||||
|
assert (await list_children(database))[0].is_active is True
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "set-active-non-bool.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Concurrency: edit/set_active are atomic under the connection lock
|
# Concurrency: edit/set_active are atomic under the connection lock
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -575,3 +726,117 @@ def test_list_children_orders_by_sort_order_then_id(tmp_path) -> None:
|
|||||||
return listed
|
return listed
|
||||||
|
|
||||||
_with_db(tmp_path, "list-order.db")(_body)
|
_with_db(tmp_path, "list-order.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deactivation keeps every referencing row intact (Feature 03)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_deactivating_child_preserves_definitions_instances_events(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
|
from custom_components.nestquest.dao_instances import (
|
||||||
|
CompletionEventsDao,
|
||||||
|
QuestInstancesDao,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.dao_rules import (
|
||||||
|
QuestDefinitionsDao,
|
||||||
|
ScheduleRulesDao,
|
||||||
|
)
|
||||||
|
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
rules = ScheduleRulesDao(database)
|
||||||
|
definitions = QuestDefinitionsDao(database)
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
events = CompletionEventsDao(database)
|
||||||
|
|
||||||
|
child = await create_child(database, "Ada")
|
||||||
|
rule = await rules.create("daily", "2026-01-01")
|
||||||
|
definition = await definitions.create(
|
||||||
|
"Brush teeth",
|
||||||
|
rule.id,
|
||||||
|
"2026-09-15T12:00:00+00:00",
|
||||||
|
assignee_child_ids=[child.id],
|
||||||
|
)
|
||||||
|
due = (date.today() + timedelta(days=1)).isoformat()
|
||||||
|
stamp = f"{due}T08:00:00+00:00"
|
||||||
|
instance = await instances.upsert(
|
||||||
|
definition.id, child.id, due, stamp, window="morning"
|
||||||
|
)
|
||||||
|
event = await events.append(
|
||||||
|
instance.id,
|
||||||
|
child.id,
|
||||||
|
"completed",
|
||||||
|
"panel",
|
||||||
|
f"{due}T09:00:00+00:00",
|
||||||
|
True,
|
||||||
|
actor_child_id=child.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deactivate: the referencing rows must not move.
|
||||||
|
deactivated = await set_child_active(database, child.id, False)
|
||||||
|
assert deactivated.is_active is False
|
||||||
|
assert await children.get(child.id) == deactivated
|
||||||
|
assert (await definitions.get(definition.id)) == definition
|
||||||
|
assert (await instances.get_by_id(instance.id)) == instance
|
||||||
|
assert await events.list_by_instance(instance.id) == [event]
|
||||||
|
# Excluded from the active list, still present in the full list.
|
||||||
|
assert await list_children(database, active_only=True) == []
|
||||||
|
assert [c.id for c in await list_children(database)] == [child.id]
|
||||||
|
|
||||||
|
# Reactivate: fully restored.
|
||||||
|
reactivated = await set_child_active(database, child.id, True)
|
||||||
|
assert reactivated.is_active is True
|
||||||
|
assert [c.id for c in await list_children(database, active_only=True)] == [
|
||||||
|
child.id
|
||||||
|
]
|
||||||
|
assert (await instances.get_by_id(instance.id)) == instance
|
||||||
|
return reactivated
|
||||||
|
|
||||||
|
_with_db(tmp_path, "deactivate-data-intact.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_code_path_deletes_a_child_row() -> None:
|
||||||
|
"""Structural guardrail: children are deactivated, never deleted.
|
||||||
|
|
||||||
|
The DAO exposes no delete method at all, and no module in the
|
||||||
|
package (nor any test outside the sanctioned DDL/migration layers,
|
||||||
|
which create the tables) carries a statement that removes child
|
||||||
|
rows — the leak guard in test_dao_children enforces the file
|
||||||
|
allowlist, this asserts the no-delete property itself across every
|
||||||
|
file that may legitimately name the table.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
package = Path(
|
||||||
|
__import__(
|
||||||
|
"custom_components.nestquest", fromlist=["__file__"]
|
||||||
|
).__file__
|
||||||
|
).parent
|
||||||
|
|
||||||
|
delete_pattern = re.compile(
|
||||||
|
r"DELETE\s+(FROM\s+)?((main|temp)\s*\.\s*)?[`'\"]*(\[)?children\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
for py in sorted(package.rglob("*.py")):
|
||||||
|
assert delete_pattern.search(py.read_text()) is None, (
|
||||||
|
f"a code path in {py.name} deletes child rows"
|
||||||
|
)
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from custom_components.nestquest.dao_children import ChildrenDao
|
||||||
|
|
||||||
|
methods = {
|
||||||
|
name
|
||||||
|
for name, _ in inspect.getmembers(ChildrenDao, inspect.isfunction)
|
||||||
|
}
|
||||||
|
assert not any(
|
||||||
|
name.startswith("delete") or name == "remove" for name in methods
|
||||||
|
), f"ChildrenDao exposed a delete-shaped method: {methods}"
|
||||||
|
|||||||
@@ -227,24 +227,34 @@ def test_reorder_is_transactional_and_idempotent(tmp_path) -> None:
|
|||||||
_run(database.close())
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
def test_reorder_empty_list_is_noop(tmp_path) -> None:
|
def test_reorder_empty_list_is_rejected(tmp_path) -> None:
|
||||||
|
"""An empty list is the maximally partial reorder: rejected while
|
||||||
|
any child exists (Feature 03 reorder contract)."""
|
||||||
|
import pytest as pytest_module
|
||||||
|
|
||||||
database = _open_db(tmp_path / "reorder-empty.db")
|
database = _open_db(tmp_path / "reorder-empty.db")
|
||||||
try:
|
try:
|
||||||
dao, _ = _daos(database)
|
dao, _ = _daos(database)
|
||||||
_make_child(dao, "Ada")
|
_make_child(dao, "Ada")
|
||||||
_run(dao.reorder([]))
|
with pytest_module.raises(ValueError, match="missing child ids"):
|
||||||
|
_run(dao.reorder([]))
|
||||||
children = _run(dao.list_all())
|
children = _run(dao.list_all())
|
||||||
assert children[0].sort_order == 0
|
assert children[0].sort_order == 0
|
||||||
finally:
|
finally:
|
||||||
_run(database.close())
|
_run(database.close())
|
||||||
|
|
||||||
|
|
||||||
def test_reorder_unknown_id_leaves_it_untouched(tmp_path) -> None:
|
def test_reorder_unknown_id_is_rejected(tmp_path) -> None:
|
||||||
|
"""An unknown id is rejected before any write; the order is
|
||||||
|
untouched (Feature 03 reorder contract)."""
|
||||||
|
import pytest as pytest_module
|
||||||
|
|
||||||
database = _open_db(tmp_path / "reorder-unknown.db")
|
database = _open_db(tmp_path / "reorder-unknown.db")
|
||||||
try:
|
try:
|
||||||
dao, _ = _daos(database)
|
dao, _ = _daos(database)
|
||||||
a = _make_child(dao, "Ada")
|
a = _make_child(dao, "Ada")
|
||||||
_run(dao.reorder([a.id, 999]))
|
with pytest_module.raises(ValueError, match="unknown child ids"):
|
||||||
|
_run(dao.reorder([a.id, 999]))
|
||||||
children = _run(dao.list_all())
|
children = _run(dao.list_all())
|
||||||
assert children[0].sort_order == 0
|
assert children[0].sort_order == 0
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in New Issue
Block a user