Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c4617459 | ||
|
|
4172b286ff | ||
|
|
88b4733a2d |
@@ -1118,95 +1118,3 @@ describe("DefinitionsTab icon picker", () => {
|
||||
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Start date ─────────────────────────────────────────────────────── */
|
||||
|
||||
function startDateInput(s: HTMLElement): HTMLInputElement {
|
||||
return within(s).getByLabelText("Start date", { selector: "input" }) as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe("DefinitionsTab start date", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("a new task defaults to today and POSTs the chosen start date", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
|
||||
await renderReady();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
const input = startDateInput(s);
|
||||
expect(input.type).toBe("date");
|
||||
expect(input.value).toBe("2026-09-23");
|
||||
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Feed the cat" },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
|
||||
fireEvent.change(input, { target: { value: "2026-10-15" } });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("POST");
|
||||
expect((api.writes()[0].body as { rule: DefinitionRule }).rule.start_date).toBe("2026-10-15");
|
||||
});
|
||||
|
||||
it("an edit pre-fills the definition's start date and PATCHes a changed one", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
const input = startDateInput(s);
|
||||
expect(input.value).toBe("2026-01-01");
|
||||
|
||||
fireEvent.change(input, { target: { value: "2026-11-02" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toEqual([
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
path: "/api/v1/admin/quest-definitions/10",
|
||||
body: expect.objectContaining({ rule: rule({ start_date: "2026-11-02" }) }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("the occurrence preview requests the chosen start date", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
await waitFor(() => expect(api.previews()).toHaveLength(1));
|
||||
|
||||
fireEvent.change(startDateInput(s), { target: { value: "2027-03-01" } });
|
||||
await settleDebounce();
|
||||
await waitFor(() => expect(api.previews()).toHaveLength(2));
|
||||
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.start_date).toBe(
|
||||
"2027-03-01",
|
||||
);
|
||||
});
|
||||
|
||||
it("a cleared start date is not submitted", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
fireEvent.change(startDateInput(s), { target: { value: "" } });
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
expect(within(s).getByText("Pick a start date.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,7 +177,6 @@ function parseWhole(value: string, min: number, max: number): number | null {
|
||||
function buildRule(draft: Draft): DefinitionRule | string {
|
||||
const interval = parseWhole(draft.interval, 1, 999);
|
||||
if (interval === null) return "Interval must be a whole number of at least 1.";
|
||||
if (draft.startDate === "") return "Pick a start date.";
|
||||
const rule: DefinitionRule = {
|
||||
rule_type: "daily",
|
||||
interval,
|
||||
@@ -741,16 +740,6 @@ function EditSheet({
|
||||
<OccurrencePreview ruleKey={previewKey} />
|
||||
</Field>
|
||||
|
||||
<Field label="Start date" id="defs-label-start">
|
||||
<input
|
||||
className="defs-input"
|
||||
type="date"
|
||||
aria-labelledby="defs-label-start"
|
||||
value={draft.startDate}
|
||||
onChange={(e) => update({ startDate: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Due time" id="defs-label-due">
|
||||
{orderedWindows.length === 0 ? (
|
||||
<p className="defs-hint">Pick a window to set its due time.</p>
|
||||
|
||||
@@ -235,11 +235,6 @@ async def create_quest_definition(
|
||||
transaction, so the returned bundle is a consistent creation
|
||||
snapshot. Raises ValueError on any rejected argument; a rejected or
|
||||
failed create persists nothing.
|
||||
|
||||
After the write, :func:`~.materialize.regenerate_for_definition`
|
||||
materializes the new definition's instances over the rolling horizon
|
||||
(host clock, :data:`~.const.DEFAULT_HORIZON_DAYS`), so it reaches the
|
||||
board without waiting for an unrelated write.
|
||||
"""
|
||||
name = _validate_text(title, "title", required=True)
|
||||
assert name is not None
|
||||
@@ -274,9 +269,6 @@ async def create_quest_definition(
|
||||
) from error
|
||||
raise
|
||||
decoded_rule = schedule_rule_from_storage(storage)
|
||||
|
||||
await regenerate_for_definition(database, snapshot.definition.id)
|
||||
|
||||
return CreatedQuestDefinition(
|
||||
definition=snapshot.definition,
|
||||
rule=decoded_rule,
|
||||
|
||||
@@ -514,10 +514,8 @@ async def test_regenerate_household_never_duplicates(
|
||||
client = admin_household.client
|
||||
database = admin_household.database
|
||||
|
||||
# The seed's creates materialized the horizon BEFORE Ada's custody
|
||||
# pattern existed, so the first household walk may legitimately
|
||||
# drop her away-day rows; only the second run must be stable.
|
||||
assert await _instance_count(database) > 0
|
||||
before = await _instance_count(database)
|
||||
assert before > 0 # the seed already materialized a few days
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
@@ -527,8 +525,9 @@ async def test_regenerate_household_never_duplicates(
|
||||
assert body["scope"] == "household"
|
||||
assert body["definition_id"] is None
|
||||
assert body["child_id"] is None
|
||||
assert body["count"] > 0
|
||||
assert body["count"] >= before
|
||||
after_first = await _instance_count(database)
|
||||
assert after_first >= before
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/admin/regenerate", json={}, headers=_admin_headers()
|
||||
@@ -545,18 +544,17 @@ async def test_regenerate_definition_scope_rematerializes(
|
||||
) -> None:
|
||||
"""The definition scope rebuilds that definition's horizon, twice, stably.
|
||||
|
||||
Creating a definition through the admin route already materializes
|
||||
its horizon (the core's create path regenerates); the scoped
|
||||
regenerate call rebuilds the same rows, and re-running it leaves
|
||||
the row count exactly where the create left it.
|
||||
Creating a definition through the admin route materializes NOTHING
|
||||
(the core's create path does not regenerate); the scoped regenerate
|
||||
call is what materializes its instances, and re-running it leaves
|
||||
the row count exactly where the first run left it.
|
||||
"""
|
||||
client = admin_api.client
|
||||
database = admin_api.database
|
||||
ada = await _create_child(client, "Ada")
|
||||
definition_id = await _create_daily_definition(client, [ada])
|
||||
|
||||
created_count = await _instance_count(database)
|
||||
assert created_count > 0
|
||||
assert await _instance_count(database) == 0
|
||||
|
||||
first = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
@@ -570,7 +568,7 @@ async def test_regenerate_definition_scope_rematerializes(
|
||||
assert body["child_id"] is None
|
||||
assert body["count"] > 0
|
||||
after_first = await _instance_count(database)
|
||||
assert after_first == body["count"] == created_count
|
||||
assert after_first == body["count"]
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/admin/regenerate",
|
||||
|
||||
@@ -151,17 +151,12 @@ async def _seed_missed_household(
|
||||
database, yesterday_iso, yesterday_iso, today=yesterday
|
||||
)
|
||||
# Today's quest: created after the yesterday walk so it has no
|
||||
# past-due instance; ``end_date`` keeps it (and create's horizon
|
||||
# regeneration) to today only.
|
||||
# past-due instance, then materialized for today only.
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Recoverable chore",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": today_iso,
|
||||
"end_date": today_iso,
|
||||
}
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("afternoon", "17:00")],
|
||||
|
||||
@@ -149,18 +149,13 @@ async def _seed_household(
|
||||
(today + datetime.timedelta(days=3)).isoformat(),
|
||||
today=today,
|
||||
)
|
||||
# The stale quest's rule ends YESTERDAY, so create's own
|
||||
# regeneration writes nothing and its only instance is the
|
||||
# yesterday one written below.
|
||||
# The stale quest is created AFTER the today walk, so its only
|
||||
# instance is the yesterday one written below.
|
||||
await _quest_definitions.create_quest_definition(
|
||||
database,
|
||||
"Stale chore",
|
||||
_recurrence.ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": yesterday_iso,
|
||||
"end_date": yesterday_iso,
|
||||
}
|
||||
{"rule_type": "daily", "start_date": yesterday_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("morning", "09:00")],
|
||||
|
||||
@@ -102,20 +102,15 @@ async def _seed_missed_household(
|
||||
database, "Old chore", rule, [ada.id], [("evening", "18:30")]
|
||||
)
|
||||
await materialize(database, yesterday_iso, yesterday_iso, today=yesterday)
|
||||
# A quest due TODAY only (created after the yesterday walk so it
|
||||
# has no past-due instance; ``end_date`` keeps create's horizon
|
||||
# regeneration to today): the post-downtime catch-up run's
|
||||
# A quest due TODAY (created after the yesterday walk so it has
|
||||
# no past-due instance): the post-downtime catch-up run's
|
||||
# target — inside [watermark=today, later_today) but not
|
||||
# inside the day-one sweep's window.
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Recoverable chore",
|
||||
ScheduleRule.from_dict(
|
||||
{
|
||||
"rule_type": "daily",
|
||||
"start_date": today_iso,
|
||||
"end_date": today_iso,
|
||||
}
|
||||
{"rule_type": "daily", "start_date": today_iso}
|
||||
),
|
||||
[ada.id],
|
||||
[("afternoon", "17:00")],
|
||||
|
||||
@@ -468,11 +468,7 @@ async def _materialize_with_change(
|
||||
proceeds with the snapped presence). This deterministically
|
||||
exercises the snapshot-to-insert gap that a config edit can
|
||||
interleave into.
|
||||
|
||||
``create_quest_definition`` already materializes its horizon, so the
|
||||
table is cleared first: the gated walk must be the only writer.
|
||||
"""
|
||||
await database.execute("DELETE FROM quest_instances")
|
||||
original_upsert = QuestInstancesDao.upsert_if_valid
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
@@ -168,42 +168,6 @@ def test_create_several_children_several_windows_with_due_times(
|
||||
_with_db(tmp_path, "create-many.db")(_body)
|
||||
|
||||
|
||||
def test_create_materializes_future_instances(tmp_path) -> None:
|
||||
"""A fresh create regenerates its rolling horizon immediately.
|
||||
|
||||
Without this the new definition has no ``quest_instances`` rows until
|
||||
some unrelated write regenerates it, so it never reaches the board.
|
||||
"""
|
||||
assert DEFAULT_HORIZON_DAYS >= 7
|
||||
|
||||
async def _body(database, children, rules, definitions, child):
|
||||
today = _today_iso()
|
||||
created = await create_quest_definition(
|
||||
database,
|
||||
"Take out bins",
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY,
|
||||
weekday_set={0},
|
||||
start_date=today,
|
||||
),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
rows = await database.fetch_all(
|
||||
"SELECT child_id, due_date FROM quest_instances "
|
||||
"WHERE definition_id = ?",
|
||||
(created.definition.id,),
|
||||
)
|
||||
assert rows, "create materialized no instances"
|
||||
assert {row[0] for row in rows} == {child.id}
|
||||
for _child_id, due_date in rows:
|
||||
assert due_date >= today
|
||||
assert datetime.date.fromisoformat(due_date).weekday() == 0
|
||||
return None
|
||||
|
||||
_with_db(tmp_path, "create-materializes.db")(_body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_quest_definition: argument rejections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user