Compare commits

...
Author SHA1 Message Date
overseerandClaude Opus 5.5 0d763519a0 Allow saving presence patterns that cover no weekday
The pattern editor disabled Save unless a weekday was selected, but an
all-empty weekday set is valid in the core model and the API, and
migration 9 produces one for a legacy all-absent schedule. Save now only
requires a name (plus a valid anchor date and a change to save).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:03:03 -04:00
overseerandClaude Opus 5.5 b91ecbd5f6 Reject non-integer weekday values in presence pattern requests
The create and edit pattern models typed weekday values as lax int, so
JSON true, "1" and 1.0 were coerced to weekday 1. Type them StrictInt
so each is a 422 naming the pattern field, and add create and edit
regression cases for bool, string, float and out-of-range weekdays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:55:43 -04:00
overseerandClaude Opus 5.5 39417615a2 Move the Admin PWA Schedule tab to multi-pattern presence
The admin client still called the removed one-per-child
/presence-schedule route, so the Schedule tab could not load or save
(review finding F1 on PR #231). The client now uses the presence-pattern
routes: list and create under /children/{id}/presence-patterns, and
PATCH / DELETE on /presence-patterns/{id}.

The Schedule screen lists every pattern of the selected child (name,
Home/Away, cycle length, weekday set) with Add, Edit and Delete, and a
pushed pattern editor sets name, kind, cycle length, anchor date and the
per-week weekday set. Edits send only the changed fields. The month grid
shows the combined presence with the core's first-match rule: override,
then any covering away pattern, then any covering home pattern, then
away when the child has a home pattern, else home. The grid keeps the
Sunday-first week; stored weekdays stay Monday=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:45:53 -04:00
overseer 1c2a7222b3 Merge remote-tracking branch 'origin/dev' into task/multi-pattern-presence-model_e63a8e64-2f1a-4818-baf2-0d0431bcd014 2026-09-24 19:39:22 -04:00
overseerandClaude Opus 5.5 ef6940d937 Replace presence schedules with multiple presence patterns per child (schema 9)
A child now carries zero or more home/away presence patterns instead of
at most one schedule. Migration 9 creates presence_patterns, copies every
presence_schedules row across as a 'home' pattern named "Home schedule"
(cycle, anchor and pattern verbatim), and drops the old table in one
transaction. Migration 1 keeps creating the retired table from a frozen
copy of its DDL; schema.py no longer declares it.

PresenceEngine resolves override -> any covering away pattern (absent)
-> any covering home pattern (present) -> otherwise absent when the child
has a home pattern, else present. The home-pattern default keeps every
migrated child's presence unchanged.

The admin plane's one-per-child presence-schedule routes are replaced by
pattern CRUD: GET/POST /children/{child_id}/presence-patterns and
PATCH/DELETE /presence-patterns/{pattern_id}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:31:34 -04:00
33 changed files with 4157 additions and 1381 deletions
+58 -38
View File
@@ -1,25 +1,35 @@
/** /**
* Typed client for the admin presence routes: a child's repeating schedule * Typed client for the admin presence routes: a child's repeating patterns
* (/api/v1/admin/children/{id}/presence-schedule) and the household's * (/api/v1/admin/children/{id}/presence-patterns, /presence-patterns/{id})
* date-range overrides (/api/v1/admin/presence-overrides). * and the household's date-range overrides (/api/v1/admin/presence-overrides).
*/ */
import { apiFetch } from "./client"; import { apiFetch } from "./client";
import { CHILDREN_PATH, readJson } from "./definitions"; import { CHILDREN_PATH, readJson } from "./definitions";
/** /** A pattern claims its covered days as the child being home or away. */
* Cycle week index ("0".."n-1", JSON object keys) -> present weekdays export type PatternKind = "home" | "away";
* (0 = Monday .. 6 = Sunday). An empty list is away that whole week.
*/
export type PresencePattern = Record<string, number[]>;
export interface PresenceScheduleBody { /**
* Cycle week index ("0".."n-1", JSON object keys) -> covered weekdays
* (0 = Monday .. 6 = Sunday). An empty list covers nothing that week.
*/
export type PatternWeeks = Record<string, number[]>;
/** The anchor-based cycle every pattern repeats on. */
export interface PatternCycle {
cycle_length_weeks: number; cycle_length_weeks: number;
/** "YYYY-MM-DD"; pins cycle week 0. */ /** "YYYY-MM-DD"; pins cycle week 0. */
anchor_date: string; anchor_date: string;
pattern: PresencePattern; pattern: PatternWeeks;
} }
export interface PresenceSchedule extends PresenceScheduleBody { export interface PresencePatternBody extends PatternCycle {
name: string;
kind: PatternKind;
}
export interface PresencePattern extends PresencePatternBody {
id: number;
child_id: number; child_id: number;
} }
@@ -40,30 +50,48 @@ export interface PresenceOverrideFilter {
} }
export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides"; export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
export const PRESENCE_PATTERNS_PATH = "/api/v1/admin/presence-patterns";
export function presenceSchedulePath(childId: number): string { export function childPatternsPath(childId: number): string {
return `${CHILDREN_PATH}/${childId}/presence-schedule`; return `${CHILDREN_PATH}/${childId}/presence-patterns`;
} }
/** The child's schedule, or null when it has none (present every day). */ function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
export async function fetchPresenceSchedule(childId: number): Promise<PresenceSchedule | null> { return apiFetch(path, {
const body = await readJson<{ schedule: PresenceSchedule | null }>( method,
await apiFetch(presenceSchedulePath(childId)), headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/** The child's patterns in id order; empty when it has none (present every day). */
export async function fetchPresencePatterns(childId: number): Promise<PresencePattern[]> {
const body = await readJson<{ patterns: PresencePattern[] }>(
await apiFetch(childPatternsPath(childId)),
); );
return body.schedule; return body.patterns;
} }
/** Set (upsert) the child's schedule; resolves to the schedule as stored. */ export async function createPresencePattern(
export async function savePresenceSchedule(
childId: number, childId: number,
body: PresenceScheduleBody, body: PresencePatternBody,
): Promise<PresenceSchedule> { ): Promise<PresencePattern> {
return readJson<PresenceSchedule>( return readJson<PresencePattern>(await sendJson(childPatternsPath(childId), "POST", body));
await apiFetch(presenceSchedulePath(childId), { }
method: "PUT",
headers: { "Content-Type": "application/json" }, /** Change only the supplied fields; resolves to the pattern as stored. */
body: JSON.stringify(body), export async function updatePresencePattern(
}), patternId: number,
body: Partial<PresencePatternBody>,
): Promise<PresencePattern> {
return readJson<PresencePattern>(
await sendJson(`${PRESENCE_PATTERNS_PATH}/${patternId}`, "PATCH", body),
);
}
export async function deletePresencePattern(patternId: number): Promise<void> {
await readJson<{ status: string }>(
await apiFetch(`${PRESENCE_PATTERNS_PATH}/${patternId}`, { method: "DELETE" }),
); );
} }
@@ -96,27 +124,19 @@ export interface PresenceOverrideBody extends PresenceOverrideRange {
export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`; export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`;
function postJson(path: string, body: unknown): Promise<Response> {
return apiFetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/** /**
* How many open upcoming tasks saving this override would remove. Computed * How many open upcoming tasks saving this override would remove. Computed
* by the API before anything is saved; completed instances never count. * by the API before anything is saved; completed instances never count.
*/ */
export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise<number> { export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise<number> {
const body = await readJson<{ removed: number }>( const body = await readJson<{ removed: number }>(
await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range), await sendJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, "POST", range),
); );
return body.removed; return body.removed;
} }
export async function createPresenceOverride(body: PresenceOverrideBody): Promise<PresenceOverride> { export async function createPresenceOverride(body: PresenceOverrideBody): Promise<PresenceOverride> {
return readJson<PresenceOverride>(await postJson(PRESENCE_OVERRIDES_PATH, body)); return readJson<PresenceOverride>(await sendJson(PRESENCE_OVERRIDES_PATH, "POST", body));
} }
export async function deletePresenceOverride(overrideId: number): Promise<void> { export async function deletePresenceOverride(overrideId: number): Promise<void> {
+9
View File
@@ -95,6 +95,15 @@
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.override-segments--7 {
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.override-segments--7 .override-segment {
padding: 0;
}
.override-segment { .override-segment {
min-height: var(--nq-a-tap-min); min-height: var(--nq-a-tap-min);
padding: 0 8px; padding: 0 8px;
+12 -9
View File
@@ -4,7 +4,7 @@ import App from "../App";
import ScheduleTab from "./ScheduleTab"; import ScheduleTab from "./ScheduleTab";
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor"; import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
import type { AdminChild } from "../api/definitions"; import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresenceSchedule } from "../api/presence"; import type { PresenceOverride, PresencePattern } from "../api/presence";
import { storeTokens } from "../auth/oidc"; import { storeTokens } from "../auth/oidc";
function child(id: number, display_name: string, sort_order: number): AdminChild { function child(id: number, display_name: string, sort_order: number): AdminChild {
@@ -13,8 +13,11 @@ function child(id: number, display_name: string, sort_order: number): AdminChild
const CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)]; const CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)];
const DECLAN: PresenceSchedule = { const DECLAN: PresencePattern = {
id: 1,
child_id: 1, child_id: 1,
name: "Home weeks",
kind: "home",
cycle_length_weeks: 2, cycle_length_weeks: 2,
anchor_date: "2026-09-07", anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] }, pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
@@ -72,9 +75,9 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
return jsonResponse({ status: "ok" }); return jsonResponse({ status: "ok" });
} }
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN }); if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const scheduleMatch = /\/children\/(\d+)\/presence-schedule$/.exec(path); const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (scheduleMatch) { if (patternsMatch) {
return jsonResponse({ schedule: Number(scheduleMatch[1]) === 1 ? DECLAN : null }); return jsonResponse({ patterns: Number(patternsMatch[1]) === 1 ? [DECLAN] : [] });
} }
if (path === OVERRIDES_PATH) return jsonResponse({ overrides }); if (path === OVERRIDES_PATH) return jsonResponse({ overrides });
return jsonResponse({ detail: "unexpected" }, 500); return jsonResponse({ detail: "unexpected" }, 500);
@@ -293,7 +296,7 @@ describe("OverrideEditor", () => {
}); });
await waitFor(() => expect(saveButton().disabled).toBe(false)); await waitFor(() => expect(saveButton().disabled).toBe(false));
const before = calls().length; const before = calls().length;
const schedulesBefore = calls().filter((c) => c.path.endsWith("/presence-schedule")).length; const patternsBefore = calls().filter((c) => c.path.endsWith("/presence-patterns")).length;
fireEvent.click(saveButton()); fireEvent.click(saveButton());
const row = await screen.findByTestId("override-50"); const row = await screen.findByTestId("override-50");
@@ -321,10 +324,10 @@ describe("OverrideEditor", () => {
}, },
]); ]);
expect(after.some((c) => c.method === "DELETE")).toBe(false); expect(after.some((c) => c.method === "DELETE")).toBe(false);
// The overrides and the presence schedules are refetched for the grid. // The overrides and the presence patterns are refetched for the grid.
expect(after.some((c) => c.method === "GET" && c.path === OVERRIDES_PATH)).toBe(true); expect(after.some((c) => c.method === "GET" && c.path === OVERRIDES_PATH)).toBe(true);
expect(calls().filter((c) => c.path.endsWith("/presence-schedule")).length).toBeGreaterThan( expect(calls().filter((c) => c.path.endsWith("/presence-patterns")).length).toBeGreaterThan(
schedulesBefore, patternsBefore,
); );
// The grid reflects the new override. // The grid reflects the new override.
expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe( expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe(
+20 -15
View File
@@ -39,7 +39,7 @@ type Consequence =
| { kind: "ready"; removed: number } | { kind: "ready"; removed: number }
| { kind: "error"; message: string }; | { kind: "error"; message: string };
function errorMessage(error: unknown, fallback: string): string { export function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiForbiddenError) return error.message; if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail; if (error instanceof ApiRequestError && error.detail) return error.detail;
return fallback; return fallback;
@@ -71,6 +71,24 @@ export function inertOutside(root: HTMLElement): () => void {
}; };
} }
/** Keep Tab / Shift+Tab cycling inside `root` (a pushed modal screen). */
export function trapTab(event: ReactKeyboardEvent<HTMLElement>, root: HTMLElement | null): void {
if (event.key !== "Tab" || !root) return;
const focusable = Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
const outside = !focusable.includes(current as HTMLElement);
if (event.shiftKey && (current === first || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
}
function tasksPhrase(count: number): string { function tasksPhrase(count: number): string {
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`; return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
} }
@@ -118,20 +136,7 @@ export default function OverrideEditor({
if (!saving) onCancel(); if (!saving) onCancel();
return; return;
} }
if (event.key !== "Tab" || !screenRef.current) return; trapTab(event, screenRef.current);
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
const outside = !focusable.includes(current as HTMLElement);
if (event.shiftKey && (current === first || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
} }
const child = childOptions.find((c) => c.id === childId) ?? childOptions[0]; const child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
+255
View File
@@ -0,0 +1,255 @@
/**
* Presence pattern editor — a pushed screen like the override editor.
*
* Adds a pattern to the child's collection (POST) or edits one in place
* (PATCH with only the changed fields). The weekday numbering sent to the
* API stays Monday=0 .. Sunday=6; only the display is Sunday-first.
*/
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import type { AdminChild } from "../api/definitions";
import {
createPresencePattern,
updatePresencePattern,
type PatternKind,
type PatternWeeks,
type PresencePattern,
type PresencePatternBody,
} from "../api/presence";
import {
DISPLAY_WEEKDAYS,
WEEKDAY_SHORT,
dayNumber,
emptyPattern,
isoFromDayNumber,
resizePattern,
togglePatternDay,
} from "./cycle";
import { anchorLabel, shortDate } from "./format";
import { ChevronLeftGlyph } from "./glyphs";
import { errorMessage, inertOutside, isIsoDate, trapTab } from "./OverrideEditor";
import "./OverrideEditor.css";
export const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
const WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
/** The fields of `next` that differ from the stored pattern. */
export function changedFields(
stored: PresencePatternBody,
next: PresencePatternBody,
): Partial<PresencePatternBody> {
const changes: Partial<PresencePatternBody> = {};
if (next.name !== stored.name) changes.name = next.name;
if (next.kind !== stored.kind) changes.kind = next.kind;
if (next.cycle_length_weeks !== stored.cycle_length_weeks) {
changes.cycle_length_weeks = next.cycle_length_weeks;
}
if (next.anchor_date !== stored.anchor_date) changes.anchor_date = next.anchor_date;
const samePattern = Array.from({ length: next.cycle_length_weeks }, (_, week) => week).every(
(week) =>
JSON.stringify([...(stored.pattern[week] ?? [])].sort()) ===
JSON.stringify([...(next.pattern[week] ?? [])].sort()),
);
if (!samePattern || changes.cycle_length_weeks !== undefined) changes.pattern = next.pattern;
return changes;
}
export interface PatternEditorProps {
child: AdminChild;
/** The pattern being edited, or null to add a new one. */
pattern: PresencePattern | null;
/** "YYYY-MM-DD"; a new pattern's anchor starts here. */
today: string;
onCancel: () => void;
onSaved: (pattern: PresencePattern) => void;
}
export default function PatternEditor({ child, pattern, today, onCancel, onSaved }: PatternEditorProps) {
const [name, setName] = useState(pattern?.name ?? "");
const [kind, setKind] = useState<PatternKind>(pattern?.kind ?? "away");
const [cycleLength, setCycleLength] = useState(pattern?.cycle_length_weeks ?? 1);
const [anchor, setAnchor] = useState(pattern?.anchor_date ?? today);
const [weeks, setWeeks] = useState<PatternWeeks>(pattern?.pattern ?? emptyPattern(1));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
titleRef.current?.focus();
return screenRef.current ? inertOutside(screenRef.current) : undefined;
}, []);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!saving) onCancel();
return;
}
trapTab(event, screenRef.current);
}
const body: PresencePatternBody = {
name: name.trim(),
kind,
cycle_length_weeks: cycleLength,
anchor_date: anchor,
pattern: resizePattern(weeks, cycleLength),
};
const changes = pattern ? changedFields(pattern, body) : body;
// A pattern covering no day is valid (e.g. a migrated all-absent home schedule).
const canSave = body.name !== "" && isIsoDate(anchor) && Object.keys(changes).length > 0 && !saving;
function save() {
if (!canSave) return;
setSaving(true);
setError(null);
const request = pattern
? updatePresencePattern(pattern.id, changes)
: createPresencePattern(child.id, body);
request.then(onSaved).catch((err: unknown) => {
setError(errorMessage(err, "The pattern could not be saved. Check your connection and try again."));
setSaving(false);
});
}
const title = pattern ? "Edit pattern" : "New pattern";
return (
<div
ref={screenRef}
className="override-screen"
role="dialog"
aria-modal="true"
aria-labelledby="pattern-title"
onKeyDown={onKeyDown}
>
<header className="override-header">
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
<ChevronLeftGlyph size={20} />
</button>
<div>
<h2 className="override-title" id="pattern-title" ref={titleRef} tabIndex={-1}>
{title}
</h2>
<p className="override-sub">Repeating presence for {child.display_name}</p>
</div>
</header>
<div className="override-scroll">
<section className="override-card" aria-label="Pattern details">
<label className="override-field">
<span className="override-label">Name</span>
<input
className="override-input"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
<div className="override-field">
<span className="override-label" id="pattern-label-kind">
Covered days are
</span>
<div className="override-segments override-segments--2" role="group" aria-labelledby="pattern-label-kind">
{(["away", "home"] as PatternKind[]).map((value) => (
<button
key={value}
type="button"
className="override-segment override-segment--ink"
aria-pressed={kind === value}
onClick={() => setKind(value)}
>
{value === "away" ? "Away" : "Home"}
</button>
))}
</div>
</div>
<div className="override-dates">
<label className="override-field">
<span className="override-label">Cycle length</span>
<select
className="override-input"
value={cycleLength}
onChange={(event) => {
const next = Number(event.target.value);
setCycleLength(next);
setWeeks((current) => resizePattern(current, next));
}}
>
{CYCLE_LENGTH_CHOICES.map((count) => (
<option key={count} value={count}>
{count} {count === 1 ? "week" : "weeks"}
</option>
))}
</select>
</label>
<label className="override-field">
<span className="override-label">Anchor date</span>
<input
className="override-input override-date"
type="date"
value={anchor}
onChange={(event) => setAnchor(event.target.value)}
/>
</label>
</div>
{Array.from({ length: cycleLength }, (_, week) => {
const days = body.pattern[week];
const from = isIsoDate(anchor)
? shortDate(isoFromDayNumber(dayNumber(anchor) + week * 7))
: null;
return (
<div key={week} className="override-field">
<span className="override-label" id={`pattern-label-week-${week}`}>
Week {week + 1}
{from ? ` · from ${from}` : ""}
</span>
<div
className="override-segments override-segments--7"
role="group"
aria-labelledby={`pattern-label-week-${week}`}
>
{DISPLAY_WEEKDAYS.map((weekday) => (
<button
key={weekday}
type="button"
className="override-segment override-segment--ink"
aria-pressed={days.includes(weekday)}
aria-label={WEEKDAY_NAMES[weekday]}
onClick={() => setWeeks((current) => togglePatternDay(current, week, weekday))}
>
{WEEKDAY_SHORT[weekday].slice(0, 2)}
</button>
))}
</div>
</div>
);
})}
<p className="override-sub">
{isIsoDate(anchor)
? `Week 1 starts ${anchorLabel(anchor)}; the cycle repeats from there.`
: "Choose an anchor date."}
</p>
</section>
{error ? (
<p className="override-error" role="alert" data-testid="pattern-error">
{error}
</p>
) : null}
<div className="override-footer">
<button type="button" className="override-cancel" onClick={onCancel} disabled={saving}>
Cancel
</button>
<button type="button" className="override-save" onClick={save} disabled={!canSave}>
{saving ? "Saving…" : "Save pattern"}
</button>
</div>
</div>
</div>
);
}
+11 -64
View File
@@ -119,14 +119,13 @@
} }
.schedule-day { .schedule-day {
display: grid;
place-items: center;
aspect-ratio: 1; aspect-ratio: 1;
padding: 0;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 6px; border-radius: 6px;
font-family: inherit;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
cursor: pointer;
} }
.schedule-day--home { .schedule-day--home {
@@ -184,60 +183,11 @@
border-radius: 3px; border-radius: 3px;
} }
/* Pattern editing controls */ .schedule-legend-note {
.schedule-controls { margin: 8px 0 0;
display: flex; font-size: 10.5px;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--nq-a-divider);
}
.schedule-field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
.schedule-field input,
.schedule-field select {
min-height: 36px;
padding: 0 8px;
border: 1px solid var(--nq-a-border);
border-radius: 6px;
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 600; font-weight: 600;
text-transform: none; color: var(--nq-a-ink-tertiary);
letter-spacing: normal;
}
.schedule-save {
min-height: 36px;
margin-left: auto;
padding: 0 14px;
border: 0;
border-radius: var(--nq-a-density-radius);
background: var(--nq-brand-gradient);
color: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.schedule-save:disabled {
opacity: 0.45;
cursor: default;
} }
.schedule-save-status { .schedule-save-status {
@@ -251,7 +201,7 @@
color: var(--nq-a-danger-strong); color: var(--nq-a-danger-strong);
} }
/* Presence pattern + overrides cards */ /* Presence patterns + overrides cards */
.schedule-section-head { .schedule-section-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -318,13 +268,6 @@
color: var(--nq-a-ink-secondary); color: var(--nq-a-ink-secondary);
} }
.schedule-rule {
flex: none;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.schedule-icon-action { .schedule-icon-action {
flex: none; flex: none;
display: grid; display: grid;
@@ -339,6 +282,10 @@
cursor: pointer; cursor: pointer;
} }
.schedule-icon-action--edit {
margin-right: -4px;
}
.schedule-empty { .schedule-empty {
padding: var(--nq-a-density-card-pad); padding: var(--nq-a-density-card-pad);
font-size: 13px; font-size: 13px;
+459 -88
View File
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import ScheduleTab from "./ScheduleTab"; import ScheduleTab from "./ScheduleTab";
import type { AdminChild } from "../api/definitions"; import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresenceSchedule } from "../api/presence"; import type { PresenceOverride, PresencePattern } from "../api/presence";
import { storeTokens } from "../auth/oidc"; import { storeTokens } from "../auth/oidc";
import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle";
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild { function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active }; return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active };
@@ -16,20 +17,39 @@ const CHILDREN = [
child(9, "Former", 4, false), child(9, "Former", 4, false),
]; ];
// Declan: anchor Monday 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun. // Declan: the migrated single schedule — one `home` pattern anchored Monday
// Keys arrive as JSON strings, exactly as the API serializes them. // 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun only. Keys arrive
const DECLAN: PresenceSchedule = { // as JSON strings, exactly as the API serializes them.
const DECLAN_HOME: PresencePattern = {
id: 1,
child_id: 1, child_id: 1,
name: "Home weeks",
kind: "home",
cycle_length_weeks: 2, cycle_length_weeks: 2,
anchor_date: "2026-09-07", anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] }, pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
}; };
const MAEVE: PresenceSchedule = { // Maeve: the owner scenario — away Thursday+Friday every week, and away
// weekends every 2 weeks (the "on" week is cycle week 0 from Sep 7).
const MAEVE_THU_FRI: PresencePattern = {
id: 2,
child_id: 2, child_id: 2,
cycle_length_weeks: 4, name: "Thursday+Friday",
kind: "away",
cycle_length_weeks: 1,
anchor_date: "2026-09-07", anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [], "2": [0, 1, 2, 3, 4, 5, 6], "3": [] }, pattern: { "0": [3, 4] },
};
const MAEVE_WEEKENDS: PresencePattern = {
id: 3,
child_id: 2,
name: "Alternate weekends",
kind: "away",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [5, 6], "1": [] },
}; };
const OVERRIDES: PresenceOverride[] = [ const OVERRIDES: PresenceOverride[] = [
@@ -39,6 +59,7 @@ const OVERRIDES: PresenceOverride[] = [
]; ];
const TODAY = "2026-09-23"; const TODAY = "2026-09-23";
const PATTERNS_PATH = "/api/v1/admin/presence-patterns";
function jsonResponse(body: unknown, status = 200): Response { function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
@@ -47,17 +68,69 @@ function jsonResponse(body: unknown, status = 200): Response {
}); });
} }
interface Call {
method: string;
path: string;
body: unknown;
}
/** The server's pattern store; POST/PATCH/DELETE mutate it like the API. */
let patterns: Record<number, PresencePattern[]>;
let nextId: number;
let writeFailure: Response | null;
let fetchMock: ReturnType<typeof vi.fn>; let fetchMock: ReturnType<typeof vi.fn>;
let putResponse: (body: unknown) => Response;
function calls(): Call[] {
return fetchMock.mock.calls.map((args: unknown[]) => {
const [url, init] = args as [string, RequestInit | undefined];
return {
method: init?.method ?? "GET",
path: new URL(String(url), "http://x").pathname,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
};
});
}
function writes(): Call[] {
return calls().filter((c) => c.method !== "GET");
}
function findPattern(id: number): PresencePattern | undefined {
return Object.values(patterns)
.flat()
.find((p) => p.id === id);
}
function routeFetch(url: string, init: RequestInit = {}): Response { function routeFetch(url: string, init: RequestInit = {}): Response {
const path = new URL(url, "http://x").pathname; const path = new URL(url, "http://x").pathname;
if (init.method === "PUT") return putResponse(JSON.parse(String(init.body))); const method = init.method ?? "GET";
const body = init.body ? JSON.parse(String(init.body)) : undefined;
if (method !== "GET" && writeFailure) return writeFailure;
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN }); if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const match = /\/children\/(\d+)\/presence-schedule$/.exec(path); const collection = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (match) { if (collection) {
const id = Number(match[1]); const childId = Number(collection[1]);
return jsonResponse({ schedule: id === 1 ? DECLAN : id === 2 ? MAEVE : null }); if (method === "POST") {
const stored: PresencePattern = { id: nextId++, child_id: childId, ...body };
patterns[childId] = [...(patterns[childId] ?? []), stored];
return jsonResponse(stored, 201);
}
return jsonResponse({ patterns: patterns[childId] ?? [] });
}
const single = /\/presence-patterns\/(\d+)$/.exec(path);
if (single) {
const id = Number(single[1]);
const existing = findPattern(id);
if (!existing) return jsonResponse({ detail: "Presence pattern not found" }, 404);
if (method === "PATCH") {
const updated = { ...existing, ...body };
patterns[existing.child_id] = patterns[existing.child_id].map((p) => (p.id === id ? updated : p));
return jsonResponse(updated);
}
if (method === "DELETE") {
patterns[existing.child_id] = patterns[existing.child_id].filter((p) => p.id !== id);
return jsonResponse({ status: "ok" });
}
} }
if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES }); if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES });
return jsonResponse({ detail: "unexpected" }, 500); return jsonResponse({ detail: "unexpected" }, 500);
@@ -69,16 +142,32 @@ function day(date: string): HTMLElement {
return cell; return cell;
} }
function saveButton(): HTMLButtonElement {
return screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement;
}
function weekGroup(week: number): HTMLElement {
return screen.getByRole("group", { name: new RegExp(`^Week ${week}\\b`) });
}
async function renderReady() { async function renderReady() {
render(<ScheduleTab today={TODAY} />); render(<ScheduleTab today={TODAY} />);
await screen.findByTestId("pattern-1"); await screen.findByTestId("pattern-1");
} }
async function selectMaeve() {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
await screen.findByTestId("pattern-3");
}
describe("ScheduleTab", () => { describe("ScheduleTab", () => {
beforeEach(() => { beforeEach(() => {
sessionStorage.clear(); sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 }); storeTokens({ access_token: "at-123", expires_in: 600 });
putResponse = (body) => jsonResponse({ child_id: 1, ...(body as object) }); patterns = { 1: [DECLAN_HOME], 2: [MAEVE_THU_FRI, MAEVE_WEEKENDS], 3: [] };
nextId = 50;
writeFailure = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
}); });
@@ -88,27 +177,31 @@ describe("ScheduleTab", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("loads active children, their schedules and the overrides", async () => { it("loads active children, their patterns and the overrides", async () => {
await renderReady(); await renderReady();
const urls = fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x").pathname); const urls = calls().map((c) => c.path);
expect(urls).toContain("/api/v1/admin/children"); expect(urls).toContain("/api/v1/admin/children");
expect(urls).toContain("/api/v1/admin/children/1/presence-schedule"); expect(urls).toContain("/api/v1/admin/children/1/presence-patterns");
expect(urls).toContain("/api/v1/admin/children/2/presence-schedule"); expect(urls).toContain("/api/v1/admin/children/2/presence-patterns");
expect(urls).toContain("/api/v1/admin/children/3/presence-schedule"); expect(urls).toContain("/api/v1/admin/children/3/presence-patterns");
expect(urls).not.toContain("/api/v1/admin/children/9/presence-schedule"); expect(urls).not.toContain("/api/v1/admin/children/9/presence-patterns");
expect(urls).toContain("/api/v1/admin/presence-overrides"); expect(urls).toContain("/api/v1/admin/presence-overrides");
const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button"); const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]); expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]);
expect(chips[0].getAttribute("aria-pressed")).toBe("true"); expect(chips[0].getAttribute("aria-pressed")).toBe("true");
}); });
it("header shows the cycle length and anchor", async () => { it("header names the child and how many patterns it has", async () => {
await renderReady(); await renderReady();
expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy(); expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy();
expect(screen.getByText("2-week cycle · anchor Monday, Sep 7")).toBeTruthy(); expect(screen.getByText("Declan · 1 pattern")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
expect(screen.getByText("Maeve · 2 patterns")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
expect(screen.getByText("Rory · no patterns, home every day")).toBeTruthy();
}); });
it("month grid renders home, away, override and today states from the anchor", async () => { it("a home-pattern-only child is away on days no home pattern covers", async () => {
await renderReady(); await renderReady();
expect(screen.getByTestId("month-label").textContent).toBe("September 2026"); expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
const states: Record<string, string> = { const states: Record<string, string> = {
@@ -131,6 +224,8 @@ describe("ScheduleTab", () => {
expect(today.className).toContain("schedule-day--today"); expect(today.className).toContain("schedule-day--today");
expect(today.dataset.state).toBe("home"); expect(today.dataset.state).toBe("home");
expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home"); expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home");
// The grid is a read-out of the combined presence, not a set of toggles.
expect(today.tagName).toBe("SPAN");
// Sunday-first header; September 2026 starts on a Tuesday: two leading pads. // Sunday-first header; September 2026 starts on a Tuesday: two leading pads.
const grid = screen.getByRole("group", { name: "Month grid" }); const grid = screen.getByRole("group", { name: "Month grid" });
expect(Array.from(grid.children, (c) => c.textContent).slice(0, 7)).toEqual([ expect(Array.from(grid.children, (c) => c.textContent).slice(0, 7)).toEqual([
@@ -142,8 +237,8 @@ describe("ScheduleTab", () => {
"F", "F",
"S", "S",
]); ]);
expect(grid.children[7].tagName).toBe("SPAN"); expect(grid.children[7].getAttribute("data-date")).toBeNull();
expect(grid.children[8].tagName).toBe("SPAN"); expect(grid.children[8].getAttribute("data-date")).toBeNull();
expect(grid.children[9].getAttribute("data-date")).toBe("2026-09-01"); expect(grid.children[9].getAttribute("data-date")).toBe("2026-09-01");
const legend = screen.getByRole("list", { name: "Legend" }); const legend = screen.getByRole("list", { name: "Legend" });
@@ -154,6 +249,29 @@ describe("ScheduleTab", () => {
"Override · home", "Override · home",
"Today", "Today",
]); ]);
expect(screen.getByText(/Combined from every pattern/)).toBeTruthy();
});
it("the owner scenario: Thu/Fri away every week, Sat/Sun away only in the on week", async () => {
await selectMaeve();
for (const date of monthDates("2026-09-01")) {
if (date >= "2026-09-28") continue; // Camp override, checked below
const weekday = weekdayOf(date);
const onWeek = cycleWeekIndex("2026-09-07", date, 2) === 0;
const away = weekday === 3 || weekday === 4 || (weekday >= 5 && onWeek);
expect(day(date).dataset.state, date).toBe(away ? "away" : "home");
}
// Explicit spot checks of that rule.
expect(day("2026-09-03").dataset.state).toBe("away"); // Thu
expect(day("2026-09-04").dataset.state).toBe("away"); // Fri
expect(day("2026-09-05").dataset.state).toBe("home"); // Sat, off week (pre-anchor)
expect(day("2026-09-12").dataset.state).toBe("away"); // Sat, on week
expect(day("2026-09-13").dataset.state).toBe("away"); // Sun, on week
expect(day("2026-09-19").dataset.state).toBe("home"); // Sat, off week
expect(day("2026-09-20").dataset.state).toBe("home"); // Sun, off week
expect(day("2026-09-26").dataset.state).toBe("away"); // Sat, on week again
expect(day("2026-09-14").dataset.state).toBe("home"); // Mon
expect(day("2026-09-28").dataset.state).toBe("override-away");
}); });
it("navigates months with the chevrons", async () => { it("navigates months with the chevrons", async () => {
@@ -168,14 +286,36 @@ describe("ScheduleTab", () => {
expect(screen.getByTestId("month-label").textContent).toBe("September 2026"); expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
}); });
it("presence pattern card shows a rule per child", async () => { it("patterns list shows each pattern's name, kind, cycle and weekday set", async () => {
await renderReady(); await selectMaeve();
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy(); const list = screen.getByRole("list", { name: "Presence patterns" });
expect(within(screen.getByTestId("pattern-2")).getByText("Weeks 1 & 3 of cycle")).toBeTruthy(); expect(within(list).getAllByRole("listitem")).toHaveLength(2);
expect(within(screen.getByTestId("pattern-3")).getByText("Every day")).toBeTruthy(); const thuFri = within(list).getByTestId("pattern-2");
expect(within(thuFri).getByText("Thursday+Friday")).toBeTruthy();
expect(within(thuFri).getByText("Away · Every week")).toBeTruthy();
expect(within(thuFri).getByText("Thu, Fri")).toBeTruthy();
const weekends = within(list).getByTestId("pattern-3");
expect(within(weekends).getByText("Alternate weekends")).toBeTruthy();
expect(within(weekends).getByText("Away · Every 2 weeks")).toBeTruthy();
expect(within(weekends).getByText("Week 1: Sat, Sun · Week 2: No days")).toBeTruthy();
expect(within(weekends).getByRole("button", { name: "Edit Alternate weekends" })).toBeTruthy();
expect(within(weekends).getByRole("button", { name: "Delete Alternate weekends" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Declan" }));
const declan = screen.getByTestId("pattern-1");
expect(within(declan).getByText("Home · Every 2 weeks")).toBeTruthy();
expect(within(declan).getByText("Week 1: Every day · Week 2: Sat, Sun")).toBeTruthy();
}); });
it("overrides card lists the selected child's overrides with inert actions", async () => { it("a child with no patterns is home every day", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
expect(screen.getByTestId("patterns-empty").textContent).toBe("No patterns for Rory — home every day.");
for (const date of monthDates("2026-09-01")) expect(day(date).dataset.state, date).toBe("home");
expect(screen.getByText("No overrides for Rory.")).toBeTruthy();
});
it("overrides card lists the selected child's overrides", async () => {
await renderReady(); await renderReady();
const list = screen.getByRole("list", { name: "Overrides" }); const list = screen.getByRole("list", { name: "Overrides" });
expect(within(list).getAllByRole("listitem")).toHaveLength(2); expect(within(list).getAllByRole("listitem")).toHaveLength(2);
@@ -188,86 +328,317 @@ describe("ScheduleTab", () => {
fireEvent.click(screen.getByRole("button", { name: "Maeve" })); fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
expect(screen.getByText("Maeve away · Sep 28 – Oct 2")).toBeTruthy(); expect(screen.getByText("Maeve away · Sep 28 – Oct 2")).toBeTruthy();
expect(screen.getByText("4-week cycle · anchor Monday, Sep 7")).toBeTruthy();
}); });
it("a child with no schedule is home every day and defaults to a 2-week cycle", async () => { it("Add POSTs a new pattern to the child's collection and refreshes the list from the API", async () => {
await renderReady(); await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" })); fireEvent.click(screen.getByRole("button", { name: "Rory" }));
expect(screen.getByText("2-week cycle · anchor Tuesday, Sep 1")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
expect(day("2026-09-15").dataset.state).toBe("home"); expect(await screen.findByRole("heading", { name: "New pattern" })).toBeTruthy();
expect(screen.getByText("No overrides for Rory.")).toBeTruthy(); // Nothing named yet.
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe( expect(saveButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: " Dad's weekends " } });
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "2" } });
fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-07" } });
// Sunday-first buttons; the body keeps Monday=0 numbering.
const week1 = weekGroup(1);
expect(within(week1).getAllByRole("button").map((b) => b.textContent)).toEqual([
"Su",
"Mo",
"Tu",
"We",
"Th",
"Fr",
"Sa",
]);
fireEvent.click(within(week1).getByRole("button", { name: "Saturday" }));
fireEvent.click(within(week1).getByRole("button", { name: "Sunday" }));
expect(saveButton().disabled).toBe(false);
const before = calls().length;
fireEvent.click(saveButton());
const row = await screen.findByTestId("pattern-50");
expect(screen.queryByRole("heading", { name: "New pattern" })).toBeNull();
expect(writes()).toEqual([
{
method: "POST",
path: "/api/v1/admin/children/3/presence-patterns",
body: {
name: "Dad's weekends",
kind: "away",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [5, 6], "1": [] },
},
},
]);
const after = calls().slice(before);
expect(after.some((c) => c.method === "GET" && c.path === "/api/v1/admin/children/3/presence-patterns")).toBe(
true, true,
); );
expect(within(row).getByText("Dad's weekends")).toBeTruthy();
expect(within(row).getByText("Away · Every 2 weeks")).toBeTruthy();
expect(day("2026-09-12").dataset.state).toBe("away");
expect(day("2026-09-19").dataset.state).toBe("home");
// Focus returns to the Add control of the patterns section.
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Add pattern" }));
}); });
it("editing a day and saving PUTs the pattern; the grid reflects the saved schedule", async () => { it("Add can save a Home pattern", async () => {
await renderReady(); await renderReady();
expect(day("2026-09-01").dataset.state).toBe("away"); fireEvent.click(screen.getByRole("button", { name: "Rory" }));
fireEvent.click(day("2026-09-01")); // cycle week 1 (pre-anchor), Tuesday fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "School nights" } });
fireEvent.click(screen.getByRole("button", { name: "Home" }));
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Monday" }));
fireEvent.click(saveButton());
await screen.findByTestId("pattern-50");
expect(writes()[0].body).toEqual({
name: "School nights",
kind: "home",
cycle_length_weeks: 1,
anchor_date: TODAY,
pattern: { "0": [0] },
});
// A home pattern: uncovered days are now away.
expect(day("2026-09-14").dataset.state).toBe("home"); // Monday
expect(day("2026-09-15").dataset.state).toBe("away"); // Tuesday
});
// The draft already shows the edit, and every other week-1 Tuesday follows. it("Save requires a name; covered days alone are not enough", async () => {
expect(day("2026-09-01").dataset.state).toBe("home"); await renderReady();
expect(day("2026-09-15").dataset.state).toBe("home"); fireEvent.click(screen.getByRole("button", { name: "Rory" }));
expect(day("2026-09-08").dataset.state).toBe("home"); fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Monday" }));
expect(saveButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: " " } });
expect(saveButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Mondays" } });
expect(saveButton().disabled).toBe(false);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "" } });
expect(saveButton().disabled).toBe(true);
fireEvent.click(saveButton());
expect(writes()).toEqual([]);
});
fireEvent.click(screen.getByRole("button", { name: "Save pattern" })); it("Add saves a pattern that covers no weekday and lists it", async () => {
expect(await screen.findByText("Pattern saved.")).toBeTruthy(); await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Always away" } });
fireEvent.click(screen.getByRole("button", { name: "Home" }));
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "2" } });
expect(saveButton().disabled).toBe(false);
fireEvent.click(saveButton());
const row = await screen.findByTestId("pattern-50");
const put = fetchMock.mock.calls.find(([, init]) => init?.method === "PUT"); expect(writes()).toEqual([
expect(put).toBeTruthy(); {
const [url, init] = put!; method: "POST",
expect(new URL(String(url), "http://x").pathname).toBe("/api/v1/admin/children/1/presence-schedule"); path: "/api/v1/admin/children/3/presence-patterns",
expect(init.headers.Authorization).toBe("Bearer at-123"); body: {
expect(JSON.parse(init.body)).toEqual({ name: "Always away",
kind: "home",
cycle_length_weeks: 2,
anchor_date: TODAY,
pattern: { "0": [], "1": [] },
},
},
]);
expect(findPattern(50)?.pattern).toEqual({ "0": [], "1": [] });
expect(within(row).getByText("Always away")).toBeTruthy();
expect(within(row).getByText("Home · Every 2 weeks")).toBeTruthy();
expect(within(row).getByText("Week 1: No days · Week 2: No days")).toBeTruthy();
// A home pattern covering no day: the child is away every day.
for (const date of monthDates("2026-09-01")) expect(day(date).dataset.state, date).toBe("away");
});
it("Edit of a migrated all-absent home pattern saves without touching any weekday", async () => {
const migrated: PresencePattern = {
id: 7,
child_id: 3,
name: "Home weeks",
kind: "home",
cycle_length_weeks: 2, cycle_length_weeks: 2,
anchor_date: "2026-09-07", anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [1, 5, 6] }, pattern: { "0": [], "1": [] },
}); };
patterns[3] = [migrated];
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
fireEvent.click(await screen.findByRole("button", { name: "Edit Home weeks" }));
await screen.findByRole("heading", { name: "Edit pattern" });
for (const week of [1, 2]) {
for (const button of within(weekGroup(week)).getAllByRole("button")) {
expect(button.getAttribute("aria-pressed")).toBe("false");
}
}
// Unchanged: nothing to save.
expect(saveButton().disabled).toBe(true);
expect(day("2026-09-15").dataset.state).toBe("home"); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Never home" } });
expect(day("2026-09-29").dataset.state).toBe("home"); expect(saveButton().disabled).toBe(false);
expect(day("2026-09-30").dataset.state).toBe("away"); fireEvent.click(saveButton());
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe( await waitFor(() => expect(screen.queryByRole("heading", { name: "Edit pattern" })).toBeNull());
expect(writes()).toEqual([{ method: "PATCH", path: `${PATTERNS_PATH}/7`, body: { name: "Never home" } }]);
expect(findPattern(7)).toEqual({ ...migrated, name: "Never home" });
const row = await screen.findByTestId("pattern-7");
expect(within(row).getByText("Week 1: No days · Week 2: No days")).toBeTruthy();
});
it("Edit PATCHes only the changed fields to /presence-patterns/{id} and refreshes", async () => {
await selectMaeve();
fireEvent.click(screen.getByRole("button", { name: "Edit Alternate weekends" }));
expect(await screen.findByRole("heading", { name: "Edit pattern" })).toBeTruthy();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Alternate weekends");
expect((screen.getByLabelText("Cycle length") as HTMLSelectElement).value).toBe("2");
expect((screen.getByLabelText("Anchor date") as HTMLInputElement).value).toBe("2026-09-07");
expect(within(weekGroup(1)).getByRole("button", { name: "Saturday" }).getAttribute("aria-pressed")).toBe(
"true",
);
// Unchanged: nothing to save.
expect(saveButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Every other weekend" } });
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Sunday" }));
const before = calls().length;
fireEvent.click(saveButton());
await waitFor(() => expect(screen.queryByRole("heading", { name: "Edit pattern" })).toBeNull());
expect(writes()).toEqual([
{
method: "PATCH",
path: `${PATTERNS_PATH}/3`,
body: { name: "Every other weekend", pattern: { "0": [5], "1": [] } },
},
]);
const after = calls().slice(before);
expect(after.some((c) => c.method === "GET" && c.path === "/api/v1/admin/children/2/presence-patterns")).toBe(
true, true,
); );
const row = await screen.findByText("Every other weekend");
expect(within(row.closest("li")!).getByText("Week 1: Sat · Week 2: No days")).toBeTruthy();
expect(day("2026-09-12").dataset.state).toBe("away"); // Sat, on week
expect(day("2026-09-13").dataset.state).toBe("home"); // Sun no longer covered
}); });
it("saves an edited anchor date and cycle length", async () => { it("Delete asks first, DELETEs /presence-patterns/{id} and refreshes the list", async () => {
await renderReady(); await selectMaeve();
fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-14" } }); fireEvent.click(screen.getByRole("button", { name: "Delete Thursday+Friday" }));
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "3" } }); const confirm = within(screen.getByTestId("pattern-2")).getByRole("group", { name: "Confirm delete" });
expect(screen.getByText("3-week cycle · anchor Monday, Sep 14")).toBeTruthy(); fireEvent.click(within(confirm).getByRole("button", { name: "Keep" }));
// Anchor moved a week: Sep 7-13 is now the last week of the cycle (full, added). expect(writes()).toEqual([]);
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
await screen.findByText("Pattern saved."); fireEvent.click(screen.getByRole("button", { name: "Delete Thursday+Friday" }));
const [, init] = fetchMock.mock.calls.find(([, i]) => i?.method === "PUT")!; fireEvent.click(within(screen.getByTestId("pattern-2")).getByRole("button", { name: "Delete" }));
expect(JSON.parse(init.body)).toEqual({ await waitFor(() => expect(screen.queryByTestId("pattern-2")).toBeNull());
cycle_length_weeks: 3, expect(writes()).toEqual([{ method: "DELETE", path: `${PATTERNS_PATH}/2`, body: undefined }]);
anchor_date: "2026-09-14", expect(screen.getByTestId("pattern-3")).toBeTruthy();
pattern: { expect(day("2026-09-17").dataset.state).toBe("home"); // Thursday, no longer away
"0": [0, 1, 2, 3, 4, 5, 6], expect(day("2026-09-12").dataset.state).toBe("away"); // weekends pattern still applies
"1": [5, 6],
"2": [0, 1, 2, 3, 4, 5, 6],
},
});
}); });
it("a 422 shows the API detail and keeps the draft", async () => { it("deleting the last pattern leaves the child with none: home every day", async () => {
putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422);
await renderReady(); await renderReady();
fireEvent.click(day("2026-09-01")); expect(day("2026-09-15").dataset.state).toBe("away");
fireEvent.click(screen.getByRole("button", { name: "Save pattern" })); fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" }));
fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" }));
expect(await screen.findByTestId("patterns-empty")).toBeTruthy();
expect(screen.queryByRole("list", { name: "Presence patterns" })).toBeNull();
expect(patterns[1]).toEqual([]);
expect(day("2026-09-15").dataset.state).toBe("home");
expect(day("2026-09-11").dataset.state).toBe("override-away"); // overrides still win
expect(screen.getByText("Declan · no patterns, home every day")).toBeTruthy();
});
it("a 422 on save shows the API detail naming the field and keeps the editor open", async () => {
writeFailure = jsonResponse({ detail: "name must not be empty" }, 422);
await selectMaeve();
fireEvent.click(screen.getByRole("button", { name: "Edit Thursday+Friday" }));
await screen.findByRole("heading", { name: "Edit pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Thu/Fri" } });
fireEvent.click(saveButton());
const alert = await screen.findByTestId("pattern-error");
expect(alert.getAttribute("role")).toBe("alert");
expect(alert.textContent).toBe("name must not be empty");
expect(screen.getByRole("heading", { name: "Edit pattern" })).toBeTruthy();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Thu/Fri");
expect(saveButton().disabled).toBe(false);
});
it("a 403 on add is surfaced as the refusal, not swallowed", async () => {
writeFailure = new Response("", { status: 403 });
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Fridays" } });
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Friday" }));
fireEvent.click(saveButton());
const alert = await screen.findByTestId("pattern-error");
expect(alert.textContent).toBe("Your account is not in the nestquest-admins group.");
expect(patterns[1]).toEqual([DECLAN_HOME]);
});
it("a 403 on delete is surfaced and the pattern stays listed", async () => {
writeFailure = new Response("", { status: 403 });
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" }));
fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" }));
const alert = await screen.findByRole("alert"); const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("anchor_date must be a Monday"); expect(alert.textContent).toBe("Your account is not in the nestquest-admins group.");
expect(day("2026-09-01").dataset.state).toBe("home"); expect(screen.getByTestId("pattern-1")).toBeTruthy();
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe( });
false,
); it("a 422 on delete shows the API detail", async () => {
// The saved rule is unchanged. writeFailure = jsonResponse({ detail: "pattern_id must be an integer" }, 422);
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy(); await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" }));
fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" }));
expect((await screen.findByRole("alert")).textContent).toBe("pattern_id must be an integer");
});
it("Cancel closes the editor without any request", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Draft" } });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("heading", { name: "New pattern" })).toBeNull();
expect(writes()).toEqual([]);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Add pattern" }));
});
it("never calls the removed one-per-child /presence-schedule route", async () => {
await selectMaeve();
// Add, edit and delete: every pattern path the screen can take.
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
await screen.findByRole("heading", { name: "New pattern" });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Mondays" } });
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Monday" }));
fireEvent.click(saveButton());
await screen.findByTestId("pattern-50");
fireEvent.click(screen.getByRole("button", { name: "Edit Mondays" }));
await screen.findByRole("heading", { name: "Edit pattern" });
fireEvent.click(screen.getByRole("button", { name: "Home" }));
fireEvent.click(saveButton());
await waitFor(() => expect(screen.queryByRole("heading", { name: "Edit pattern" })).toBeNull());
fireEvent.click(await screen.findByRole("button", { name: "Delete Mondays" }));
fireEvent.click(within(screen.getByTestId("pattern-50")).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(screen.queryByTestId("pattern-50")).toBeNull());
expect(writes().map((c) => `${c.method} ${c.path}`)).toEqual([
"POST /api/v1/admin/children/2/presence-patterns",
`PATCH ${PATTERNS_PATH}/50`,
`DELETE ${PATTERNS_PATH}/50`,
]);
expect(calls().filter((c) => c.path.includes("presence-schedule"))).toEqual([]);
}); });
it("scroll column ends with 92px bottom padding", async () => { it("scroll column ends with 92px bottom padding", async () => {
+193 -165
View File
@@ -1,51 +1,48 @@
/** /**
* Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4). * Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4) and the
* selected child's presence patterns and overrides.
* *
* Every day's cycle position comes from the anchor date (./cycle.ts), never * The grid shows the COMBINED presence of every pattern (./cycle.ts
* from ISO week numbers or week parity (D-004). * `dayState`); each pattern's cycle position comes from its own anchor date,
* never from ISO week numbers or week parity (D-004).
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ApiForbiddenError } from "../api/client"; import { ApiForbiddenError } from "../api/client";
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions"; import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
import { import {
deletePresenceOverride, deletePresenceOverride,
deletePresencePattern,
fetchPresenceOverrides, fetchPresenceOverrides,
fetchPresenceSchedule, fetchPresencePatterns,
savePresenceSchedule,
type PresenceOverride, type PresenceOverride,
type PresenceSchedule, type PresencePattern,
type PresenceScheduleBody,
} from "../api/presence"; } from "../api/presence";
import { import {
cycleWeekIndex, cycleLabel,
dayState, dayState,
fullPattern,
localTodayIso, localTodayIso,
monthDates, monthDates,
monthStart, monthStart,
patternIncludes, patternDaysLabel,
presenceRule,
resizePattern,
togglePatternDay,
weekdayOf, weekdayOf,
type DayState, type DayState,
} from "./cycle"; } from "./cycle";
import { anchorLabel, dateRange, monthLabel, shortDate } from "./format"; import { dateRange, monthLabel, shortDate } from "./format";
import { import {
CalendarCheckGlyph, CalendarCheckGlyph,
CalendarXGlyph, CalendarXGlyph,
ChevronLeftGlyph, ChevronLeftGlyph,
ChevronRightGlyph, ChevronRightGlyph,
PencilGlyph,
Trash2Glyph, Trash2Glyph,
} from "./glyphs"; } from "./glyphs";
import OverrideEditor from "./OverrideEditor"; import OverrideEditor from "./OverrideEditor";
import PatternEditor from "./PatternEditor";
import "./ScheduleTab.css"; import "./ScheduleTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ /** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px"; export const SCROLL_BOTTOM_PADDING = "92px";
export const DEFAULT_CYCLE_LENGTH_WEEKS = 2;
const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
/** Sunday-first header; `weekdayOf` stays Monday=0. */ /** Sunday-first header; `weekdayOf` stays Monday=0. */
const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"]; const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"];
@@ -58,7 +55,7 @@ export const STATE_LABELS: Record<DayState, string> = {
interface Loaded { interface Loaded {
children: AdminChild[]; children: AdminChild[];
schedules: Record<number, PresenceSchedule | null>; patterns: Record<number, PresencePattern[]>;
overrides: PresenceOverride[]; overrides: PresenceOverride[];
} }
@@ -73,23 +70,22 @@ type DeleteState =
| { kind: "deleting"; id: number } | { kind: "deleting"; id: number }
| { kind: "error"; id: number; message: string }; | { kind: "error"; id: number; message: string };
type SaveState = type EditorState =
| { kind: "idle" } | { kind: "closed" }
| { kind: "saving" } | { kind: "override" }
| { kind: "saved" } | { kind: "pattern"; pattern: PresencePattern | null };
| { kind: "error"; message: string };
async function loadSchedule(): Promise<Loaded> { async function loadSchedule(): Promise<Loaded> {
const children = (await fetchChildren()) const children = (await fetchChildren())
.filter((child) => child.is_active) .filter((child) => child.is_active)
.sort((a, b) => a.sort_order - b.sort_order); .sort((a, b) => a.sort_order - b.sort_order);
const [schedules, overrides] = await Promise.all([ const [patterns, overrides] = await Promise.all([
Promise.all(children.map((child) => fetchPresenceSchedule(child.id))), Promise.all(children.map((child) => fetchPresencePatterns(child.id))),
fetchPresenceOverrides(), fetchPresenceOverrides(),
]); ]);
return { return {
children, children,
schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])), patterns: Object.fromEntries(children.map((child, i) => [child.id, patterns[i]])),
overrides, overrides,
}; };
} }
@@ -100,10 +96,7 @@ function loadErrorMessage(error: unknown): string {
: "The schedule could not be loaded. Check your connection and try again."; : "The schedule could not be loaded. Check your connection and try again.";
} }
function saveErrorMessage( function saveErrorMessage(error: unknown, fallback: string): string {
error: unknown,
fallback = "The pattern could not be saved. Check your connection and try again.",
): string {
if (error instanceof ApiForbiddenError) return error.message; if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail; if (error instanceof ApiRequestError && error.detail) return error.detail;
return fallback; return fallback;
@@ -121,15 +114,13 @@ function ScreenHeader({ sub }: { sub: string }) {
interface MonthGridProps { interface MonthGridProps {
month: string; month: string;
today: string; today: string;
schedule: PresenceScheduleBody; /** Every pattern of the child; none means home every day. */
/** Null when the child has no saved schedule and no edits: home every day. */ patterns: PresencePattern[];
effective: PresenceScheduleBody | null;
overrides: PresenceOverride[]; overrides: PresenceOverride[];
onMonth: (delta: number) => void; onMonth: (delta: number) => void;
onToggle: (date: string) => void;
} }
function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) { function MonthGrid({ month, today, patterns, overrides, onMonth }: MonthGridProps) {
const dates = monthDates(month); const dates = monthDates(month);
const leading = (weekdayOf(month) + 1) % 7; const leading = (weekdayOf(month) + 1) % 7;
return ( return (
@@ -167,23 +158,21 @@ function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onTo
<span key={`pad-${i}`} aria-hidden="true" /> <span key={`pad-${i}`} aria-hidden="true" />
))} ))}
{dates.map((date) => { {dates.map((date) => {
const state = dayState(effective, overrides, date); const state = dayState(patterns, overrides, date);
const isToday = date === today; const isToday = date === today;
let className = `schedule-day schedule-day--${state}`; let className = `schedule-day schedule-day--${state}`;
if (isToday) className += " schedule-day--today"; if (isToday) className += " schedule-day--today";
return ( return (
<button <span
key={date} key={date}
type="button" role="img"
className={className} className={className}
data-date={date} data-date={date}
data-state={state} data-state={state}
aria-pressed={patternIncludes(schedule, date)}
aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`} aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`}
onClick={() => onToggle(date)}
> >
{Number(date.slice(8))} {Number(date.slice(8))}
</button> </span>
); );
})} })}
</div> </div>
@@ -199,10 +188,83 @@ function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onTo
Today Today
</li> </li>
</ul> </ul>
<p className="schedule-legend-note">
Combined from every pattern: away beats home; overrides beat both.
</p>
</> </>
); );
} }
interface PatternRowProps {
pattern: PresencePattern;
deletion: DeleteState;
onEdit: () => void;
onAskDelete: () => void;
onKeep: () => void;
onDelete: () => void;
}
function PatternRow({ pattern, deletion, onEdit, onAskDelete, onKeep, onDelete }: PatternRowProps) {
const mine =
(deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === pattern.id;
return (
<li className="schedule-row" data-testid={`pattern-${pattern.id}`}>
<span className="schedule-row-glyph">
{pattern.kind === "home" ? <CalendarCheckGlyph /> : <CalendarXGlyph />}
</span>
<div className="schedule-row-text">
<div className="schedule-row-title">{pattern.name}</div>
<div className="schedule-row-meta">
{pattern.kind === "home" ? "Home" : "Away"} · {cycleLabel(pattern.cycle_length_weeks)}
</div>
<div className="schedule-row-meta" data-testid={`pattern-days-${pattern.id}`}>
{patternDaysLabel(pattern)}
</div>
</div>
{mine ? (
<div className="schedule-confirm" role="group" aria-label="Confirm delete">
<span className="schedule-confirm-text">Delete pattern?</span>
<button
type="button"
className="schedule-confirm-keep"
onClick={onKeep}
disabled={deletion.kind === "deleting"}
>
Keep
</button>
<button
type="button"
className="schedule-confirm-delete"
onClick={onDelete}
disabled={deletion.kind === "deleting"}
>
{deletion.kind === "deleting" ? "Deleting…" : "Delete"}
</button>
</div>
) : (
<>
<button
type="button"
className="schedule-icon-action schedule-icon-action--edit"
aria-label={`Edit ${pattern.name}`}
onClick={onEdit}
>
<PencilGlyph />
</button>
<button
type="button"
className="schedule-icon-action"
aria-label={`Delete ${pattern.name}`}
onClick={onAskDelete}
>
<Trash2Glyph />
</button>
</>
)}
</li>
);
}
interface OverrideRowProps { interface OverrideRowProps {
override: PresenceOverride; override: PresenceOverride;
childName: string; childName: string;
@@ -271,21 +333,24 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [month, setMonth] = useState(() => monthStart(today)); const [month, setMonth] = useState(() => monthStart(today));
const [drafts, setDrafts] = useState<Record<number, PresenceScheduleBody>>({}); const [editor, setEditor] = useState<EditorState>({ kind: "closed" });
const [save, setSave] = useState<SaveState>({ kind: "idle" });
const [editorOpen, setEditorOpen] = useState(false);
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" }); const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
const [patternDeletion, setPatternDeletion] = useState<DeleteState>({ kind: "idle" });
const addRef = useRef<HTMLButtonElement>(null); const addRef = useRef<HTMLButtonElement>(null);
const editorWasOpen = useRef(false); const addPatternRef = useRef<HTMLButtonElement>(null);
const openedEditor = useRef<EditorState["kind"]>("closed");
// The editor replaces this screen, so Add is remounted when it closes; // An editor replaces this screen, so the Add controls are remounted when
// hand focus back to it then. // it closes; hand focus back to the one that belongs to that editor.
useEffect(() => { useEffect(() => {
if (editorWasOpen.current && !editorOpen) addRef.current?.focus(); if (editor.kind === "closed") {
editorWasOpen.current = editorOpen; if (openedEditor.current === "override") addRef.current?.focus();
}, [editorOpen]); if (openedEditor.current === "pattern") addPatternRef.current?.focus();
}
openedEditor.current = editor.kind;
}, [editor.kind]);
/** Reload children, schedules and overrides in place (no loading flash). */ /** Reload children, patterns and overrides in place (no loading flash). */
const refresh = () => const refresh = () =>
loadSchedule() loadSchedule()
.then((data) => setState({ kind: "ready", data })) .then((data) => setState({ kind: "ready", data }))
@@ -309,16 +374,31 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
}; };
}, [attempt]); }, [attempt]);
if (editorOpen && state.kind === "ready" && state.data.children.length > 0) { if (editor.kind !== "closed" && state.kind === "ready" && state.data.children.length > 0) {
const { children } = state.data; const { children } = state.data;
const current = children.find((c) => c.id === selectedId) ?? children[0];
if (editor.kind === "pattern") {
return (
<PatternEditor
child={current}
pattern={editor.pattern}
today={today}
onCancel={() => setEditor({ kind: "closed" })}
onSaved={() => {
setEditor({ kind: "closed" });
void refresh();
}}
/>
);
}
return ( return (
<OverrideEditor <OverrideEditor
childOptions={children} childOptions={children}
initialChildId={children.find((c) => c.id === selectedId)?.id ?? children[0].id} initialChildId={current.id}
today={today} today={today}
onCancel={() => setEditorOpen(false)} onCancel={() => setEditor({ kind: "closed" })}
onSaved={(override) => { onSaved={(override) => {
setEditorOpen(false); setEditor({ kind: "closed" });
setSelectedId(override.child_id); setSelectedId(override.child_id);
void refresh(); void refresh();
}} }}
@@ -348,28 +428,13 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
} else { } else {
const { data } = state; const { data } = state;
const child = data.children.find((c) => c.id === selectedId) ?? data.children[0]; const child = data.children.find((c) => c.id === selectedId) ?? data.children[0];
const saved = data.schedules[child.id] ?? null; const patterns = data.patterns[child.id] ?? [];
const draft = drafts[child.id];
// Edits start from the saved schedule, or — with none — from "home every
// day" on a 2-week cycle anchored at the displayed month's 1st.
const schedule: PresenceScheduleBody = draft ??
saved ?? {
cycle_length_weeks: DEFAULT_CYCLE_LENGTH_WEEKS,
anchor_date: month,
pattern: fullPattern(DEFAULT_CYCLE_LENGTH_WEEKS),
};
const effective = draft ?? saved;
const childOverrides = data.overrides.filter((o) => o.child_id === child.id); const childOverrides = data.overrides.filter((o) => o.child_id === child.id);
sub = `${schedule.cycle_length_weeks}-week cycle · anchor ${anchorLabel(schedule.anchor_date)}`; sub =
patterns.length === 0
? `${child.display_name} · no patterns, home every day`
: `${child.display_name} · ${patterns.length} ${patterns.length === 1 ? "pattern" : "patterns"}`;
const edit = (next: PresenceScheduleBody) => {
setDrafts((all) => ({ ...all, [child.id]: next }));
setSave({ kind: "idle" });
};
const toggle = (date: string) => {
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) });
};
const confirmDelete = (id: number) => { const confirmDelete = (id: number) => {
setDeletion({ kind: "deleting", id }); setDeletion({ kind: "deleting", id });
deletePresenceOverride(id) deletePresenceOverride(id)
@@ -386,32 +451,21 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
}), }),
); );
}; };
const submit = () => { const confirmPatternDelete = (id: number) => {
if (!draft) return; setPatternDeletion({ kind: "deleting", id });
const childId = child.id; deletePresencePattern(id)
setSave({ kind: "saving" }); .then(() => refresh())
const { cycle_length_weeks, anchor_date, pattern } = draft; .then(() => setPatternDeletion({ kind: "idle" }))
savePresenceSchedule(childId, { cycle_length_weeks, anchor_date, pattern }) .catch((error: unknown) =>
.then((stored) => { setPatternDeletion({
setState((current) => kind: "error",
current.kind === "ready" id,
? { message: saveErrorMessage(
kind: "ready", error,
data: { "The pattern could not be deleted. Check your connection and try again.",
...current.data, ),
schedules: { ...current.data.schedules, [childId]: stored }, }),
}, );
}
: current,
);
setDrafts((all) => {
const rest = { ...all };
delete rest[childId];
return rest;
});
setSave({ kind: "saved" });
})
.catch((error: unknown) => setSave({ kind: "error", message: saveErrorMessage(error) }));
}; };
body = ( body = (
@@ -425,7 +479,7 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
aria-pressed={c.id === child.id} aria-pressed={c.id === child.id}
onClick={() => { onClick={() => {
setSelectedId(c.id); setSelectedId(c.id);
setSave({ kind: "idle" }); setPatternDeletion({ kind: "idle" });
}} }}
> >
{c.display_name} {c.display_name}
@@ -437,74 +491,48 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
<MonthGrid <MonthGrid
month={month} month={month}
today={today} today={today}
schedule={schedule} patterns={patterns}
effective={effective}
overrides={childOverrides} overrides={childOverrides}
onMonth={(delta) => setMonth((m) => monthStart(m, delta))} onMonth={(delta) => setMonth((m) => monthStart(m, delta))}
onToggle={toggle}
/> />
<div className="schedule-controls">
<label className="schedule-field">
<span>Anchor date</span>
<input
type="date"
value={schedule.anchor_date}
onChange={(event) => {
if (event.target.value) edit({ ...schedule, anchor_date: event.target.value });
}}
/>
</label>
<label className="schedule-field">
<span>Cycle length</span>
<select
value={schedule.cycle_length_weeks}
onChange={(event) => {
const weeks = Number(event.target.value);
edit({
...schedule,
cycle_length_weeks: weeks,
pattern: resizePattern(schedule.pattern, weeks),
});
}}
>
{CYCLE_LENGTH_CHOICES.map((weeks) => (
<option key={weeks} value={weeks}>
{weeks} {weeks === 1 ? "week" : "weeks"}
</option>
))}
</select>
</label>
<button
type="button"
className="schedule-save"
disabled={!draft || save.kind === "saving"}
onClick={submit}
>
{save.kind === "saving" ? "Saving…" : "Save pattern"}
</button>
</div>
{save.kind === "error" ? (
<p className="schedule-save-status schedule-save-status--error" role="alert">
{save.message}
</p>
) : save.kind === "saved" ? (
<p className="schedule-save-status" role="status">
Pattern saved.
</p>
) : null}
</section> </section>
<h3 className="schedule-section-label">Presence pattern</h3> <div className="schedule-section-head">
<ul className="schedule-card schedule-list" aria-label="Presence pattern"> <h3 className="schedule-section-label">Presence patterns</h3>
{data.children.map((c) => ( <button
<li key={c.id} className="schedule-row" data-testid={`pattern-${c.id}`}> ref={addPatternRef}
<div className="schedule-row-text"> type="button"
<div className="schedule-row-title">{c.display_name}</div> className="schedule-text-action"
</div> aria-label="Add pattern"
<span className="schedule-rule">{presenceRule(data.schedules[c.id] ?? null)}</span> onClick={() => setEditor({ kind: "pattern", pattern: null })}
</li> >
))} Add
</ul> </button>
</div>
{patterns.length > 0 ? (
<ul className="schedule-card schedule-list" aria-label="Presence patterns">
{patterns.map((pattern) => (
<PatternRow
key={pattern.id}
pattern={pattern}
deletion={patternDeletion}
onEdit={() => setEditor({ kind: "pattern", pattern })}
onAskDelete={() => setPatternDeletion({ kind: "confirming", id: pattern.id })}
onKeep={() => setPatternDeletion({ kind: "idle" })}
onDelete={() => confirmPatternDelete(pattern.id)}
/>
))}
</ul>
) : (
<div className="schedule-card schedule-empty" data-testid="patterns-empty">
No patterns for {child.display_name} — home every day.
</div>
)}
{patternDeletion.kind === "error" ? (
<p className="schedule-save-status schedule-save-status--error" role="alert">
{patternDeletion.message}
</p>
) : null}
<div className="schedule-section-head"> <div className="schedule-section-head">
<h3 className="schedule-section-label">Overrides</h3> <h3 className="schedule-section-label">Overrides</h3>
@@ -512,7 +540,7 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
ref={addRef} ref={addRef}
type="button" type="button"
className="schedule-text-action" className="schedule-text-action"
onClick={() => setEditorOpen(true)} onClick={() => setEditor({ kind: "override" })}
> >
Add Add
</button> </button>
+86 -28
View File
@@ -1,23 +1,44 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
cycleLabel,
cycleWeekIndex, cycleWeekIndex,
dayState, dayState,
emptyPattern,
monthDates, monthDates,
monthStart, monthStart,
presenceRule, patternDaysLabel,
patternIncludes,
resizePattern, resizePattern,
togglePatternDay, togglePatternDay,
weekdayOf, weekdayOf,
} from "./cycle"; } from "./cycle";
import type { PresenceOverride, PresenceScheduleBody } from "../api/presence"; import type { PatternCycle, PatternKind, PresenceOverride } from "../api/presence";
// Anchor Monday 2026-09-07; week 0 home every day, week 1 home Sat/Sun only. type KindedCycle = PatternCycle & { kind: PatternKind };
const SCHEDULE: PresenceScheduleBody = {
// The migrated single schedule: anchor Monday 2026-09-07; week 0 home every
// day, week 1 home Sat/Sun only.
const SCHEDULE: KindedCycle = {
kind: "home",
cycle_length_weeks: 2, cycle_length_weeks: 2,
anchor_date: "2026-09-07", anchor_date: "2026-09-07",
pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] }, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] },
}; };
// The owner scenario: away Thu+Fri every week, away weekends every 2 weeks.
const THU_FRI: KindedCycle = {
kind: "away",
cycle_length_weeks: 1,
anchor_date: "2026-09-07",
pattern: { 0: [3, 4] },
};
const ALTERNATE_WEEKENDS: KindedCycle = {
kind: "away",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { 0: [5, 6], 1: [] },
};
describe("cycleWeekIndex", () => { describe("cycleWeekIndex", () => {
it("follows the anchor across a month, wrapping every 2 weeks", () => { it("follows the anchor across a month, wrapping every 2 weeks", () => {
const expected: Record<string, number> = {}; const expected: Record<string, number> = {};
@@ -89,22 +110,61 @@ describe("dayState", () => {
{ id: 2, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null }, { id: 2, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
]; ];
it("applies overrides first, then the anchor-based pattern", () => { it("a home-only child: overrides first, then absent on days no home pattern covers", () => {
expect(dayState(SCHEDULE, overrides, "2026-09-08")).toBe("home"); const patterns = [SCHEDULE];
expect(dayState(SCHEDULE, overrides, "2026-09-11")).toBe("override-away"); expect(dayState(patterns, overrides, "2026-09-08")).toBe("home");
expect(dayState(SCHEDULE, overrides, "2026-09-15")).toBe("away"); expect(dayState(patterns, overrides, "2026-09-11")).toBe("override-away");
expect(dayState(SCHEDULE, overrides, "2026-09-16")).toBe("override-home"); expect(dayState(patterns, overrides, "2026-09-15")).toBe("away");
expect(dayState(SCHEDULE, overrides, "2026-09-19")).toBe("home"); expect(dayState(patterns, overrides, "2026-09-16")).toBe("override-home");
expect(dayState(SCHEDULE, overrides, "2026-09-01")).toBe("away"); expect(dayState(patterns, overrides, "2026-09-19")).toBe("home");
expect(dayState(SCHEDULE, overrides, "2026-09-05")).toBe("home"); expect(dayState(patterns, overrides, "2026-09-01")).toBe("away");
expect(dayState(patterns, overrides, "2026-09-05")).toBe("home");
}); });
it("treats a child with no schedule as home every day", () => { it("combines the owner's two away patterns; every other day is home", () => {
expect(dayState(null, [], "2026-09-15")).toBe("home"); const patterns = [THU_FRI, ALTERNATE_WEEKENDS];
const expected: Record<string, string> = {};
for (const date of monthDates("2026-09-01")) {
const weekday = weekdayOf(date);
const onWeek = cycleWeekIndex("2026-09-07", date, 2) === 0;
const away = weekday === 3 || weekday === 4 || (onWeek && weekday >= 5);
expected[date] = away ? "away" : "home";
}
for (const [date, state] of Object.entries(expected)) {
expect(dayState(patterns, [], date), date).toBe(state);
}
// Spot checks: Thu/Fri every week; weekends alternate from the anchor.
expect(dayState(patterns, [], "2026-09-10")).toBe("away"); // Thu, week 0
expect(dayState(patterns, [], "2026-09-18")).toBe("away"); // Fri, week 1
expect(dayState(patterns, [], "2026-09-12")).toBe("away"); // Sat, week 0
expect(dayState(patterns, [], "2026-09-20")).toBe("home"); // Sun, week 1
expect(dayState(patterns, [], "2026-09-27")).toBe("away"); // Sun, week 0
expect(dayState(patterns, [], "2026-09-15")).toBe("home"); // Tue
});
it("away beats home when both cover a date; a home pattern makes uncovered days away", () => {
const patterns = [SCHEDULE, THU_FRI];
expect(dayState(patterns, [], "2026-09-10")).toBe("away"); // home week 0 Thu, but away wins
expect(dayState(patterns, [], "2026-09-08")).toBe("home"); // home covers, away does not
expect(dayState(patterns, [], "2026-09-15")).toBe("away"); // no home pattern covers it
// An override still beats every pattern.
expect(
dayState(patterns, [{ ...overrides[1], start_date: "2026-09-10", end_date: "2026-09-10" }], "2026-09-10"),
).toBe("override-home");
});
it("treats a child with no patterns as home every day", () => {
expect(dayState([], [], "2026-09-15")).toBe("home");
}); });
}); });
describe("pattern editing and rule text", () => { describe("pattern editing and labels", () => {
it("patternIncludes reads the date's cycle week from the anchor", () => {
expect(patternIncludes(ALTERNATE_WEEKENDS, "2026-09-12")).toBe(true);
expect(patternIncludes(ALTERNATE_WEEKENDS, "2026-09-19")).toBe(false);
expect(patternIncludes(ALTERNATE_WEEKENDS, "2026-09-05")).toBe(false); // pre-anchor week 1
});
it("toggles one weekday of one cycle week", () => { it("toggles one weekday of one cycle week", () => {
expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({ expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
0: [0, 1, 2, 3, 4, 5, 6], 0: [0, 1, 2, 3, 4, 5, 6],
@@ -113,21 +173,19 @@ describe("pattern editing and rule text", () => {
expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]); expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]);
}); });
it("resizes the pattern to the cycle length", () => { it("resizes the pattern to the cycle length; added weeks cover no day", () => {
expect(resizePattern(SCHEDULE.pattern, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6] }); expect(resizePattern(SCHEDULE.pattern, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6] });
expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([0, 1, 2, 3, 4, 5, 6]); expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([]);
expect(emptyPattern(2)).toEqual({ 0: [], 1: [] });
}); });
it("describes the presence rule in plain language", () => { it("describes the cycle and the weekday set in plain language", () => {
expect(presenceRule(null)).toBe("Every day"); expect(cycleLabel(1)).toBe("Every week");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [0, 1, 2, 3, 4, 5, 6] } })).toBe( expect(cycleLabel(2)).toBe("Every 2 weeks");
"Every day", expect(patternDaysLabel(THU_FRI)).toBe("Thu, Fri");
); expect(patternDaysLabel(ALTERNATE_WEEKENDS)).toBe("Week 1: Sat, Sun · Week 2: No days");
expect(presenceRule(SCHEDULE)).toBe("Weeks 1 & 2 of cycle"); expect(patternDaysLabel(SCHEDULE)).toBe("Week 1: Every day · Week 2: Sat, Sun");
expect( // Listed Monday-first by weekday number, whatever order they were stored in.
presenceRule({ cycle_length_weeks: 4, anchor_date: "2026-09-07", pattern: { 0: [0], 1: [], 2: [3], 3: [] } }), expect(patternDaysLabel({ ...THU_FRI, pattern: { "0": [6, 0] } })).toBe("Mon, Sun");
).toBe("Weeks 1 & 3 of cycle");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [1], 1: [] } })).toBe("Week 1 of cycle");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [], 1: [] } })).toBe("Never home");
}); });
}); });
+58 -37
View File
@@ -10,12 +10,23 @@
* Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no * Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
* viewer offset or DST change can shift a day. * viewer offset or DST change can shift a day.
*/ */
import type { PresenceOverride, PresencePattern, PresenceScheduleBody } from "../api/presence"; import type {
PatternCycle,
PatternKind,
PatternWeeks,
PresenceOverride,
} from "../api/presence";
const MS_PER_DAY = 86_400_000; const MS_PER_DAY = 86_400_000;
export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
/** Monday=0 weekday numbers in Sunday-first display order. */
export const DISPLAY_WEEKDAYS = [6, 0, 1, 2, 3, 4, 5];
/** Short names indexed by Monday=0 weekday number. */
export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
/** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */ /** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */
export function dayNumber(iso: string): number { export function dayNumber(iso: string): number {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
@@ -67,44 +78,50 @@ export function monthDates(firstIso: string): string[] {
return dates; return dates;
} }
/** Present every day for every week of the cycle (the no-schedule case). */ /** Every week of the cycle covers no day yet (a new pattern). */
export function fullPattern(cycleLengthWeeks: number): PresencePattern { export function emptyPattern(cycleLengthWeeks: number): PatternWeeks {
const pattern: PresencePattern = {}; const pattern: PatternWeeks = {};
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [...ALL_WEEKDAYS]; for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [];
return pattern; return pattern;
} }
export type DayState = "home" | "away" | "override-home" | "override-away"; export type DayState = "home" | "away" | "override-home" | "override-away";
export function patternIncludes( /** Does the pattern cover `date` (its cycle week lists the date's weekday)? */
schedule: PresenceScheduleBody, export function patternIncludes(cycle: PatternCycle, date: string): boolean {
date: string, const week = cycleWeekIndex(cycle.anchor_date, date, cycle.cycle_length_weeks);
): boolean { return (cycle.pattern[week] ?? []).includes(weekdayOf(date));
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
return (schedule.pattern[week] ?? []).includes(weekdayOf(date));
} }
/** /**
* A date's state for one child: an override covering the date wins; * A date's combined state for one child, mirroring core/presence.py
* otherwise the schedule's pattern decides (no schedule = home every day). * `is_present` — first match wins:
* 1. an override covering the date;
* 2. away when any `away` pattern covers it;
* 3. home when any `home` pattern covers it;
* 4. away when the child has at least one `home` pattern (a home pattern
* lists the days the child IS here), else home (no patterns, or only
* `away` ones).
*/ */
export function dayState( export function dayState(
schedule: PresenceScheduleBody | null, patterns: (PatternCycle & { kind: PatternKind })[],
overrides: PresenceOverride[], overrides: PresenceOverride[],
date: string, date: string,
): DayState { ): DayState {
const override = overrides.find((o) => o.start_date <= date && date <= o.end_date); const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
if (override) return override.is_present ? "override-home" : "override-away"; if (override) return override.is_present ? "override-home" : "override-away";
if (!schedule) return "home"; if (patterns.some((p) => p.kind === "away" && patternIncludes(p, date))) return "away";
return patternIncludes(schedule, date) ? "home" : "away"; const homes = patterns.filter((p) => p.kind === "home");
if (homes.some((p) => patternIncludes(p, date))) return "home";
return homes.length > 0 ? "away" : "home";
} }
/** Flip `weekday` in `week` of the pattern; returns a new pattern. */ /** Flip `weekday` in `week` of the pattern; returns a new pattern. */
export function togglePatternDay( export function togglePatternDay(
pattern: PresencePattern, pattern: PatternWeeks,
week: number, week: number,
weekday: number, weekday: number,
): PresencePattern { ): PatternWeeks {
const days = pattern[week] ?? []; const days = pattern[week] ?? [];
const next = days.includes(weekday) const next = days.includes(weekday)
? days.filter((d) => d !== weekday) ? days.filter((d) => d !== weekday)
@@ -112,31 +129,35 @@ export function togglePatternDay(
return { ...pattern, [week]: next }; return { ...pattern, [week]: next };
} }
/** Fit the pattern to a new cycle length; added weeks start present every day. */ /** Fit the pattern to a new cycle length; added weeks start covering no day. */
export function resizePattern(pattern: PresencePattern, cycleLengthWeeks: number): PresencePattern { export function resizePattern(pattern: PatternWeeks, cycleLengthWeeks: number): PatternWeeks {
const next: PresencePattern = {}; const next: PatternWeeks = {};
for (let week = 0; week < cycleLengthWeeks; week += 1) { for (let week = 0; week < cycleLengthWeeks; week += 1) {
next[week] = pattern[week] ? [...pattern[week]] : [...ALL_WEEKDAYS]; next[week] = pattern[week] ? [...pattern[week]] : [];
} }
return next; return next;
} }
function joinWeeks(weeks: number[]): string { function daysLabel(days: number[]): string {
if (weeks.length === 1) return String(weeks[0]); if (days.length === 7) return "Every day";
return `${weeks.slice(0, -1).join(", ")} & ${weeks[weeks.length - 1]}`; if (days.length === 0) return "No days";
return [...days].sort((a, b) => a - b).map((d) => WEEKDAY_SHORT[d]).join(", ");
} }
/** Plain-language rule: `Every day`, `Weeks 1 & 3 of cycle`, `Never home`. */ /**
export function presenceRule(schedule: PresenceScheduleBody | null): string { * Plain-language weekday set: `Thu, Fri` for a 1-week cycle, otherwise one
if (!schedule) return "Every day"; * entry per cycle week, e.g. `Week 1: Sat, Sun · Week 2: No days`.
const weeks: number[] = []; */
let full = true; export function patternDaysLabel(cycle: PatternCycle): string {
for (let week = 0; week < schedule.cycle_length_weeks; week += 1) { if (cycle.cycle_length_weeks === 1) return daysLabel(cycle.pattern[0] ?? []);
const days = schedule.pattern[week] ?? []; const weeks: string[] = [];
if (days.length > 0) weeks.push(week + 1); for (let week = 0; week < cycle.cycle_length_weeks; week += 1) {
if (days.length < 7) full = false; weeks.push(`Week ${week + 1}: ${daysLabel(cycle.pattern[week] ?? [])}`);
} }
if (full) return "Every day"; return weeks.join(" · ");
if (weeks.length === 0) return "Never home"; }
return `${weeks.length === 1 ? "Week" : "Weeks"} ${joinWeeks(weeks)} of cycle`;
/** `Every week`, `Every 2 weeks`. */
export function cycleLabel(cycleLengthWeeks: number): string {
return cycleLengthWeeks === 1 ? "Every week" : `Every ${cycleLengthWeeks} weeks`;
} }
+9
View File
@@ -67,6 +67,15 @@ export function CalendarCheckGlyph({ size = 18 }: { size?: number }) {
); );
} }
export function PencilGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</Glyph>
);
}
export function Trash2Glyph({ size = 16 }: { size?: number }) { export function Trash2Glyph({ size = 16 }: { size?: number }) {
return ( return (
<Glyph size={size}> <Glyph size={size}>
+3 -3
View File
@@ -159,12 +159,12 @@ core_quest_definitions = importlib.import_module(
core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence") core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence")
#: The presence model (``core.presence.PresenceEngine``, #: The presence model (``core.presence.PresenceEngine``,
#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's #: ``PresencePattern``, ``PresenceOverride``) the admin plane's
#: presence routes serialize and the tests resolve presence with. #: presence routes serialize and the tests resolve presence with.
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence") core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
#: The presence-write business layer #: The presence-write business layer
#: (``core.presence_management.set_presence_schedule`` et al.) the #: (``core.presence_management.create_presence_pattern`` et al.) the
#: admin plane's presence routes call — the ONE implementation the HA #: admin plane's presence routes call — the ONE implementation the HA
#: services share. #: services share.
core_presence_management = importlib.import_module( core_presence_management = importlib.import_module(
@@ -172,7 +172,7 @@ core_presence_management = importlib.import_module(
) )
#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests #: The typed presence DAOs (``core.dao_presence``) the admin-plane tests
#: read stored schedules and overrides back through. #: read stored patterns and overrides back through.
core_dao_presence = importlib.import_module( core_dao_presence = importlib.import_module(
f"{_CORE_MODULE_NAME}.dao_presence" f"{_CORE_MODULE_NAME}.dao_presence"
) )
+230 -110
View File
@@ -39,15 +39,27 @@ The presence routes follow the same pattern over
:mod:`nestquest_core.presence_management` — the ONE presence write :mod:`nestquest_core.presence_management` — the ONE presence write
layer the HA services share, so the two planes cannot drift: layer the HA services share, so the two planes cannot drift:
- ``PUT /children/{child_id}/presence-schedule`` sets (UPSERTS) the - ``GET /children/{child_id}/presence-patterns`` lists the child's
child's repeating schedule: the body's ``cycle_length_weeks``, repeating presence patterns (zero or more, schema 9) through ONE
``anchor_date`` and ``pattern`` go straight into ONE ``list_presence_patterns`` call, wrapped as ``{"patterns": [...]}``
:class:`~nestquest_core.presence.PresenceSchedule` (the model ordered by id — an empty list when the child has none (present
validates cycle length, the strict anchor date and the pattern's every day), never a 404; an unknown child is 404.
week coverage at construction), the core upserts by child (setting - ``POST /children/{child_id}/presence-patterns`` adds one pattern:
again REPLACES the one schedule a child can have) and regenerates the body's ``name``, ``kind`` (``home``/``away``),
the child's future instances; the response is the STORED schedule ``cycle_length_weeks``, ``anchor_date`` and ``pattern`` go straight
decoded back through the model. into ONE :class:`~nestquest_core.presence.PresencePattern` (the model
validates the name, the kind, cycle length, the strict anchor date
and the pattern's week coverage at construction), the core inserts
it beside the child's other patterns and regenerates the child's
future instances; the response (201) is the STORED pattern with its
``id``, its weekday sets decoded back through the model.
- ``PATCH /presence-patterns/{pattern_id}`` edits one pattern: only
SUPPLIED fields are forwarded (``exclude_unset``), the core lays them
over the stored pattern and rebuilds the WHOLE model, so a partial
edit is validated exactly as a create; it regenerates the pattern's
child.
- ``DELETE /presence-patterns/{pattern_id}`` deletes one pattern and
regenerates its child. It answers ``{"status": "ok"}``.
- ``POST /presence-overrides`` creates one date-range override: the - ``POST /presence-overrides`` creates one date-range override: the
body becomes ONE :class:`~nestquest_core.presence.PresenceOverride` body becomes ONE :class:`~nestquest_core.presence.PresenceOverride`
(the model validates the dates, ``end >= start``, the real bool and (the model validates the dates, ``end >= start``, the real bool and
@@ -65,11 +77,6 @@ layer the HA services share, so the two planes cannot drift:
(instances with a completion event are never counted, D-005). (instances with a completion event are never counted, D-005).
Nothing is written; an unknown child is 404, a malformed or inverted Nothing is written; an unknown child is 404, a malformed or inverted
range 422. range 422.
- ``GET /children/{child_id}/presence-schedule`` reads the child's
schedule through ONE ``get_presence_schedule`` call, wrapped as
``{"schedule": ...}`` in the PUT response's shape — ``null`` when the
child has none (present every day), never a 404; an unknown child is
404.
- ``GET /presence-overrides`` lists the household's overrides through - ``GET /presence-overrides`` lists the household's overrides through
ONE ``list_presence_overrides`` call, wrapped as ONE ``list_presence_overrides`` call, wrapped as
``{"overrides": [...]}`` in the POST response's shape, ordered by ``{"overrides": [...]}`` in the POST response's shape, ordered by
@@ -238,21 +245,24 @@ deliberately narrow):
``rule_type``, a shape-missing field such as a weekly rule without ``rule_type``, a shape-missing field such as a weekly rule without
weekdays, a malformed date, a forbidden field), a bad window name or weekdays, a malformed date, a forbidden field), a bad window name or
due time, a rejected window or assignee list (empty, duplicate, due time, a rejected window or assignee list (empty, duplicate,
wrong-typed); a rejected presence schedule or override (bad cycle wrong-typed); a rejected presence pattern or override (a blank name,
length, a pattern not covering the cycle's weeks, a malformed or an unknown kind, a bad cycle length, a pattern not covering the
cycle's weeks, a malformed anchor date, a malformed or
inverted date range, an override overlapping the same child's inverted date range, an override overlapping the same child's
existing one) — is mapped to 422: the body was well-formed JSON but existing one) — is mapped to 422: the body was well-formed JSON but
semantically invalid, the same failure class Pydantic reports. The semantically invalid, the same failure class Pydantic reports. The
core rejects these BEFORE any write, so a 422 never leaves a core rejects these BEFORE any write, so a 422 never leaves a
half-applied change behind. half-applied change behind.
- A non-existent CHILD on a presence route (setting a schedule or - A non-existent CHILD on a presence route (listing or creating a
creating an override for an unknown id) is mapped to 404 through the pattern, or creating an override, for an unknown id) is mapped to 404
SAME :func:`_raise_child_error` the children routes use — the through the SAME :func:`_raise_child_error` the children routes use —
message reads ``child N does not exist``. A non-existent OVERRIDE the message reads ``child N does not exist``. A non-existent
on the delete route is mapped to 404 through OVERRIDE on the delete route is mapped to 404 through
:func:`_raise_presence_override_error`, keyed on the :func:`_raise_presence_override_error`, keyed on the
``override_id:`` prefix the core's unknown-id error carries (the ``override_id:`` prefix the core's unknown-id error carries (the
same convention as the quest-definition 404). same convention as the quest-definition 404); a non-existent PATTERN
on the pattern edit/delete routes likewise through
:func:`_raise_presence_pattern_error`, keyed on ``pattern_id:``.
- The UNCOMPLETE route maps the core completion layer's unknown- - The UNCOMPLETE route maps the core completion layer's unknown-
``instance_id`` error (prefixed ``instance_id:``, the same ``instance_id`` error (prefixed ``instance_id:``, the same
convention the panel complete route keys on) to 404; every other convention the panel complete route keys on) to 404; every other
@@ -327,7 +337,6 @@ from api.nestquest_core import (
core_dao_rules, core_dao_rules,
core_history, core_history,
core_materialize, core_materialize,
core_presence,
core_presence_management, core_presence_management,
core_quest_definitions, core_quest_definitions,
core_recurrence, core_recurrence,
@@ -605,37 +614,70 @@ class AdminQuestDefinitionActiveRequest(BaseModel):
is_active: bool is_active: bool
class AdminPresenceScheduleRequest(BaseModel): class AdminPresencePatternCreateRequest(BaseModel):
"""The body of ``PUT /api/v1/admin/children/{child_id}/presence-schedule``. """The body of ``POST /api/v1/admin/children/{child_id}/presence-patterns``.
The shape of :class:`~nestquest_core.presence.PresenceSchedule` — The shape of :class:`~nestquest_core.presence.PresencePattern` —
nothing more. ``pattern`` maps each cycle week index (JSON object nothing more. ``pattern`` maps each cycle week index (JSON object
keys, coerced to ints by the model) to that week's present weekdays keys, coerced to ints by the model) to the weekdays the pattern
(Monday=0); every week of the cycle must be covered, which the covers that week (Monday=0); every week of the cycle must be
PRESENCE MODEL enforces at construction, not this shape. A week covered, which the PRESENCE MODEL enforces at construction, not
with an empty list means absent every day of that week. this shape — as it does the non-blank ``name`` and the
``home``/``away`` ``kind``. ``cycle_length_weeks`` and the weekday
values are strict JSON integers: ``2.0``, ``"2"`` or ``true`` is a
422, not a silent coercion.
""" """
cycle_length_weeks: int name: str
kind: str
cycle_length_weeks: StrictInt
anchor_date: str anchor_date: str
pattern: dict[int, list[int]] pattern: dict[int, list[StrictInt]]
class AdminPresenceScheduleResponse(BaseModel): class AdminPresencePatternEditRequest(BaseModel):
"""A child's repeating presence schedule as the admin plane serializes it. """The body of ``PATCH /api/v1/admin/presence-patterns/{pattern_id}``.
The stored schedule decoded back through Every field optional; only SUPPLIED fields are forwarded, and an
:meth:`nestquest_core.presence.PresenceSchedule.decode`: each explicit ``null`` is forwarded too — and rejected by the model.
pattern week is the sorted list of present weekdays, and the child The pattern's child cannot be changed.
has exactly ONE schedule (setting again replaces it).
""" """
name: str | None = None
kind: str | None = None
cycle_length_weeks: StrictInt | None = None
anchor_date: str | None = None
pattern: dict[int, list[StrictInt]] | None = None
class AdminPresencePatternResponse(BaseModel):
"""One presence pattern as the admin plane serializes it.
The stored row with its weekday sets decoded back through
:meth:`nestquest_core.presence.PresencePattern.decode`: each
pattern week is the sorted list of covered weekdays. The ``id`` is
the handle the PATCH and DELETE routes take.
"""
id: int
child_id: int child_id: int
name: str
kind: str
cycle_length_weeks: int cycle_length_weeks: int
anchor_date: str anchor_date: str
pattern: dict[int, list[int]] pattern: dict[int, list[int]]
class AdminPresencePatternListResponse(BaseModel):
"""The ``GET /children/{child_id}/presence-patterns`` payload.
``patterns`` is empty when the child has none — the child is
present every day (Feature 05); absence is state, not an error.
"""
patterns: list[AdminPresencePatternResponse]
class AdminPresenceOverrideCreateRequest(BaseModel): class AdminPresenceOverrideCreateRequest(BaseModel):
"""The body of ``POST /api/v1/admin/presence-overrides``. """The body of ``POST /api/v1/admin/presence-overrides``.
@@ -669,16 +711,6 @@ class AdminPresenceOverrideResponse(BaseModel):
note: str | None note: str | None
class AdminPresenceScheduleReadResponse(BaseModel):
"""The ``GET /children/{child_id}/presence-schedule`` payload.
``schedule`` is ``None`` when the child has no schedule — the child
is present every day (Feature 05); absence is state, not an error.
"""
schedule: AdminPresenceScheduleResponse | None
class AdminPresenceOverrideListResponse(BaseModel): class AdminPresenceOverrideListResponse(BaseModel):
"""The ``GET /presence-overrides`` payload: the matching overrides.""" """The ``GET /presence-overrides`` payload: the matching overrides."""
@@ -989,8 +1021,9 @@ def _raise_presence_override_error(error: ValueError) -> NoReturn:
``override_id:`` (an unknown id), and that one case is this route's ``override_id:`` (an unknown id), and that one case is this route's
404. Every other ``ValueError`` — a non-integer id the path 404. Every other ``ValueError`` — a non-integer id the path
parser's ``int`` already makes unreachable — becomes 422. The parser's ``int`` already makes unreachable — becomes 422. The
schedule and override-CREATE routes do NOT use this mapper: their pattern list/create and override-CREATE routes do NOT use this
only 404 is an unknown CHILD, mapped by :func:`_raise_child_error`. mapper: their only 404 is an unknown CHILD, mapped by
:func:`_raise_child_error`.
""" """
if str(error).startswith("override_id:"): if str(error).startswith("override_id:"):
raise HTTPException( raise HTTPException(
@@ -999,6 +1032,26 @@ def _raise_presence_override_error(error: ValueError) -> NoReturn:
raise HTTPException(status_code=422, detail=str(error)) from error raise HTTPException(status_code=422, detail=str(error)) from error
#: 404 detail for a pattern id the core layer reports as non-existent.
_PRESENCE_PATTERN_NOT_FOUND_DETAIL = "Presence pattern not found"
def _raise_presence_pattern_error(error: ValueError) -> NoReturn:
"""Map a core ``ValueError`` from the pattern PATCH/DELETE routes.
Mirrors :func:`_raise_presence_override_error`: the core names a
missing pattern by prefixing its error with ``pattern_id:``, and
that one case is 404. Every other ``ValueError`` — a rejected edit
(blank name, unknown kind, bad cycle length, malformed anchor date
or pattern), each naming its field — becomes 422.
"""
if str(error).startswith("pattern_id:"):
raise HTTPException(
status_code=404, detail=_PRESENCE_PATTERN_NOT_FOUND_DETAIL
) from error
raise HTTPException(status_code=422, detail=str(error)) from error
#: 404 detail for an instance id the core layer reports as non-existent. #: 404 detail for an instance id the core layer reports as non-existent.
_QUEST_INSTANCE_NOT_FOUND_DETAIL = "Quest instance not found" _QUEST_INSTANCE_NOT_FOUND_DETAIL = "Quest instance not found"
@@ -1169,16 +1222,20 @@ def _quest_definition_response(
) )
def _presence_schedule_response( def _presence_pattern_response(
schedule: core_presence.PresenceSchedule, record: core_presence_management.PresencePatternRecord,
) -> AdminPresenceScheduleResponse: ) -> AdminPresencePatternResponse:
"""Serialize one decoded core PresenceSchedule into the payload.""" """Serialize one stored core pattern, weekday sets decoded."""
return AdminPresenceScheduleResponse( pattern = core_presence_management.decode_pattern_record(record)
child_id=schedule.child_id, return AdminPresencePatternResponse(
cycle_length_weeks=schedule.cycle_length_weeks, id=record.id,
anchor_date=schedule.anchor_date.isoformat(), child_id=pattern.child_id,
name=pattern.name,
kind=pattern.kind,
cycle_length_weeks=pattern.cycle_length_weeks,
anchor_date=pattern.anchor_date.isoformat(),
pattern={ pattern={
week: sorted(days) for week, days in schedule.pattern.items() week: sorted(days) for week, days in pattern.pattern.items()
}, },
) )
@@ -1545,40 +1602,136 @@ async def admin_set_quest_definition_active(
return _quest_definition_response(bundle) return _quest_definition_response(bundle)
@router.put( @router.get(
"/children/{child_id}/presence-schedule", "/children/{child_id}/presence-patterns",
summary="Set a child's repeating presence schedule", summary="List a child's repeating presence patterns",
response_model=AdminPresenceScheduleResponse, response_model=AdminPresencePatternListResponse,
) )
async def admin_set_presence_schedule( async def admin_list_presence_patterns(
child_id: int, body: AdminPresenceScheduleRequest, request: Request child_id: int, request: Request
) -> AdminPresenceScheduleResponse: ) -> AdminPresencePatternListResponse:
"""Set (upsert) a child's repeating presence schedule. """Return the child's presence patterns, ordered by id.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: one
:func:`nestquest_core.presence_management.list_presence_patterns`
call, each pattern serialized exactly as the POST response is; a
child without one answers ``{"patterns": []}`` (present every day).
Errors are mapped by :func:`_raise_child_error`: an unknown child
is 404.
"""
state: DatabaseState = request.app.state.db
try:
records = await core_presence_management.list_presence_patterns(
state.database, child_id
)
except ValueError as error:
_raise_child_error(error)
return AdminPresencePatternListResponse(
patterns=[_presence_pattern_response(record) for record in records]
)
@router.post(
"/children/{child_id}/presence-patterns",
summary="Add a repeating presence pattern to a child",
status_code=201,
response_model=AdminPresencePatternResponse,
)
async def admin_create_presence_pattern(
child_id: int, body: AdminPresencePatternCreateRequest, request: Request
) -> AdminPresencePatternResponse:
"""Add one repeating presence pattern to a child.
Requires a valid admin JWT (the router's shared Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this :func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: the body model handler performs NO auth of its own). Thin adapter: the body model
carries the shape, and the cycle-length, anchor-date and carries the shape, and the name, kind, cycle-length, anchor-date
pattern-coverage policies all live in the and pattern-coverage policies all live in the
:class:`~nestquest_core.presence.PresenceSchedule` constructor the :class:`~nestquest_core.presence.PresencePattern` constructor the
core builds the schedule with — setting again REPLACES the child's core builds the pattern with; the core regenerates the child's
one schedule, and the core regenerates the child's future instances future instances so they track the new presence. Errors are
so they track the new presence. Errors are mapped by mapped by :func:`_raise_child_error`: an unknown child is 404, a
:func:`_raise_child_error`: an unknown child is 404, a rejected rejected pattern 422.
schedule 422.
""" """
state: DatabaseState = request.app.state.db state: DatabaseState = request.app.state.db
try: try:
schedule = await core_presence_management.set_presence_schedule( record = await core_presence_management.create_presence_pattern(
state.database, state.database,
child_id, child_id,
body.name,
body.kind,
body.cycle_length_weeks, body.cycle_length_weeks,
body.anchor_date, body.anchor_date,
body.pattern, body.pattern,
) )
except ValueError as error: except ValueError as error:
_raise_child_error(error) _raise_child_error(error)
return _presence_schedule_response(schedule) return _presence_pattern_response(record)
@router.patch(
"/presence-patterns/{pattern_id}",
summary="Edit a repeating presence pattern",
response_model=AdminPresencePatternResponse,
)
async def admin_edit_presence_pattern(
pattern_id: int, body: AdminPresencePatternEditRequest, request: Request
) -> AdminPresencePatternResponse:
"""Edit one presence pattern; only supplied fields change.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: the supplied
fields are forwarded with ``exclude_unset`` semantics to
:func:`nestquest_core.presence_management.update_presence_pattern`,
which rebuilds the whole pattern through the model (an explicit
``null`` is rejected there) and regenerates the pattern's child.
Error mapping (:func:`_raise_presence_pattern_error`): an unknown
pattern id is 404; a rejected edit is 422.
"""
state: DatabaseState = request.app.state.db
try:
record = await core_presence_management.update_presence_pattern(
state.database,
pattern_id,
**body.model_dump(exclude_unset=True),
)
except ValueError as error:
_raise_presence_pattern_error(error)
return _presence_pattern_response(record)
@router.delete(
"/presence-patterns/{pattern_id}",
summary="Delete a repeating presence pattern",
)
async def admin_delete_presence_pattern(
pattern_id: int, request: Request
) -> dict[str, str]:
"""Delete one presence pattern.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: the id goes
straight to
:func:`nestquest_core.presence_management.delete_presence_pattern`,
which looks the pattern up first (an unknown id is refused BEFORE
any delete), removes it and regenerates ITS child's future
instances. On success the route answers ``{"status": "ok"}``.
Error mapping (:func:`_raise_presence_pattern_error`): an unknown
pattern id is 404; every other core ``ValueError`` is 422.
"""
state: DatabaseState = request.app.state.db
try:
await core_presence_management.delete_presence_pattern(
state.database, pattern_id
)
except ValueError as error:
_raise_presence_pattern_error(error)
return {"status": "ok"}
@router.post( @router.post(
@@ -1694,39 +1847,6 @@ async def admin_presence_override_consequence(
return AdminPresenceOverrideConsequenceResponse(removed=removed) return AdminPresenceOverrideConsequenceResponse(removed=removed)
@router.get(
"/children/{child_id}/presence-schedule",
summary="Read a child's repeating presence schedule",
response_model=AdminPresenceScheduleReadResponse,
)
async def admin_get_presence_schedule(
child_id: int, request: Request
) -> AdminPresenceScheduleReadResponse:
"""Return the child's presence schedule, or ``schedule: null``.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: one
:func:`nestquest_core.presence_management.get_presence_schedule`
call; a stored schedule is serialized exactly as the PUT response
is, and a child without one answers ``{"schedule": null}`` (present
every day). Errors are mapped by :func:`_raise_child_error`: an
unknown child is 404.
"""
state: DatabaseState = request.app.state.db
try:
schedule = await core_presence_management.get_presence_schedule(
state.database, child_id
)
except ValueError as error:
_raise_child_error(error)
return AdminPresenceScheduleReadResponse(
schedule=(
None if schedule is None else _presence_schedule_response(schedule)
)
)
@router.get( @router.get(
"/presence-overrides", "/presence-overrides",
summary="List presence overrides", summary="List presence overrides",
+123 -74
View File
@@ -1,4 +1,4 @@
"""Typed DAO layer for the presence_schedules and presence_overrides tables. """Typed DAO layer for the presence_patterns and presence_overrides tables.
All SQL for these tables lives in this module per the feature All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows guardrails: callers get typed dataclasses back and never see raw rows
@@ -8,11 +8,12 @@ executor and the event loop never blocks.
Semantics per the done-condition and Feature 05: Semantics per the done-condition and Feature 05:
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting - A child has ZERO OR MORE presence patterns (schema 9 — no UNIQUE on
by child updates the existing row rather than raising. ``child_id``); each is created, updated and deleted by its own id.
- A child with NO schedule row is present every day — the absence of a - A child with NO pattern row is present every day — the absence of a
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child` row is meaningful state, expressed by
returning None, never by an empty pattern row. :meth:`PresencePatternsDao.list_by_child` returning an empty list,
never by an empty pattern row.
- Overrides beat the pattern for their date range (that resolution is - Overrides beat the pattern for their date range (that resolution is
the presence engine's job, Feature 05); the DAO only stores and the presence engine's job, Feature 05); the DAO only stores and
lists them. A single-day override stores the same date as both lists them. A single-day override stores the same date as both
@@ -32,11 +33,13 @@ from .db import NestQuestDatabase
@dataclass(frozen=True) @dataclass(frozen=True)
class PresenceScheduleRecord: class PresencePatternRecord:
"""One row of ``presence_schedules``.""" """One row of ``presence_patterns``."""
id: int id: int
child_id: int child_id: int
name: str
kind: str
cycle_length_weeks: int cycle_length_weeks: int
anchor_date: str anchor_date: str
pattern: str pattern: str
@@ -54,21 +57,23 @@ class PresenceOverrideRecord:
note: str | None note: str | None
_SCHEDULE_COLUMNS = ( _PATTERN_COLUMNS = (
"id, child_id, cycle_length_weeks, anchor_date, pattern" "id, child_id, name, kind, cycle_length_weeks, anchor_date, pattern"
) )
_OVERRIDE_COLUMNS = ( _OVERRIDE_COLUMNS = (
"id, child_id, start_date, end_date, is_present, note" "id, child_id, start_date, end_date, is_present, note"
) )
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord: def _pattern_from_row(row: tuple) -> PresencePatternRecord:
return PresenceScheduleRecord( return PresencePatternRecord(
id=row[0], id=row[0],
child_id=row[1], child_id=row[1],
cycle_length_weeks=row[2], name=row[2],
anchor_date=row[3], kind=row[3],
pattern=row[4], cycle_length_weeks=row[4],
anchor_date=row[5],
pattern=row[6],
) )
@@ -83,29 +88,27 @@ def _override_from_row(row: tuple) -> PresenceOverrideRecord:
) )
class PresenceSchedulesDao: class PresencePatternsDao:
"""Typed async access to the ``presence_schedules`` table.""" """Typed async access to the ``presence_patterns`` table."""
def __init__(self, database: NestQuestDatabase) -> None: def __init__(self, database: NestQuestDatabase) -> None:
self._database = database self._database = database
async def upsert_by_child( async def create(
self, self,
child_id: int, child_id: int,
name: str,
kind: str,
cycle_length_weeks: int, cycle_length_weeks: int,
anchor_date: str, anchor_date: str,
pattern: str, pattern: str,
) -> PresenceScheduleRecord: ) -> PresencePatternRecord:
"""Set the child's presence schedule, replacing any existing one. """Insert one pattern for the child and return it as stored.
Upsert semantics are the done-condition: a second schedule for The child must exist (FK) — validated atomically under the
the same child updates the existing row rather than raising. connection lock so a deleted child cannot pass a stale check.
Runs under the connection lock so a concurrent upsert/delete ``kind``, the cycle length and the pattern shape (segment count
for the same child serializes instead of colliding on the = ``cycle_length_weeks``) are enforced by the schema CHECKs.
schema's UNIQUE(child_id). Pattern validity (segment shape,
segment count = cycle_length_weeks) is enforced by the schema
CHECKs; the child must exist (FK) — validated atomically here
so a deleted child cannot pass a stale check.
""" """
_validate_date(anchor_date, "anchor_date") _validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database): async with _connection_lock(self._database):
@@ -115,53 +118,98 @@ class PresenceSchedulesDao:
) )
if child is None: if child is None:
raise ValueError(f"child {child_id} does not exist") raise ValueError(f"child {child_id} does not exist")
await self._database.execute( result = await self._database.execute(
"INSERT INTO presence_schedules (child_id, " "INSERT INTO presence_patterns (child_id, name, kind, "
"cycle_length_weeks, anchor_date, pattern) " "cycle_length_weeks, anchor_date, pattern) "
"VALUES (?, ?, ?, ?) " "VALUES (?, ?, ?, ?, ?, ?)",
"ON CONFLICT (child_id) DO UPDATE SET " (
"cycle_length_weeks = excluded.cycle_length_weeks, " child_id,
"anchor_date = excluded.anchor_date, " name,
"pattern = excluded.pattern", kind,
(child_id, cycle_length_weeks, anchor_date, pattern), cycle_length_weeks,
anchor_date,
pattern,
),
) )
schedule = await self.get_by_child(child_id) record = await self.get(result.lastrowid)
assert schedule is not None assert record is not None
return schedule return record
async def get_by_child( async def list_by_child(self, child_id: int) -> list[PresencePatternRecord]:
self, child_id: int """Return the child's patterns ordered by id; empty when none.
) -> PresenceScheduleRecord | None:
"""Return the child's schedule, or None when the child has none.
None is meaningful and UNAMBIGUOUS here: the child exists and An empty list is meaningful and UNAMBIGUOUS here: the child
has no schedule row, i.e. the child is present every day exists and has no pattern row, i.e. the child is present every
(Feature 05 guardrail). An unknown child id raises ValueError day (Feature 05 guardrail). An unknown child id raises
instead of returning None, because "nonexistent child" must ValueError instead of returning an empty list, because
never be silently read as "present every day". "nonexistent child" must never be silently read as "present
every day".
""" """
child = await self._database.fetch_one( child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,) "SELECT 1 FROM children WHERE id = ?", (child_id,)
) )
if child is None: if child is None:
raise ValueError(f"child {child_id} does not exist") raise ValueError(f"child {child_id} does not exist")
row = await self._database.fetch_one( rows = await self._database.fetch_all(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules " f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns "
"WHERE child_id = ?", "WHERE child_id = ? ORDER BY id",
(child_id,), (child_id,),
) )
return _schedule_from_row(row) if row is not None else None return [_pattern_from_row(row) for row in rows]
async def delete(self, child_id: int) -> bool: async def get(self, pattern_id: int) -> PresencePatternRecord | None:
"""Remove the child's schedule; True if a row was removed. """Return one pattern by id, or None when it does not exist."""
row = await self._database.fetch_one(
f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns WHERE id = ?",
(pattern_id,),
)
return _pattern_from_row(row) if row is not None else None
After deletion the child is present every day (no row means async def update(
no restriction). Deleting a schedule the child never had self,
returns False. pattern_id: int,
name: str,
kind: str,
cycle_length_weeks: int,
anchor_date: str,
pattern: str,
) -> PresencePatternRecord | None:
"""Replace one pattern's fields; None when the id does not exist.
Every column but ``id`` and ``child_id`` is rewritten — a
pattern never moves to another child. The schema CHECKs apply
exactly as on :meth:`create`.
"""
_validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database):
async with self._database.transaction():
result = await self._database.execute(
"UPDATE presence_patterns SET name = ?, kind = ?, "
"cycle_length_weeks = ?, anchor_date = ?, pattern = ? "
"WHERE id = ?",
(
name,
kind,
cycle_length_weeks,
anchor_date,
pattern,
pattern_id,
),
)
if result.rowcount == 0:
return None
record = await self.get(pattern_id)
return record
async def delete(self, pattern_id: int) -> bool:
"""Remove one pattern by id; True if a row was removed.
Deleting a child's last pattern leaves it present every day (no
row means no restriction).
""" """
result = await self._database.execute( result = await self._database.execute(
"DELETE FROM presence_schedules WHERE child_id = ?", "DELETE FROM presence_patterns WHERE id = ?",
(child_id,), (pattern_id,),
) )
return result.rowcount > 0 return result.rowcount > 0
@@ -345,22 +393,23 @@ async def read_snapshot_unlocked(
range_start: str, range_start: str,
range_end: str, range_end: str,
) -> tuple[ ) -> tuple[
dict[int, PresenceScheduleRecord], dict[int, list[PresencePatternRecord]],
dict[int, list[PresenceOverrideRecord]], dict[int, list[PresenceOverrideRecord]],
]: ]:
"""Read schedules and overrides for ``child_ids`` WITHOUT locking. """Read patterns and overrides for ``child_ids`` WITHOUT locking.
MUST be called inside the connection lock and an open transaction, so MUST be called inside the connection lock and an open transaction, so
the schedules and overrides returned here stay coherent with every the patterns and overrides returned here stay coherent with every
other table the caller reads inside that same transaction (the other table the caller reads inside that same transaction (the
materialization walk's single input snapshot does exactly that). The materialization walk's single input snapshot does exactly that). The
date range comes already validated by the caller. date range comes already validated by the caller.
Returns ``(schedules, overrides)``: Returns ``(patterns, overrides)``:
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord` - ``patterns`` maps ``child_id`` to its :class:`PresencePatternRecord`
for every child that HAS a schedule row. A child absent from the list (ordered by id) for every child that HAS a pattern row. A
mapping has no schedule, i.e. is present every day (Feature 05). child absent from the mapping has no pattern, i.e. is present
every day (Feature 05).
- ``overrides`` maps ``child_id`` to that child's overrides overlapping - ``overrides`` maps ``child_id`` to that child's overrides overlapping
``[range_start, range_end]`` (inclusive boundaries), ordered by start ``[range_start, range_end]`` (inclusive boundaries), ordered by start
date; a child with no matching override is absent from the mapping. date; a child with no matching override is absent from the mapping.
@@ -372,9 +421,9 @@ async def read_snapshot_unlocked(
if not children: if not children:
return {}, {} return {}, {}
placeholders = ",".join("?" for _ in children) placeholders = ",".join("?" for _ in children)
schedule_rows = await database.fetch_all( pattern_rows = await database.fetch_all(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules " f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns "
f"WHERE child_id IN ({placeholders})", f"WHERE child_id IN ({placeholders}) ORDER BY id",
tuple(children), tuple(children),
) )
override_rows = await database.fetch_all( override_rows = await database.fetch_all(
@@ -383,12 +432,12 @@ async def read_snapshot_unlocked(
"AND start_date <= ? ORDER BY start_date", "AND start_date <= ? ORDER BY start_date",
tuple(children) + (range_start, range_end), tuple(children) + (range_start, range_end),
) )
schedules = { patterns: dict[int, list[PresencePatternRecord]] = {}
record.child_id: record for row in pattern_rows:
for record in map(_schedule_from_row, schedule_rows) record = _pattern_from_row(row)
} patterns.setdefault(record.child_id, []).append(record)
overrides: dict[int, list[PresenceOverrideRecord]] = {} overrides: dict[int, list[PresenceOverrideRecord]] = {}
for row in override_rows: for row in override_rows:
record = _override_from_row(row) record = _override_from_row(row)
overrides.setdefault(record.child_id, []).append(record) overrides.setdefault(record.child_id, []).append(record)
return schedules, overrides return patterns, overrides
@@ -1319,16 +1319,16 @@ async def load_materialization_input(
): ):
"""Read every materialization input in ONE locked transaction. """Read every materialization input in ONE locked transaction.
Returns ``(snapshots, schedules, overrides)``: Returns ``(snapshots, patterns, overrides)``:
- ``snapshots``: every active definition with its rule, assignees and - ``snapshots``: every active definition with its rule, assignees and
windows (the :class:`QuestDefinitionSnapshot` shape). windows (the :class:`QuestDefinitionSnapshot` shape).
- ``schedules`` / ``overrides``: the presence schedules and - ``patterns`` / ``overrides``: the presence patterns and
range-scoped overrides of every assignee child, in range-scoped overrides of every assignee child, in
:mod:`.dao_presence`'s record shapes. :mod:`.dao_presence`'s record shapes.
The whole read — definitions, rules, assignees, windows, and the The whole read — definitions, rules, assignees, windows, and the
assignees' presence schedules and overrides — runs inside a single assignees' presence patterns and overrides — runs inside a single
BEGIN..COMMIT span under the connection lock, so a concurrent edit BEGIN..COMMIT span under the connection lock, so a concurrent edit
(definition, assignment, window, or presence) cannot interleave (definition, assignment, window, or presence) cannot interleave
between the parts and the walk never sees a mixed-time view. Each between the parts and the walk never sees a mixed-time view. Each
@@ -1349,7 +1349,7 @@ async def load_materialization_input(
) )
from . import dao_presence # lazy: dao_presence imports us from . import dao_presence # lazy: dao_presence imports us
schedules, overrides = await dao_presence.read_snapshot_unlocked( patterns, overrides = await dao_presence.read_snapshot_unlocked(
database, child_ids, range_start, range_end database, child_ids, range_start, range_end
) )
return snapshots, schedules, overrides return snapshots, patterns, overrides
+33 -28
View File
@@ -9,7 +9,7 @@ task and lives elsewhere.
The walk is deterministic and idempotent: The walk is deterministic and idempotent:
- All inputs — the active definitions with their rules, assignees and - All inputs — the active definitions with their rules, assignees and
windows, AND the assignee children's presence schedules and scoped windows, AND the assignee children's presence patterns and scoped
overrides — are read inside ONE locked transaction (see overrides — are read inside ONE locked transaction (see
:func:`~.dao_rules.load_materialization_input`), so the walk sees a :func:`~.dao_rules.load_materialization_input`), so the walk sees a
single coherent snapshot rather than a definition state from one single coherent snapshot rather than a definition state from one
@@ -51,12 +51,12 @@ never the past — and re-run the walk over the rolling horizon
HOOK (Feature 09 presence services): the presence business layer that HOOK (Feature 09 presence services): the presence business layer that
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
presence-state write so a child's future instances track its presence: presence-state write so a child's future instances track its presence:
``set_presence_pattern`` (including clearing/setting an empty pattern), creating, updating and deleting a presence pattern
deleting/clearing the presence schedule (:mod:`.presence_management`'s ``create_presence_pattern`` /
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`), ``update_presence_pattern`` / ``delete_presence_pattern``),
``create_presence_override``, and ``delete_presence_override``. A ``create_presence_override``, and ``delete_presence_override``. A
schedule delete changes presence state too (the child becomes pattern delete changes presence state too, so it must regenerate just
always-present), so it must regenerate just like the others. This hook like the others. This hook
is documented, not yet wired: no presence business layer exists yet, and is documented, not yet wired: no presence business layer exists yet, and
the presence DAO (``dao_presence.py``) must stay free of business rules the presence DAO (``dao_presence.py``) must stay free of business rules
— never call :func:`regenerate_for_child` from inside the DAO layer. — never call :func:`regenerate_for_child` from inside the DAO layer.
@@ -79,7 +79,7 @@ from .dao_rules import (
schedule_rule_storage_from_record, schedule_rule_storage_from_record,
) )
from .db import NestQuestDatabase from .db import NestQuestDatabase
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule from .presence import PresenceEngine, PresenceOverride, PresencePattern
from .recurrence import ScheduleRule, occurs_on from .recurrence import ScheduleRule, occurs_on
@@ -129,7 +129,7 @@ def _decode_rule(snapshot) -> ScheduleRule:
def _build_engine( def _build_engine(
schedules_records, patterns_records,
overrides_records, overrides_records,
*, *,
extra_overrides: tuple[PresenceOverride, ...] = (), extra_overrides: tuple[PresenceOverride, ...] = (),
@@ -140,11 +140,18 @@ def _build_engine(
overrides on top of the stored ones — the consequence preview's overrides on top of the stored ones — the consequence preview's
proposed override (:func:`count_removed_by_override`). proposed override (:func:`count_removed_by_override`).
""" """
schedules: dict[int, PresenceSchedule] = {} patterns: dict[int, list[PresencePattern]] = {}
for child_id, record in schedules_records.items(): for child_id, records in patterns_records.items():
schedules[child_id] = PresenceSchedule.decode( patterns[child_id] = [
child_id, record.anchor_date, record.pattern PresencePattern.decode(
) record.child_id,
record.name,
record.kind,
record.anchor_date,
record.pattern,
)
for record in records
]
overrides: dict[int, list[PresenceOverride]] = {} overrides: dict[int, list[PresenceOverride]] = {}
for child_id, records in overrides_records.items(): for child_id, records in overrides_records.items():
overrides[child_id] = [ overrides[child_id] = [
@@ -159,7 +166,7 @@ def _build_engine(
] ]
for override in extra_overrides: for override in extra_overrides:
overrides.setdefault(override.child_id, []).append(override) overrides.setdefault(override.child_id, []).append(override)
return PresenceEngine(schedules, overrides) return PresenceEngine(patterns, overrides)
class GeneratedTuple(NamedTuple): class GeneratedTuple(NamedTuple):
@@ -279,10 +286,10 @@ async def materialize(
f"{end_date!r} < {start_date!r}" f"{end_date!r} < {start_date!r}"
) )
snapshots, schedules_records, overrides_records = await ( snapshots, patterns_records, overrides_records = await (
load_materialization_input(database, start_date, end_date) load_materialization_input(database, start_date, end_date)
) )
engine = _build_engine(schedules_records, overrides_records) engine = _build_engine(patterns_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids) child_filter = None if child_ids is None else set(child_ids)
generated_at = _now_stamp() generated_at = _now_stamp()
@@ -366,12 +373,11 @@ async def regenerate_for_definition(
# child-presence counterpart to ``regenerate_for_definition``. The # child-presence counterpart to ``regenerate_for_definition``. The
# presence business layer that lands in Feature 09 MUST call # presence business layer that lands in Feature 09 MUST call
# ``regenerate_for_child`` after EVERY presence-state write, so future # ``regenerate_for_child`` after EVERY presence-state write, so future
# instances track a child's changed presence: set_presence_pattern # instances track a child's changed presence: create_presence_pattern,
# (including clearing/setting an empty pattern), deleting/clearing the # update_presence_pattern, delete_presence_pattern,
# presence schedule (PresenceSchedulesDao.delete), # create_presence_override, and delete_presence_override. A pattern
# create_presence_override, and delete_presence_override. A schedule # delete changes presence state too, so it must regenerate just like
# delete changes presence state too (the child becomes always-present), # the others. It is deliberately NOT
# so it must regenerate just like the others. It is deliberately NOT
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no # wired into ``dao_presence.py``: the DAO layer stays pure storage (no
# business rules), and no presence business layer exists yet to host the # business rules), and no presence business layer exists yet to host the
# call — see the module docstring note above. # call — see the module docstring note above.
@@ -385,9 +391,8 @@ async def regenerate_for_child(
"""Regenerate a child's future instances after a presence change. """Regenerate a child's future instances after a presence change.
The Feature 09 presence services call this after every The Feature 09 presence services call this after every
presence-state write — set_presence_pattern (including clearing or presence-state write — create_presence_pattern,
setting an empty pattern), deleting/clearing the presence schedule update_presence_pattern, delete_presence_pattern,
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
create_presence_override, and delete_presence_override — so a create_presence_override, and delete_presence_override — so a
child's future instances track its new presence: the child's open child's future instances track its new presence: the child's open
instances at or after today are deleted across ALL its definitions instances at or after today are deleted across ALL its definitions
@@ -481,12 +486,12 @@ async def count_removed_by_override(
start_date, end_date = start.isoformat(), end.isoformat() start_date, end_date = start.isoformat(), end.isoformat()
child_id = override.child_id child_id = override.child_id
snapshots, schedules_records, overrides_records = await ( snapshots, patterns_records, overrides_records = await (
load_materialization_input(database, start_date, end_date) load_materialization_input(database, start_date, end_date)
) )
current = _generation_preview( current = _generation_preview(
snapshots, snapshots,
_build_engine(schedules_records, overrides_records), _build_engine(patterns_records, overrides_records),
start, start,
end, end,
child_filter={child_id}, child_filter={child_id},
@@ -495,7 +500,7 @@ async def count_removed_by_override(
_generation_preview( _generation_preview(
snapshots, snapshots,
_build_engine( _build_engine(
schedules_records, patterns_records,
overrides_records, overrides_records,
extra_overrides=(override,), extra_overrides=(override,),
), ),
+78 -3
View File
@@ -31,7 +31,11 @@ widened (definition_id, child_id, due_date, window) key, and migration
column. Migration 7 adds the generic ``nestquest_meta_state`` column. Migration 7 adds the generic ``nestquest_meta_state``
key/value table for integration-owned bookkeeping (Feature 11's key/value table for integration-owned bookkeeping (Feature 11's
missed-sweep watermark). Migration 8 adds the ``skip_on_away`` missed-sweep watermark). Migration 8 adds the ``skip_on_away``
column to ``quest_definitions`` (Feature 22), defaulting to 1. Each column to ``quest_definitions`` (Feature 22), defaulting to 1.
Migration 9 replaces the one-per-child ``presence_schedules`` table
with ``presence_patterns`` (zero or more patterns per child), copying
every schedule across as a ``home`` pattern and dropping the old table
— see :data:`MIGRATION_9_PRESENCE_PATTERNS` for the rollback note. Each
callable migration runs inside its own transaction callable migration runs inside its own transaction
together with its version stamp, so a crash mid-step rolls the together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together. statements and the stamp back together.
@@ -85,6 +89,7 @@ from .schema import (
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
SCHEMA_V7_META_STATE_DDL, SCHEMA_V7_META_STATE_DDL,
SCHEMA_V9_PRESENCE_PATTERNS_DDL,
) )
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@@ -116,9 +121,76 @@ VERSION_TABLE_DDL = f"""
) )
""" """
#: The retired ``presence_schedules`` DDL (one schedule per child),
#: frozen here verbatim: migration 1 shipped creating this table and
#: applied migrations are never edited, so a fresh file still walks
#: through it and migration 9 retires it. :mod:`.schema` no longer
#: declares it.
_MIGRATION_1_PRESENCE_SCHEDULES_DDL = """
CREATE TABLE IF NOT EXISTS presence_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL CHECK (
NOT pattern GLOB '*[^0-6,|]*'
AND NOT pattern GLOB '*[0-6][0-6]*'
AND NOT pattern GLOB '*,,*'
AND NOT pattern GLOB '*,|*'
AND NOT pattern GLOB '*|,*'
AND NOT pattern GLOB ',*'
AND NOT pattern GLOB '*,'
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
)
)
"""
#: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades #: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades
#: version ``n-1`` to version ``n``. #: version ``n-1`` to version ``n``. Migration 1 is the v1 DDL as it
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS) #: shipped: the current tables, except that it still creates the
#: retired ``presence_schedules`` table in place of
#: ``presence_patterns`` (which migration 9 creates).
MIGRATION_1_V1_DDL: list[str] = [
_MIGRATION_1_PRESENCE_SCHEDULES_DDL
if sql in SCHEMA_V9_PRESENCE_PATTERNS_DDL
else sql
for sql in SCHEMA_V1_STATEMENTS
]
#: The label every migrated ``presence_schedules`` row carries as a
#: ``presence_patterns`` row.
MIGRATED_PRESENCE_PATTERN_NAME = "Home schedule"
#: Migration 9 (schema 9): multiple presence patterns per child.
#: Creates ``presence_patterns``, copies EVERY ``presence_schedules``
#: row into it as a ``home`` pattern named
#: :data:`MIGRATED_PRESENCE_PATTERN_NAME` — ``cycle_length_weeks``,
#: ``anchor_date`` and ``pattern`` verbatim, the row id kept — and
#: drops ``presence_schedules``. A ``home`` pattern with no other
#: pattern beside it answers exactly as the old schedule did (the
#: engine treats a date no home pattern covers as a day away), so every
#: child's presence is unchanged. A plain statement list: the runner
#: applies it and the version stamp in ONE transaction, so a failure
#: rolls the copy and the drop back together. Dropping a table nothing
#: references needs no ``foreign_keys = OFF`` bracket.
#:
#: Rollback: the drop is one-way. A 0.7.1 build refuses a version-9
#: file ("newer than this integration understands") and could not read
#: ``presence_patterns`` anyway, so take a copy of the SQLite file (or
#: note the Litestream generation) BEFORE the first start of the release
#: carrying migration 9; rolling back to 0.7.1 means restoring that
#: pre-migration copy, which still holds the single-schedule rows.
#: Pattern edits made after the upgrade are not in that copy.
MIGRATION_9_PRESENCE_PATTERNS: list[str] = [
*SCHEMA_V9_PRESENCE_PATTERNS_DDL,
"INSERT INTO presence_patterns (id, child_id, name, kind, "
"cycle_length_weeks, anchor_date, pattern) "
f"SELECT id, child_id, '{MIGRATED_PRESENCE_PATTERN_NAME}', 'home', "
"cycle_length_weeks, anchor_date, pattern "
"FROM presence_schedules ORDER BY id",
"DROP TABLE presence_schedules",
]
#: The assignees table DDL, shared with migration 1 via the schema #: The assignees table DDL, shared with migration 1 via the schema
#: module so a rebuilt and a freshly created table cannot drift. #: module so a rebuilt and a freshly created table cannot drift.
@@ -450,6 +522,9 @@ MIGRATIONS: Sequence[MigrationStep] = [
# quest_definitions — an additive column defaulting to 1, so every # quest_definitions — an additive column defaulting to 1, so every
# pre-existing definition keeps today's skip-when-absent behaviour. # pre-existing definition keeps today's skip-when-absent behaviour.
_add_skip_on_away, _add_skip_on_away,
# Migration 9 (schema 9): presence_schedules -> presence_patterns,
# each schedule copied across as a 'home' pattern.
MIGRATION_9_PRESENCE_PATTERNS,
] ]
+139 -70
View File
@@ -5,21 +5,24 @@ timezone logic — it operates on plain calendar dates only (feature
guardrail). The later engine (cycle position, ``is_present``, guardrail). The later engine (cycle position, ``is_present``,
overrides, previews) builds directly on this model. overrides, previews) builds directly on this model.
A :class:`PresenceSchedule` answers "on which weekdays of which cycle A :class:`PresencePattern` answers "on which weekdays of which cycle
week is this child at this house?" for an N-week repeating custody week does this rule apply to this child?" for an N-week repeating
pattern. Two of the household's three children follow a two-week custody rule, and its ``kind`` says what the rule claims on those
rotation; a child with NO schedule row is present every day (that days: ``home`` (the child is at this house) or ``away``. A child
default lives in the engine, not here). carries ZERO OR MORE patterns (schema 9), e.g. "away every Thursday
and Friday" plus "away every other weekend"; a child with NO pattern
is present every day (that default, and how patterns combine, lives in
the engine, not here).
Pattern encoding: the model's canonical serialization is the SAME Pattern encoding: the model's canonical serialization is the SAME
pipe-separated CSV the database stores (see schema.py's pipe-separated CSV the database stores (see schema.py's
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a ``presence_patterns.pattern``): segment ``i`` (week index ``i``) is a
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday, comma-separated list of the weekdays the pattern covers, 0 = Monday ..
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An 6 = Sunday, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
empty segment means absent every day of that week. Encoding sorts week 1. An empty segment means the pattern covers no day of that
each segment's weekdays and uses the exact segment order, so week. Encoding sorts each segment's weekdays and uses the exact
``decode(encode(schedule))`` round-trips losslessly and two schedules segment order, so ``decode(encode(pattern))`` round-trips losslessly
with equal patterns produce equal strings. and two patterns with equal weekday sets produce equal strings.
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
computed from anchor-date arithmetic ONLY — never ISO week numbers or computed from anchor-date arithmetic ONLY — never ISO week numbers or
@@ -28,7 +31,9 @@ week parity (D-004): 53-week years silently invert parity on January
that the anchor is a strict calendar date. that the anchor is a strict calendar date.
Validation policy: construction is total — every reject happens in Validation policy: construction is total — every reject happens in
``__init__`` so no invalid schedule can ever exist: ``__init__`` so no invalid pattern can ever exist:
- ``name`` must be a string that is not blank; it is stored trimmed.
- ``kind`` must be ``'home'`` or ``'away'``.
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches - ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
the database CHECK (schema.py) and keeps anchor arithmetic and the the database CHECK (schema.py) and keeps anchor arithmetic and the
encoded pattern human-readable; revisit both together if a longer encoded pattern human-readable; revisit both together if a longer
@@ -49,6 +54,10 @@ from types import MappingProxyType
#: the pattern human-readable and the anchor arithmetic small). #: the pattern human-readable and the anchor arithmetic small).
MAX_CYCLE_LENGTH_WEEKS = 4 MAX_CYCLE_LENGTH_WEEKS = 4
#: The pattern kinds the schema's CHECK allows: what a covering
#: pattern claims about the child on that date.
PATTERN_KINDS = ("home", "away")
_DATE_FORMAT = "%Y-%m-%d" _DATE_FORMAT = "%Y-%m-%d"
@@ -126,6 +135,25 @@ def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
return frozenset(members) return frozenset(members)
def _validate_name(value: object) -> str:
"""Reject non-string and blank pattern names; return it trimmed."""
if not isinstance(value, str):
raise ValueError(f"name must be a string, got {value!r}")
trimmed = value.strip()
if not trimmed:
raise ValueError("name must not be empty")
return trimmed
def _validate_kind(value: object) -> str:
"""Reject any kind other than the schema's ``home`` / ``away``."""
if not isinstance(value, str) or value not in PATTERN_KINDS:
raise ValueError(
f"kind must be one of {', '.join(PATTERN_KINDS)}, got {value!r}"
)
return value
def _validate_cycle_length(value: object) -> int: def _validate_cycle_length(value: object) -> int:
"""Reject non-int and out-of-range cycle lengths (bools included).""" """Reject non-int and out-of-range cycle lengths (bools included)."""
if isinstance(value, bool) or not isinstance(value, int): if isinstance(value, bool) or not isinstance(value, int):
@@ -148,17 +176,19 @@ def _validate_child_id(value: object) -> int:
@dataclass(frozen=True, init=False) @dataclass(frozen=True, init=False)
class PresenceSchedule: class PresencePattern:
"""One child's N-week repeating presence pattern. """One of a child's N-week repeating presence rules.
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``) ``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
to the set of present weekdays (ints, Monday=0). A week with an to the set of weekdays the rule covers (ints, Monday=0); ``kind``
empty set means the child is absent every day of that week — which is what the rule claims on a covered date (``home`` or ``away``).
is distinct from having no schedule at all (the engine treats a A week with an empty set covers no day of that week. How several
missing schedule as present every day). patterns combine is the engine's job (:class:`PresenceEngine`).
""" """
child_id: int child_id: int
name: str
kind: str
cycle_length_weeks: int cycle_length_weeks: int
anchor_date: datetime.date anchor_date: datetime.date
pattern: MappingProxyType # week index -> frozenset of weekdays pattern: MappingProxyType # week index -> frozenset of weekdays
@@ -166,11 +196,15 @@ class PresenceSchedule:
def __init__( def __init__(
self, self,
child_id: int, child_id: int,
name: str,
kind: str,
cycle_length_weeks: int, cycle_length_weeks: int,
anchor_date: object, anchor_date: object,
pattern: dict[int, object], pattern: dict[int, object],
) -> None: ) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id)) object.__setattr__(self, "child_id", _validate_child_id(child_id))
object.__setattr__(self, "name", _validate_name(name))
object.__setattr__(self, "kind", _validate_kind(kind))
cycle = _validate_cycle_length(cycle_length_weeks) cycle = _validate_cycle_length(cycle_length_weeks)
object.__setattr__(self, "cycle_length_weeks", cycle) object.__setattr__(self, "cycle_length_weeks", cycle)
object.__setattr__( object.__setattr__(
@@ -178,7 +212,7 @@ class PresenceSchedule:
) )
if not isinstance(pattern, dict): if not isinstance(pattern, dict):
raise ValueError( raise ValueError(
"pattern must map week index to a set of present " "pattern must map week index to a set of covered "
f"weekdays, got {pattern!r}" f"weekdays, got {pattern!r}"
) )
normalised: dict[int, frozenset[int]] = {} normalised: dict[int, frozenset[int]] = {}
@@ -204,9 +238,9 @@ class PresenceSchedule:
"""Serialize to the database's pipe-separated CSV shape. """Serialize to the database's pipe-separated CSV shape.
Segments appear in week order; each segment is the week's Segments appear in week order; each segment is the week's
present weekdays as a sorted comma-separated list (an empty set covered weekdays as a sorted comma-separated list (an empty set
encodes as an empty segment). Two schedules with equal encodes as an empty segment). Two patterns with equal weekday
patterns encode identically. sets encode identically.
""" """
segments = [ segments = [
",".join(str(day) for day in sorted(self.pattern[week])) ",".join(str(day) for day in sorted(self.pattern[week]))
@@ -218,10 +252,12 @@ class PresenceSchedule:
def decode( def decode(
cls, cls,
child_id: int, child_id: int,
name: str,
kind: str,
anchor_date: object, anchor_date: object,
encoded: str, encoded: str,
) -> "PresenceSchedule": ) -> "PresencePattern":
"""Build a schedule from its encoded pattern. """Build a pattern from its encoded weekday sets.
The cycle length is derived from the segment count, mirroring The cycle length is derived from the segment count, mirroring
the database's segment-count CHECK. Decoding is strict: wrong the database's segment-count CHECK. Decoding is strict: wrong
@@ -248,7 +284,15 @@ class PresenceSchedule:
) )
weekdays.add(int(part)) weekdays.add(int(part))
pattern[week] = weekdays pattern[week] = weekdays
return cls(child_id, len(segments), anchor_date, pattern) return cls(child_id, name, kind, len(segments), anchor_date, pattern)
def covers(self, date: datetime.date) -> bool:
"""Return True when ``date``'s weekday is in its cycle week's set.
The cycle week comes from :func:`cycle_week_index` — anchor-date
arithmetic only, never ISO week numbers or parity (D-004).
"""
return date.weekday() in self.pattern[cycle_week_index(self, date)]
@dataclass(frozen=True, init=False) @dataclass(frozen=True, init=False)
@@ -301,7 +345,7 @@ class PresenceOverride:
def cycle_week_index( def cycle_week_index(
schedule: PresenceSchedule, target_date: object pattern: PresencePattern, target_date: object
) -> int: ) -> int:
"""Return the cycle week index ``target_date`` falls in. """Return the cycle week index ``target_date`` falls in.
@@ -315,14 +359,14 @@ def cycle_week_index(
valid week index. valid week index.
""" """
target = _parse_date(target_date, "target_date") target = _parse_date(target_date, "target_date")
days = (target - schedule.anchor_date).days days = (target - pattern.anchor_date).days
return (days // 7) % schedule.cycle_length_weeks return (days // 7) % pattern.cycle_length_weeks
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full #: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
#: years of daily checks. A child absent every day (an all-absent #: years of daily checks. A child absent every day (an away pattern
#: pattern with a blocking override) would otherwise loop forever; the #: covering every weekday) would otherwise loop forever; the cap bounds
#: cap bounds the scan and surfaces the situation as an error instead. #: the scan and surfaces the situation as an error instead.
MAX_PREVIEW_SCAN_DAYS = 366 * 2 MAX_PREVIEW_SCAN_DAYS = 366 * 2
@@ -330,40 +374,45 @@ class PresenceEngine:
"""Answers "is this child at this house on this date?". """Answers "is this child at this house on this date?".
Pure and side-effect-free: built from an immutable snapshot of the Pure and side-effect-free: built from an immutable snapshot of the
children's schedules (``child_id -> PresenceSchedule``), it never children's patterns (``child_id -> list of PresencePattern``), it
touches the database — callers build a snapshot from the DAO layer never touches the database — callers build a snapshot from the DAO
and pass it in. A child with NO schedule entry is present every layer and pass it in. A child with NO pattern is present every day
day (the third household child's case); a child WITH a schedule is (the third household child's case). Evaluation order, first match
present exactly when the date's weekday is in that schedule's wins — see :meth:`is_present`. Pattern coverage is anchor-date
pattern for the date's cycle week index (anchor-date arithmetic — arithmetic (:func:`cycle_week_index`, never ISO week parity).
:func:`cycle_week_index`, never ISO week parity).
""" """
def __init__( def __init__(
self, self,
schedules: dict[int, PresenceSchedule], patterns: dict[int, list[PresencePattern]],
overrides: dict[int, list[PresenceOverride]] | None = None, overrides: dict[int, list[PresenceOverride]] | None = None,
) -> None: ) -> None:
if not isinstance(schedules, dict): if not isinstance(patterns, dict):
raise ValueError( raise ValueError(
f"schedules must map child_id to PresenceSchedule, got " "patterns must map child_id to a list of PresencePattern, "
f"{schedules!r}" f"got {patterns!r}"
) )
for child_id, schedule in schedules.items(): for child_id, entries in patterns.items():
# Validate the KEY as a model child id too: True and 1.0 # Validate the KEY as a model child id too: True and 1.0
# compare equal to 1, so {True: schedule(1)} would be # compare equal to 1, so {True: [pattern(1)]} would be
# accepted and then behave differently on lookup. # accepted and then behave differently on lookup.
_validate_child_id(child_id) _validate_child_id(child_id)
if not isinstance(schedule, PresenceSchedule): if not isinstance(entries, list):
raise ValueError( raise ValueError(
f"schedules[{child_id!r}] must be a PresenceSchedule, " f"patterns[{child_id!r}] must be a list of "
f"got {schedule!r}" f"PresencePattern, got {entries!r}"
)
if schedule.child_id != child_id:
raise ValueError(
f"schedules is keyed {child_id!r} but holds the "
f"schedule of child {schedule.child_id}"
) )
for entry in entries:
if not isinstance(entry, PresencePattern):
raise ValueError(
f"patterns[{child_id!r}] entries must be "
f"PresencePattern, got {entry!r}"
)
if entry.child_id != child_id:
raise ValueError(
f"patterns is keyed {child_id!r} but holds a "
f"pattern of child {entry.child_id}"
)
if overrides is None: if overrides is None:
overrides = {} overrides = {}
if not isinstance(overrides, dict): if not isinstance(overrides, dict):
@@ -391,11 +440,17 @@ class PresenceEngine:
) )
# Read-only snapshot: reassignment is refused and no mutator # Read-only snapshot: reassignment is refused and no mutator
# exists, so a built engine answers consistently for its # exists, so a built engine answers consistently for its
# lifetime. The overrides lists are copied to immutable # lifetime. The patterns and overrides lists are copied to
# tuples — a shallow mapping copy alone would leave caller- # immutable tuples — a shallow mapping copy alone would leave
# owned lists reachable through _overrides, and mutating one # caller-owned lists reachable, and mutating one later would
# later would change is_present results. # change is_present results.
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules))) object.__setattr__(
self,
"_patterns",
MappingProxyType(
{child_id: tuple(entries) for child_id, entries in patterns.items()}
),
)
object.__setattr__( object.__setattr__(
self, self,
"_overrides", "_overrides",
@@ -407,12 +462,22 @@ class PresenceEngine:
def is_present(self, child_id: object, date: object) -> bool: def is_present(self, child_id: object, date: object) -> bool:
"""Return whether ``child_id`` is present on ``date``. """Return whether ``child_id`` is present on ``date``.
Overrides take precedence over the repeating pattern: when an Ordered, first match wins:
override covers the date, its flag IS the answer — including
for a child with no schedule at all. The DAO layer guarantees 1. An override covering the date — its flag IS the answer,
a child's overrides never overlap; if a snapshot still carries including for a child with no pattern at all. The DAO layer
two covering the same date, the one with the LATEST start date guarantees a child's overrides never overlap; if a snapshot
wins (deterministic, and matches "the most specific swap wins"). still carries two covering the same date, the one with the
LATEST start date wins (deterministic, and matches "the most
specific swap wins").
2. Any ``away`` pattern covering the date — absent.
3. Any ``home`` pattern covering the date — present.
4. Otherwise: absent when the child has at least one ``home``
pattern (a home pattern lists the days the child IS here, so
an uncovered date is a day away — the retired single-schedule
semantics, which migration 9 carries across as ``home``
patterns unchanged), else present (no pattern, or ``away``
patterns only: the household default).
""" """
if isinstance(child_id, bool) or not isinstance(child_id, int): if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}") raise ValueError(f"child_id must be an integer, got {child_id!r}")
@@ -424,12 +489,16 @@ class PresenceEngine:
if covering: if covering:
winner = max(covering, key=lambda entry: entry.start_date) winner = max(covering, key=lambda entry: entry.start_date)
return winner.is_present return winner.is_present
schedule = self._schedules.get(child_id) patterns = self._patterns.get(child_id) or ()
if schedule is None: if any(
# No schedule at all: present every day (household default). pattern.kind == "away" and pattern.covers(target)
for pattern in patterns
):
return False
homes = [pattern for pattern in patterns if pattern.kind == "home"]
if any(pattern.covers(target) for pattern in homes):
return True return True
week = cycle_week_index(schedule, target) return not homes
return target.weekday() in schedule.pattern[week]
def next_present_dates( def next_present_dates(
self, self,
@@ -442,7 +511,7 @@ class PresenceEngine:
Walks forward from ``start_date`` (INCLUSIVE — a parent Walks forward from ``start_date`` (INCLUSIVE — a parent
previewing "starting Monday" expects Monday itself when the previewing "starting Monday" expects Monday itself when the
child is present) checking :meth:`is_present` for every day, child is present) checking :meth:`is_present` for every day,
so overrides and the pattern are honoured identically. The so overrides and the patterns are honoured identically. The
admin Schedule tab renders this preview so a parent can confirm admin Schedule tab renders this preview so a parent can confirm
a custody pattern is right before saving it. a custody pattern is right before saving it.
@@ -2,11 +2,11 @@
Sits between the write callers (the Feature 09 HA service handlers and Sits between the write callers (the Feature 09 HA service handlers and
the API admin plane's presence routes) and the typed presence DAOs plus the API admin plane's presence routes) and the typed presence DAOs plus
the pure presence model: callers get the model validation, the upsert / the pure presence model: callers get the model validation, the create /
create / delete operations and the regeneration side effect here, and update / delete operations and the regeneration side effect here, and
never touch the DAO or SQL directly. This is the ONE implementation of never touch the DAO or SQL directly. This is the ONE implementation of
these business rules — every caller constructs the same these business rules — every caller constructs the same
:class:`~.presence.PresenceSchedule` / :class:`~.presence.PresenceOverride` :class:`~.presence.PresencePattern` / :class:`~.presence.PresenceOverride`
models (whose constructors are total, so no re-validation happens in a models (whose constructors are total, so no re-validation happens in a
handler) and gets the same post-write handler) and gets the same post-write
:func:`~.materialize.regenerate_for_child`, so a child's future quest :func:`~.materialize.regenerate_for_child`, so a child's future quest
@@ -15,16 +15,16 @@ write. Like the model it is Home-Assistant-free: ``today`` is a plain
calendar date threaded through to the regeneration, defaulting to the calendar date threaded through to the regeneration, defaulting to the
host clock there. host clock there.
Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence Pattern policy (schema 9): a child has ZERO OR MORE presence patterns,
schedule — setting again REPLACES the existing row rather than each created, edited and deleted by its own id. Patterns and overrides
duplicating it. Overrides are deletable by design (children are not — are deletable by design (children are not — the children layer owns
the children layer owns that guardrail). Deleting a nonexistent that guardrail). An unknown pattern or override id raises ValueError
override raises ValueError prefixed ``override_id:`` so a caller can prefixed ``pattern_id:`` / ``override_id:`` so a caller can distinguish
distinguish "unknown id" from a rejected argument the same way the "unknown id" from a rejected argument the same way the
quest-definitions layer prefixes ``definition_id:``. quest-definitions layer prefixes ``definition_id:``.
The admin plane's presence READS go through here too The admin plane's presence READS go through here too
(:func:`get_presence_schedule`, :func:`list_presence_overrides`), so a (:func:`list_presence_patterns`, :func:`list_presence_overrides`), so a
reader never touches the DAO directly either. reader never touches the DAO directly either.
""" """
from __future__ import annotations from __future__ import annotations
@@ -35,11 +35,13 @@ from .dao_children import ChildrenDao
from .dao_presence import ( from .dao_presence import (
PresenceOverrideRecord, PresenceOverrideRecord,
PresenceOverridesDao, PresenceOverridesDao,
PresenceSchedulesDao, PresencePatternRecord,
PresencePatternsDao,
) )
from .dao_rules import _UNSET
from .db import NestQuestDatabase from .db import NestQuestDatabase
from .materialize import count_removed_by_override, regenerate_for_child from .materialize import count_removed_by_override, regenerate_for_child
from .presence import PresenceOverride, PresenceSchedule from .presence import PresenceOverride, PresencePattern
def _validate_override_id(value: object) -> int: def _validate_override_id(value: object) -> int:
@@ -49,49 +51,159 @@ def _validate_override_id(value: object) -> int:
return value return value
async def set_presence_schedule( def _validate_pattern_id(value: object) -> int:
"""Reject non-int pattern ids (bools would silently address id 1)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"pattern_id must be an integer, got {value!r}")
return value
async def _get_pattern_or_raise(
dao: PresencePatternsDao, pattern_id: object
) -> PresencePatternRecord:
"""Return the stored pattern; an unknown id raises ``pattern_id:``."""
record = await dao.get(_validate_pattern_id(pattern_id))
if record is None:
raise ValueError(
f"pattern_id: presence pattern {pattern_id} does not exist"
)
return record
def decode_pattern_record(record: PresencePatternRecord) -> PresencePattern:
"""Decode one stored pattern row back through the model."""
return PresencePattern.decode(
record.child_id,
record.name,
record.kind,
record.anchor_date,
record.pattern,
)
async def create_presence_pattern(
database: NestQuestDatabase, database: NestQuestDatabase,
child_id: int, child_id: int,
name: str,
kind: str,
cycle_length_weeks: int, cycle_length_weeks: int,
anchor_date: object, anchor_date: object,
pattern: dict[int, object], pattern: dict[int, object],
*, *,
today: datetime.date | None = None, today: datetime.date | None = None,
horizon_days: int | None = None, horizon_days: int | None = None,
) -> PresenceSchedule: ) -> PresencePatternRecord:
"""Set (upsert) a child's repeating presence schedule. """Add one repeating presence pattern to a child.
The :class:`~.presence.PresenceSchedule` model validates at The :class:`~.presence.PresencePattern` model validates at
construction — cycle length 1..4, a strict calendar anchor date, construction — a non-blank name, ``kind`` ``home``/``away``, cycle
and a pattern covering week indices 0..n-1 with weekday sets — so length 1..4, a strict calendar anchor date, and a pattern covering
no invalid schedule reaches the DAO. The encoded pattern is week indices 0..n-1 with weekday sets — so no invalid pattern
UPSERTed by child (a second set REPLACES the child's existing reaches the DAO. The encoded pattern is inserted beside the
schedule; the schema's UNIQUE(child_id) makes duplication child's other patterns, then :func:`~.materialize.regenerate_for_child`
unrepresentable), then :func:`~.materialize.regenerate_for_child`
re-materializes the child's future instances against the new re-materializes the child's future instances against the new
presence. ``today``/``horizon_days`` thread through to that presence. ``today``/``horizon_days`` thread through to that
regeneration; raising ValueError names what to fix (an unknown regeneration; raising ValueError names what to fix (an unknown
child reads ``child N does not exist``). child reads ``child N does not exist``).
Returns the STORED schedule decoded back through the model — the Returns the STORED record WITH its ``id`` — the handle
round-trip proves the encoded pattern the database now holds is :func:`update_presence_pattern` and :func:`delete_presence_pattern`
exactly the one the caller asked for. take.
""" """
schedule = PresenceSchedule( model = PresencePattern(
child_id, cycle_length_weeks, anchor_date, pattern child_id, name, kind, cycle_length_weeks, anchor_date, pattern
) )
record = await PresenceSchedulesDao(database).upsert_by_child( record = await PresencePatternsDao(database).create(
schedule.child_id, model.child_id,
schedule.cycle_length_weeks, model.name,
schedule.anchor_date.isoformat(), model.kind,
schedule.encode(), model.cycle_length_weeks,
model.anchor_date.isoformat(),
model.encode(),
) )
await regenerate_for_child( await regenerate_for_child(
database, schedule.child_id, today=today, horizon_days=horizon_days database, model.child_id, today=today, horizon_days=horizon_days
) )
return PresenceSchedule.decode( return record
record.child_id, record.anchor_date, record.pattern
async def update_presence_pattern(
database: NestQuestDatabase,
pattern_id: int,
*,
name: str | object = _UNSET,
kind: str | object = _UNSET,
cycle_length_weeks: int | object = _UNSET,
anchor_date: object = _UNSET,
pattern: dict[int, object] | object = _UNSET,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresencePatternRecord:
"""Edit one presence pattern; only supplied fields change.
The pattern is looked up FIRST (an unknown id raises ValueError
prefixed ``pattern_id:``), the supplied fields are laid over the
stored ones and the WHOLE result is rebuilt through the
:class:`~.presence.PresencePattern` model — so a partial edit is
validated exactly as a create is (changing ``cycle_length_weeks``
without a matching ``pattern`` is rejected), and an explicit None
is rejected rather than ignored. The pattern stays with its child;
the child's future instances are regenerated afterwards.
"""
dao = PresencePatternsDao(database)
stored = decode_pattern_record(await _get_pattern_or_raise(dao, pattern_id))
model = PresencePattern(
stored.child_id,
stored.name if name is _UNSET else name,
stored.kind if kind is _UNSET else kind,
(
stored.cycle_length_weeks
if cycle_length_weeks is _UNSET
else cycle_length_weeks
),
stored.anchor_date if anchor_date is _UNSET else anchor_date,
dict(stored.pattern) if pattern is _UNSET else pattern,
) )
record = await dao.update(
pattern_id,
model.name,
model.kind,
model.cycle_length_weeks,
model.anchor_date.isoformat(),
model.encode(),
)
if record is None:
raise ValueError(
f"pattern_id: presence pattern {pattern_id} does not exist"
)
await regenerate_for_child(
database, model.child_id, today=today, horizon_days=horizon_days
)
return record
async def delete_presence_pattern(
database: NestQuestDatabase,
pattern_id: int,
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresencePatternRecord:
"""Delete one presence pattern by id and return it as it was stored.
The pattern is looked up FIRST and an unknown id raises ValueError
prefixed ``pattern_id:`` BEFORE any delete, so a mistyped id can
never be silently swallowed by a no-op. The deletion is followed
by :func:`~.materialize.regenerate_for_child` for the pattern's own
child — the id decides the child, never a caller-supplied one.
"""
dao = PresencePatternsDao(database)
record = await _get_pattern_or_raise(dao, pattern_id)
await dao.delete(record.id)
await regenerate_for_child(
database, record.child_id, today=today, horizon_days=horizon_days
)
return record
async def create_presence_override( async def create_presence_override(
@@ -171,21 +283,16 @@ async def preview_presence_override_consequence(
) )
async def get_presence_schedule( async def list_presence_patterns(
database: NestQuestDatabase, child_id: int database: NestQuestDatabase, child_id: int
) -> PresenceSchedule | None: ) -> list[PresencePatternRecord]:
"""Return the child's presence schedule decoded, or None when unset. """Return the child's presence patterns ordered by id.
None means the child exists and has no schedule row — present An empty list means the child exists and has no pattern — present
every day (Feature 05). An unknown child raises ValueError every day (Feature 05). An unknown child raises ValueError
(``child N does not exist``) rather than reading as present. (``child N does not exist``) rather than reading as present.
""" """
record = await PresenceSchedulesDao(database).get_by_child(child_id) return await PresencePatternsDao(database).list_by_child(child_id)
if record is None:
return None
return PresenceSchedule.decode(
record.child_id, record.anchor_date, record.pattern
)
async def list_presence_overrides( async def list_presence_overrides(
+24 -14
View File
@@ -43,18 +43,19 @@ rule or a monthly rule could later be pinned to a month) nor extra
fields on weekly/monthly/yearly rules generally, so the CHECKs only fields on weekly/monthly/yearly rules generally, so the CHECKs only
reject what the rule engine definitively cannot express now. reject what the rule engine definitively cannot express now.
Presence pattern format: ``presence_schedules.pattern`` encodes the Presence pattern format: ``presence_patterns.pattern`` encodes the
whole N-week repeating cycle in one TEXT column as pipe-separated whole N-week repeating cycle in one TEXT column as pipe-separated
segments, one per week of the cycle: segment ``i`` is a ``weekday_set`` segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``) CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
week 1. An empty segment means the child is absent every day of that week 1. An empty segment means the pattern covers no day of that week.
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means A child carries zero or more patterns, each ``kind`` ``home`` or
absent every day of every week, which is distinct from having no ``away``; a child with no pattern row at all is present every day, per
schedule row at all (a child with no schedule is present every day, per the feature guardrails (how patterns combine is the presence engine's
the feature guardrails). Chosen over JSON or per-week rows to stay job). The encoding and its CHECKs are carried over verbatim from the
consistent with the CSV ``weekday_set`` convention and fully checkable retired ``presence_schedules`` table (schema 9). Chosen over JSON or
by plain CHECKs. DB-level validation is pure CHECK, no triggers or per-week rows to stay consistent with the CSV ``weekday_set``
convention and fully checkable by plain CHECKs. DB-level validation is pure CHECK, no triggers or
custom functions: char-class GLOBs reject every character outside custom functions: char-class GLOBs reject every character outside
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
(double comma, comma next to a pipe, leading/trailing comma) and (double comma, comma next to a pipe, leading/trailing comma) and
@@ -68,7 +69,8 @@ weekday_set this is not full CSV-set normalisation (duplicates like
the engine treats the segment as a set. A the engine treats the segment as a set. A
one-row-per-``week_index`` normalization was considered and rejected: one-row-per-``week_index`` normalization was considered and rejected:
the flat single-column encoding keeps the done-condition's table shape the flat single-column encoding keeps the done-condition's table shape
and proved fully checkable with plain CHECKs. and proved fully checkable with plain CHECKs. ``name`` is a short
label and carries no CHECK; the model rejects a blank one.
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the ``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
common case, and a small cap keeps anchor arithmetic and the encoded common case, and a small cap keeps anchor arithmetic and the encoded
@@ -288,11 +290,19 @@ SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
""", """,
] ]
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [ #: ``presence_patterns`` (schema version 9): zero or more repeating
f""" #: presence rules per child, replacing the retired one-per-child
CREATE TABLE IF NOT EXISTS presence_schedules ( #: ``presence_schedules`` table. ``child_id`` is deliberately NOT
#: unique; ``kind`` is what a covering pattern claims (``home`` or
#: ``away``); ``pattern`` keeps the retired table's encoding and CHECKs
#: verbatim.
SCHEMA_V9_PRESENCE_PATTERNS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS presence_patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id), child_id INTEGER NOT NULL REFERENCES children(id),
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('home', 'away')),
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4), cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
anchor_date TEXT NOT NULL, anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL CHECK ( pattern TEXT NOT NULL CHECK (
@@ -407,7 +417,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_QUEST_DEFINITIONS_DDL, *SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, *SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V9_PRESENCE_PATTERNS_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL,
+32 -21
View File
@@ -39,14 +39,14 @@ from typing import Any
from .completion import derive_state from .completion import derive_state
from .dao_children import ChildrenDao from .dao_children import ChildrenDao
from .dao_instances import CompletionEventsDao, QuestInstancesDao from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao from .dao_presence import PresenceOverridesDao, PresencePatternsDao
from .dao_rules import QuestDefinitionsDao from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase from .db import NestQuestDatabase
from .presence import ( from .presence import (
MAX_PREVIEW_SCAN_DAYS, MAX_PREVIEW_SCAN_DAYS,
PresenceEngine, PresenceEngine,
PresenceOverride, PresenceOverride,
PresenceSchedule, PresencePattern,
) )
from .settings import NestQuestSettings from .settings import NestQuestSettings
@@ -98,20 +98,26 @@ class NestQuestSnapshot:
def _cycle_day( def _cycle_day(
schedules: dict[int, PresenceSchedule], children, today: datetime.date patterns: dict[int, list[PresencePattern]],
children,
today: datetime.date,
) -> int: ) -> int:
"""Return today's 1-based day in the first scheduled child's cycle. """Return today's 1-based day in the first patterned child's cycle.
Anchor-date arithmetic ONLY (D-004): the day offset is computed from The cycle is that child's FIRST pattern (lowest id) — one number
the schedule's own anchor, never ISO week numbers or parity, so a for the household's cycle-day sensor even when a child carries
53-week year cannot silently invert the cycle. several patterns. Anchor-date arithmetic ONLY (D-004): the day
offset is computed from the pattern's own anchor, never ISO week
numbers or parity, so a 53-week year cannot silently invert the
cycle.
""" """
for child in children: for child in children:
schedule = schedules.get(child.id) entries = patterns.get(child.id)
if schedule is None: if not entries:
continue continue
days = (today - schedule.anchor_date).days pattern = entries[0]
cycle_length = schedule.cycle_length_weeks * 7 days = (today - pattern.anchor_date).days
cycle_length = pattern.cycle_length_weeks * 7
return (days % cycle_length) + 1 return (days % cycle_length) + 1
return 0 return 0
@@ -191,16 +197,21 @@ async def build_snapshot(
children = await ChildrenDao(database).list_active() children = await ChildrenDao(database).list_active()
schedules: dict[int, PresenceSchedule] = {} patterns: dict[int, list[PresencePattern]] = {}
overrides: dict[int, list[PresenceOverride]] = {} overrides: dict[int, list[PresenceOverride]] = {}
for child in children: for child in children:
schedule = await PresenceSchedulesDao(database).get_by_child( records = await PresencePatternsDao(database).list_by_child(child.id)
child.id if records:
) patterns[child.id] = [
if schedule is not None: PresencePattern.decode(
schedules[child.id] = PresenceSchedule.decode( record.child_id,
child.id, schedule.anchor_date, schedule.pattern record.name,
) record.kind,
record.anchor_date,
record.pattern,
)
for record in records
]
window_end = today + datetime.timedelta( window_end = today + datetime.timedelta(
days=MAX_PREVIEW_SCAN_DAYS days=MAX_PREVIEW_SCAN_DAYS
) )
@@ -222,7 +233,7 @@ async def build_snapshot(
) )
for record in ranged for record in ranged
] ]
engine = PresenceEngine(schedules, overrides) engine = PresenceEngine(patterns, overrides)
instances_dao = QuestInstancesDao(database) instances_dao = QuestInstancesDao(database)
events_dao = CompletionEventsDao(database) events_dao = CompletionEventsDao(database)
@@ -302,5 +313,5 @@ async def build_snapshot(
return NestQuestSnapshot( return NestQuestSnapshot(
today_iso=today_iso, today_iso=today_iso,
children=tuple(snapshots), children=tuple(snapshots),
cycle_day=_cycle_day(schedules, children, today), cycle_day=_cycle_day(patterns, children, today),
) )
+584 -144
View File
@@ -1,4 +1,4 @@
"""Admin-plane presence route tests (task e4a9d31b). """Admin-plane presence route tests (task e4a9d31b; schema 9 patterns).
These tests exercise the admin plane's presence routes against the ASGI These tests exercise the admin plane's presence routes against the ASGI
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
@@ -11,31 +11,38 @@ read through the SAME core copy the app uses (``nestquest_core.*``; see
the two-copy caveat in api/nestquest_core.py): the two-copy caveat in api/nestquest_core.py):
- Authentication runs through the router's ONE ``require_admin`` - Authentication runs through the router's ONE ``require_admin``
dependency: an admin JWT passes, a JWT without the group is 403, an dependency on EVERY presence route (the four pattern routes and the
absent credential is 401 — and nothing is written. override routes): an admin JWT passes, a JWT without the group or the
- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the panel service token is 403, an absent credential is 401 — and
first set persists the schedule, setting again REPLACES it — one nothing is written.
schedule row per child, never a duplicate. - ``POST /api/v1/admin/children/{id}/presence-patterns`` ADDS a
pattern: a child has zero or more, each create inserts a new row
(never replacing an earlier one). ``PATCH
/api/v1/admin/presence-patterns/{id}`` changes only the supplied
fields; ``DELETE`` removes the one pattern. The removed one-per-child
``/presence-schedule`` route no longer exists.
- ``POST /api/v1/admin/presence-overrides`` creates the override (its - ``POST /api/v1/admin/presence-overrides`` creates the override (its
``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}`` ``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}``
removes it again. removes it again.
- The child's RESOLVED presence changes accordingly: the pure - The child's RESOLVED presence changes accordingly: the pure
:class:`~nestquest_core.presence.PresenceEngine` built from the :class:`~nestquest_core.presence.PresenceEngine` built from the
stored rows answers differently before/after each write — a schedule stored rows answers differently before/after each write — a home
restricts the child's present weekdays, an override beats the pattern pattern restricts the child's present weekdays, an away pattern beats
for its range, and deleting the override restores the pattern's a home one, an override beats every pattern for its range, and
answer. deleting the override (or the pattern) restores the earlier answer.
- The regeneration side effect lives in the core layer the route - The regeneration side effect lives in the core layer the route
calls: setting an all-absent schedule through the route deletes the calls: adding an all-absent home pattern through the route deletes
child's future open quest instances and they are NOT rebuilt while the child's future open quest instances and they are NOT rebuilt
the child is absent every day — the same proof the HA service test while the child is absent every day; deleting that pattern rebuilds
makes, through the API instead. them — the same proof the HA service test makes, through the API.
Error mapping (documented in api/routes_admin.py): a core ValueError Error mapping (documented in api/routes_admin.py): a core ValueError
for a non-existent child (schedule or override create) or override for a non-existent child (pattern or override create) is 404 "Child not
(delete) is 404; every other core ValueError — a rejected schedule found", a non-existent pattern (edit/delete) 404 "Presence pattern not
(cycle length, pattern coverage, anchor date) or override (malformed or found", a non-existent override (delete) 404; every other core
inverted dates, an overlapping range) — is 422, the same failure class ValueError — a rejected pattern (name, kind, cycle length, pattern
coverage, anchor date) or override (malformed or inverted dates, an
overlapping range) — is 422 naming the field, the same failure class
FastAPI reports for malformed bodies. FastAPI reports for malformed bodies.
""" """
from __future__ import annotations from __future__ import annotations
@@ -64,24 +71,54 @@ _dao_instances = importlib.import_module("nestquest_core.dao_instances")
_materialize = importlib.import_module("nestquest_core.materialize") _materialize = importlib.import_module("nestquest_core.materialize")
_presence = importlib.import_module("nestquest_core.presence") _presence = importlib.import_module("nestquest_core.presence")
#: A Monday anchoring every schedule: cycle week 0 starts here. #: A Monday anchoring every pattern: cycle week 0 starts here.
ANCHOR_MONDAY = "2026-01-05" ANCHOR_MONDAY = "2026-01-05"
#: The Tuesday of that week — absent under the test schedule. #: The Tuesday of that week — absent under the test home pattern.
TUESDAY = "2026-01-06" TUESDAY = "2026-01-06"
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _admin_headers() -> dict[str, str]: def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT.""" """The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"} return {"Authorization": f"Bearer {make_token()}"}
def _schedule_body( def _patterns_url(child_id: int) -> str:
cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None """The child-scoped pattern list/create route."""
return f"/api/v1/admin/children/{child_id}/presence-patterns"
def _pattern_url(pattern_id: int | str) -> str:
"""The id-scoped pattern edit/delete route."""
return f"/api/v1/admin/presence-patterns/{pattern_id}"
def _pattern_body(
cycle_length_weeks: object = 1,
pattern: dict[str, object] | None = None,
*,
name: object = "Mum's house",
kind: object = "home",
anchor_date: object = ANCHOR_MONDAY,
) -> dict[str, object]: ) -> dict[str, object]:
"""A valid schedule body anchored to the test Monday.""" """A valid pattern body anchored to the test Monday (Mon/Wed/Fri home)."""
return { return {
"name": name,
"kind": kind,
"cycle_length_weeks": cycle_length_weeks, "cycle_length_weeks": cycle_length_weeks,
"anchor_date": ANCHOR_MONDAY, "anchor_date": anchor_date,
"pattern": {"0": [0, 2, 4]} if pattern is None else pattern, "pattern": {"0": [0, 2, 4]} if pattern is None else pattern,
} }
@@ -103,6 +140,18 @@ def _override_body(
} }
def _detail_names(response: object, field: str) -> bool:
"""True when a 422's detail names ``field``.
A core ValueError arrives as a string detail naming the field; a
Pydantic shape failure as a list of errors whose ``loc`` holds it.
"""
detail = response.json()["detail"]
if isinstance(detail, str):
return field in detail
return any(field in [str(part) for part in error["loc"]] for error in detail)
async def _create_child(client: object, name: str = "Ada") -> int: async def _create_child(client: object, name: str = "Ada") -> int:
"""Create one child through the admin children route; return the id.""" """Create one child through the admin children route; return the id."""
created = await client.post( created = await client.post(
@@ -114,26 +163,44 @@ async def _create_child(client: object, name: str = "Ada") -> int:
return created.json()["id"] return created.json()["id"]
async def _create_pattern(
client: object, child_id: int, body: dict[str, object] | None = None
) -> dict[str, object]:
"""Create one pattern through the admin route; return the payload."""
created = await client.post(
_patterns_url(child_id),
json=_pattern_body() if body is None else body,
headers=_admin_headers(),
)
assert created.status_code == 201, created.text
return created.json()
def _engine( def _engine(
database: object, database: object,
child_id: int, child_id: int,
schedule_record: object | None, pattern_records: list[object],
override_records: list[object], override_records: list[object],
) -> object: ) -> object:
"""Build the pure PresenceEngine from stored rows (the snapshot shape). """Build the pure PresenceEngine from stored rows (the snapshot shape).
Mirrors what the core snapshot builder assembles: the child's Mirrors what the core snapshot builder assembles: the child's
decoded schedule (absent → present every day) plus its overrides, decoded patterns (none → present every day) plus its overrides,
so :meth:`PresenceEngine.is_present` answers from the SAME rows the so :meth:`PresenceEngine.is_present` answers from the SAME rows the
routes produced. routes produced.
""" """
schedules: dict[int, object] = {} patterns = {
if schedule_record is not None: child_id: [
schedules[child_id] = _presence.PresenceSchedule.decode( _presence.PresencePattern.decode(
schedule_record.child_id, record.child_id,
schedule_record.anchor_date, record.name,
schedule_record.pattern, record.kind,
) record.anchor_date,
record.pattern,
)
for record in pattern_records
]
}
overrides = { overrides = {
child_id: [ child_id: [
_presence.PresenceOverride( _presence.PresenceOverride(
@@ -146,21 +213,21 @@ def _engine(
for record in override_records for record in override_records
] ]
} }
return _presence.PresenceEngine(schedules, overrides) return _presence.PresenceEngine(patterns, overrides)
async def _child_state( async def _child_state(
database: object, child_id: int database: object, child_id: int
) -> tuple[object | None, list[object], object]: ) -> tuple[list[object], list[object], object]:
"""The child's stored schedule record, overrides and PresenceEngine.""" """The child's stored pattern records, overrides and PresenceEngine."""
schedule_record = await _dao_presence.PresenceSchedulesDao( pattern_records = await _dao_presence.PresencePatternsDao(
database database
).get_by_child(child_id) ).list_by_child(child_id)
override_records = await _dao_presence.PresenceOverridesDao( override_records = await _dao_presence.PresenceOverridesDao(
database database
).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01") ).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01")
engine = _engine(database, child_id, schedule_record, override_records) engine = _engine(database, child_id, pattern_records, override_records)
return schedule_record, override_records, engine return pattern_records, override_records, engine
@pytest.fixture @pytest.fixture
@@ -192,15 +259,13 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials(
non_admin = make_token(groups=("some-other-group",)) non_admin = make_token(groups=("some-other-group",))
non_admin_headers = {"Authorization": f"Bearer {non_admin}"} non_admin_headers = {"Authorization": f"Bearer {non_admin}"}
forbidden_schedule = await client.put( forbidden_pattern = await client.post(
f"/api/v1/admin/children/{child_id}/presence-schedule", _patterns_url(child_id),
json=_schedule_body(), json=_pattern_body(),
headers=non_admin_headers, headers=non_admin_headers,
) )
assert forbidden_schedule.status_code == 403 assert forbidden_pattern.status_code == 403
assert forbidden_schedule.json()["detail"] == ( assert forbidden_pattern.json()["detail"] == FORBIDDEN_DETAIL
"Admin access requires membership in the nestquest-admins group"
)
forbidden_override = await client.post( forbidden_override = await client.post(
"/api/v1/admin/presence-overrides", "/api/v1/admin/presence-overrides",
@@ -214,11 +279,10 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials(
) )
assert forbidden_delete.status_code == 403 assert forbidden_delete.status_code == 403
unauthenticated_schedule = await client.put( unauthenticated_pattern = await client.post(
f"/api/v1/admin/children/{child_id}/presence-schedule", _patterns_url(child_id), json=_pattern_body()
json=_schedule_body(),
) )
assert unauthenticated_schedule.status_code == 401 assert unauthenticated_pattern.status_code == 401
unauthenticated_override = await client.post( unauthenticated_override = await client.post(
"/api/v1/admin/presence-overrides", json=_override_body(child_id) "/api/v1/admin/presence-overrides", json=_override_body(child_id)
@@ -230,113 +294,225 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials(
) )
assert unauthenticated_delete.status_code == 401 assert unauthenticated_delete.status_code == 401
# No route ran its handler: the child still has no schedule and no # No route ran its handler: the child still has no pattern and no
# overrides (and is present every day, the no-schedule default). # overrides (and is present every day, the no-pattern default).
_schedule, _overrides, engine = await _child_state( patterns, overrides, engine = await _child_state(
admin_presence.database, child_id admin_presence.database, child_id
) )
assert _schedule is None assert patterns == []
assert _overrides == [] assert overrides == []
assert engine.is_present(child_id, ANCHOR_MONDAY) is True assert engine.is_present(child_id, ANCHOR_MONDAY) is True
# --- schedule upsert -------------------------------------------------------- def _non_admin_headers() -> dict[str, str]:
return {
"Authorization": (
f"Bearer {make_token(groups=('some-other-group',))}"
)
}
async def test_schedule_upserts_and_replaces_one_row_per_child( _CREDENTIALS = {
"absent": (lambda: {}, 401, None),
"panel-token": (
lambda: {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
403,
PANEL_TOKEN_REJECTION_DETAIL,
),
"non-admin-jwt": (_non_admin_headers, 403, FORBIDDEN_DETAIL),
}
@pytest.mark.parametrize("credential", sorted(_CREDENTIALS))
@pytest.mark.parametrize("route", ["list", "create", "edit", "delete"])
async def test_pattern_routes_enforce_admin_auth(
admin_presence: SimpleNamespace, route: str, credential: str
) -> None:
"""Every pattern route rejects absent, panel and non-admin credentials.
Each refused request leaves the stored pattern exactly as it was: a
refused create adds no row, a refused edit changes nothing, a
refused delete removes nothing.
"""
client = admin_presence.client
child_id = await _create_child(client)
existing = await _create_pattern(client, child_id)
make_headers, status, detail = _CREDENTIALS[credential]
if route == "list":
response = await client.get(
_patterns_url(child_id), headers=make_headers()
)
elif route == "create":
response = await client.post(
_patterns_url(child_id),
json=_pattern_body(name="Dad's house"),
headers=make_headers(),
)
elif route == "edit":
response = await client.patch(
_pattern_url(existing["id"]),
json={"name": "Renamed"},
headers=make_headers(),
)
else:
response = await client.delete(
_pattern_url(existing["id"]), headers=make_headers()
)
assert response.status_code == status
if detail is not None:
assert response.json()["detail"] == detail
listed = await client.get(_patterns_url(child_id), headers=_admin_headers())
assert listed.json() == {"patterns": [existing]}
# --- the removed one-per-child schedule route ----------------------------------
async def test_old_presence_schedule_route_is_gone(
admin_presence: SimpleNamespace, admin_presence: SimpleNamespace,
) -> None: ) -> None:
"""PUT /presence-schedule persists, and setting again REPLACES it.""" """PUT/GET /presence-schedule no longer exist; nothing is written."""
client = admin_presence.client
child_id = await _create_child(client)
url = f"/api/v1/admin/children/{child_id}/presence-schedule"
put = await client.put(
url,
json={
"cycle_length_weeks": 1,
"anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [0]},
},
headers=_admin_headers(),
)
assert put.status_code in (404, 405)
get = await client.get(url, headers=_admin_headers())
assert get.status_code in (404, 405)
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert patterns == []
# --- pattern create ---------------------------------------------------------
async def test_pattern_create_persists_and_adds_a_row_per_create(
admin_presence: SimpleNamespace,
) -> None:
"""POST /presence-patterns persists; a second POST ADDS another row."""
client = admin_presence.client client = admin_presence.client
child_id = await _create_child(client) child_id = await _create_child(client)
first = await client.put( first = await client.post(
f"/api/v1/admin/children/{child_id}/presence-schedule", _patterns_url(child_id),
json=_schedule_body(), json=_pattern_body(),
headers=_admin_headers(), headers=_admin_headers(),
) )
assert first.status_code == 200 assert first.status_code == 201
assert first.json() == { first_payload = first.json()
assert first_payload == {
"id": first_payload["id"],
"child_id": child_id, "child_id": child_id,
"name": "Mum's house",
"kind": "home",
"cycle_length_weeks": 1, "cycle_length_weeks": 1,
"anchor_date": ANCHOR_MONDAY, "anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [0, 2, 4]}, "pattern": {"0": [0, 2, 4]},
} }
# Database proof: exactly ONE schedule row (the schema's # Database proof: ONE pattern row, stored as encoded CSV.
# UNIQUE(child_id) makes a second row unrepresentable), stored as patterns, _overrides, _engine = await _child_state(
# encoded CSV.
schedule, _overrides, _engine = await _child_state(
admin_presence.database, child_id admin_presence.database, child_id
) )
assert schedule is not None assert [record.id for record in patterns] == [first_payload["id"]]
row_id = schedule.id assert patterns[0].name == "Mum's house"
assert schedule.cycle_length_weeks == 1 assert patterns[0].kind == "home"
assert schedule.anchor_date == ANCHOR_MONDAY assert patterns[0].cycle_length_weeks == 1
assert schedule.pattern == "0,2,4" assert patterns[0].anchor_date == ANCHOR_MONDAY
assert patterns[0].pattern == "0,2,4"
# Setting again UPSERTS: the SAME row is updated in place — new # Creating again ADDS a second pattern beside the first — the first
# values, unchanged id — never a duplicate or a stale first set. # row is untouched (schema 9 has no one-per-child constraint).
second = await client.put( second = await client.post(
f"/api/v1/admin/children/{child_id}/presence-schedule", _patterns_url(child_id),
json=_schedule_body( json=_pattern_body(
cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]} cycle_length_weeks=2,
pattern={"0": [], "1": [3, 1]},
name=" Holiday rota ",
kind="away",
), ),
headers=_admin_headers(), headers=_admin_headers(),
) )
assert second.status_code == 200 assert second.status_code == 201
assert second.json() == { second_payload = second.json()
assert second_payload == {
"id": second_payload["id"],
"child_id": child_id, "child_id": child_id,
"name": "Holiday rota",
"kind": "away",
"cycle_length_weeks": 2, "cycle_length_weeks": 2,
"anchor_date": ANCHOR_MONDAY, "anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [], "1": [1, 3]}, "pattern": {"0": [], "1": [1, 3]},
} }
schedule, _overrides, _engine = await _child_state( assert second_payload["id"] != first_payload["id"]
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id admin_presence.database, child_id
) )
assert schedule is not None assert [record.id for record in patterns] == [
assert schedule.id == row_id first_payload["id"],
assert schedule.cycle_length_weeks == 2 second_payload["id"],
assert schedule.pattern == "|1,3" ]
assert patterns[0].pattern == "0,2,4"
assert patterns[1].pattern == "|1,3"
async def test_schedule_changes_the_childs_resolved_presence( async def test_pattern_changes_the_childs_resolved_presence(
admin_presence: SimpleNamespace, admin_presence: SimpleNamespace,
) -> None: ) -> None:
"""The resolved presence (PresenceEngine) follows the stored schedule.""" """The resolved presence (PresenceEngine) follows the stored patterns."""
client = admin_presence.client client = admin_presence.client
child_id = await _create_child(client) child_id = await _create_child(client)
# Before any schedule the child is present every day. # Before any pattern the child is present every day.
_schedule, _overrides, engine = await _child_state( _patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id admin_presence.database, child_id
) )
assert engine.is_present(child_id, ANCHOR_MONDAY) is True assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is True assert engine.is_present(child_id, TUESDAY) is True
# Mon/Wed/Fri only, week 0 of a one-week cycle. # Home Mon/Wed/Fri only, week 0 of a one-week cycle.
response = await client.put( await _create_pattern(client, child_id)
f"/api/v1/admin/children/{child_id}/presence-schedule", _patterns, _overrides, engine = await _child_state(
json=_schedule_body(),
headers=_admin_headers(),
)
assert response.status_code == 200
_schedule, _overrides, engine = await _child_state(
admin_presence.database, child_id admin_presence.database, child_id
) )
assert engine.is_present(child_id, ANCHOR_MONDAY) is True assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is False assert engine.is_present(child_id, TUESDAY) is False
# An away pattern covering Monday beats the home pattern there.
await _create_pattern(
client,
child_id,
_pattern_body(pattern={"0": [0]}, name="Swim camp", kind="away"),
)
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is False
assert engine.is_present(child_id, "2026-01-07") is True # Wednesday
assert engine.is_present(child_id, TUESDAY) is False
async def test_schedule_upsert_regenerates_future_instances(
async def test_pattern_writes_regenerate_future_instances(
admin_presence: SimpleNamespace, admin_presence: SimpleNamespace,
) -> None: ) -> None:
"""Setting an all-absent schedule deletes the child's future instances. """An all-absent home pattern deletes the child's future instances.
The regeneration side effect lives in the core layer the route The regeneration side effect lives in the core layer the route
calls: the child's open instances at or after today are deleted calls: the child's open instances at or after today are deleted
across ALL its definitions and NOT rebuilt while the child is across ALL its definitions and NOT rebuilt while the child is
absent every day — the same proof the HA service test makes, absent every day; deleting the pattern rebuilds them.
exercised here through the API route instead.
""" """
client = admin_presence.client client = admin_presence.client
database = admin_presence.database database = admin_presence.database
@@ -360,16 +536,257 @@ async def test_schedule_upsert_regenerates_future_instances(
instances = _dao_instances.QuestInstancesDao(database) instances = _dao_instances.QuestInstancesDao(database)
assert await instances.list_by_date_range(child_id, start, end) assert await instances.list_by_date_range(child_id, start, end)
# The route's write must regenerate: absent every day removes every # The route's write must regenerate: a home pattern covering no day
# open future instance. # makes the child absent every day and removes every open instance.
response = await client.put( pattern = await _create_pattern(
f"/api/v1/admin/children/{child_id}/presence-schedule", client, child_id, _pattern_body(pattern={"0": []})
json=_schedule_body(pattern={"0": []}), )
assert await instances.list_by_date_range(child_id, start, end) == []
# Deleting it regenerates too: present every day again, rebuilt.
deleted = await client.delete(
_pattern_url(pattern["id"]), headers=_admin_headers()
)
assert deleted.status_code == 200
assert await instances.list_by_date_range(child_id, start, end)
async def test_pattern_edit_regenerates_future_instances(
admin_presence: SimpleNamespace,
) -> None:
"""PATCH regenerates: flipping an empty home pattern to away rebuilds."""
client = admin_presence.client
database = admin_presence.database
child_id = await _create_child(client)
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child_id],
"windows": ["morning"],
},
headers=_admin_headers(), headers=_admin_headers(),
) )
assert response.status_code == 200 assert created.status_code == 201
today = datetime.date.today()
start = today.isoformat()
end = (today + datetime.timedelta(days=14)).isoformat()
pattern = await _create_pattern(
client, child_id, _pattern_body(pattern={"0": []})
)
await _materialize.materialize(database, start, end, today=today)
instances = _dao_instances.QuestInstancesDao(database)
assert await instances.list_by_date_range(child_id, start, end) == [] assert await instances.list_by_date_range(child_id, start, end) == []
# An away pattern covering no day leaves no home pattern: present
# every day, so the edit's regeneration builds the instances.
edited = await client.patch(
_pattern_url(pattern["id"]),
json={"kind": "away"},
headers=_admin_headers(),
)
assert edited.status_code == 200
assert await instances.list_by_date_range(child_id, start, end)
# --- pattern edit (PATCH) ------------------------------------------------------
async def test_pattern_patch_changes_only_supplied_fields(
admin_presence: SimpleNamespace,
) -> None:
"""A partial PATCH changes the supplied fields and keeps the rest."""
client = admin_presence.client
child_id = await _create_child(client)
original = await _create_pattern(client, child_id)
sibling = await _create_pattern(
client, child_id, _pattern_body(name="Sibling", kind="away")
)
renamed = await client.patch(
_pattern_url(original["id"]),
json={"name": "Weekday home"},
headers=_admin_headers(),
)
assert renamed.status_code == 200
assert renamed.json() == dict(original, name="Weekday home")
reshaped = await client.patch(
_pattern_url(original["id"]),
json={"cycle_length_weeks": 2, "pattern": {"0": [1], "1": [6, 5]}},
headers=_admin_headers(),
)
assert reshaped.status_code == 200
assert reshaped.json() == dict(
original,
name="Weekday home",
cycle_length_weeks=2,
pattern={"0": [1], "1": [5, 6]},
)
moved = await client.patch(
_pattern_url(original["id"]),
json={"anchor_date": "2026-01-12", "kind": "away"},
headers=_admin_headers(),
)
assert moved.status_code == 200
expected = dict(
reshaped.json(), anchor_date="2026-01-12", kind="away"
)
assert moved.json() == expected
# Database proof: the edited row, same id, and the sibling untouched.
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert [record.id for record in patterns] == [
original["id"],
sibling["id"],
]
assert patterns[0].name == "Weekday home"
assert patterns[0].kind == "away"
assert patterns[0].cycle_length_weeks == 2
assert patterns[0].anchor_date == "2026-01-12"
assert patterns[0].pattern == "1|5,6"
listed = await client.get(_patterns_url(child_id), headers=_admin_headers())
assert listed.json() == {"patterns": [expected, sibling]}
async def test_pattern_patch_unknown_id_is_404(
admin_presence: SimpleNamespace,
) -> None:
"""Editing a pattern id that does not exist is 404."""
response = await admin_presence.client.patch(
_pattern_url(999), json={"name": "Nope"}, headers=_admin_headers()
)
assert response.status_code == 404
assert response.json()["detail"] == "Presence pattern not found"
malformed = await admin_presence.client.patch(
_pattern_url("not-an-id"),
json={"name": "Nope"},
headers=_admin_headers(),
)
assert malformed.status_code == 422
@pytest.mark.parametrize(
("edit", "field"),
[
({"name": ""}, "name"),
({"name": " "}, "name"),
({"name": None}, "name"),
({"kind": "sometimes"}, "kind"),
({"kind": None}, "kind"),
({"cycle_length_weeks": 0}, "cycle_length_weeks"),
({"cycle_length_weeks": 5}, "cycle_length_weeks"),
({"cycle_length_weeks": 2.5}, "cycle_length_weeks"),
({"cycle_length_weeks": "2"}, "cycle_length_weeks"),
({"cycle_length_weeks": None}, "cycle_length_weeks"),
# A longer cycle without a matching pattern leaves week 1 uncovered.
({"cycle_length_weeks": 2}, "pattern"),
({"anchor_date": "2026-13-40"}, "anchor_date"),
({"anchor_date": None}, "anchor_date"),
({"pattern": {}}, "pattern"),
({"pattern": None}, "pattern"),
({"pattern": {"0": [7]}}, "pattern"), # weekday out of 0..6
# Strict JSON integer weekdays: no bool, string or float coercion.
({"pattern": {"0": [True]}}, "pattern"),
({"pattern": {"0": [False]}}, "pattern"),
({"pattern": {"0": ["1"]}}, "pattern"),
({"pattern": {"0": [1.0]}}, "pattern"),
],
)
async def test_rejected_pattern_patch_is_422_and_changes_nothing(
admin_presence: SimpleNamespace, edit: dict[str, object], field: str
) -> None:
"""A rejected edit (incl. explicit null) is 422 naming the field."""
client = admin_presence.client
child_id = await _create_child(client)
original = await _create_pattern(client, child_id)
response = await client.patch(
_pattern_url(original["id"]), json=edit, headers=_admin_headers()
)
assert response.status_code == 422, response.text
assert _detail_names(response, field), response.json()
listed = await client.get(_patterns_url(child_id), headers=_admin_headers())
assert listed.json() == {"patterns": [original]}
# --- pattern delete ------------------------------------------------------------
async def test_pattern_delete_removes_one_pattern_and_presence_resolves(
admin_presence: SimpleNamespace,
) -> None:
"""DELETE removes the one pattern; its siblings and presence follow."""
client = admin_presence.client
child_id = await _create_child(client)
home = await _create_pattern(client, child_id)
away = await _create_pattern(
client,
child_id,
_pattern_body(pattern={"0": [0]}, name="Swim camp", kind="away"),
)
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is False
deleted = await client.delete(
_pattern_url(away["id"]), headers=_admin_headers()
)
assert deleted.status_code == 200
assert deleted.json() == {"status": "ok"}
assert (
await _dao_presence.PresencePatternsDao(admin_presence.database).get(
away["id"]
)
is None
)
patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert [record.id for record in patterns] == [home["id"]]
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is False
# Deleting the last pattern: present every day again.
deleted = await client.delete(
_pattern_url(home["id"]), headers=_admin_headers()
)
assert deleted.status_code == 200
patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert patterns == []
assert engine.is_present(child_id, TUESDAY) is True
# A second delete of the same id is 404: it no longer exists.
again = await client.delete(
_pattern_url(home["id"]), headers=_admin_headers()
)
assert again.status_code == 404
assert again.json()["detail"] == "Presence pattern not found"
async def test_delete_unknown_pattern_is_404(
admin_presence: SimpleNamespace,
) -> None:
"""Deleting a pattern id that does not exist is 404."""
response = await admin_presence.client.delete(
_pattern_url(999), headers=_admin_headers()
)
assert response.status_code == 404
assert response.json()["detail"] == "Presence pattern not found"
malformed = await admin_presence.client.delete(
_pattern_url("not-an-id"), headers=_admin_headers()
)
assert malformed.status_code == 422
# --- override create + delete ----------------------------------------------- # --- override create + delete -----------------------------------------------
@@ -381,12 +798,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
client = admin_presence.client client = admin_presence.client
database = admin_presence.database database = admin_presence.database
child_id = await _create_child(client) child_id = await _create_child(client)
schedule_response = await client.put( await _create_pattern(client, child_id)
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
headers=_admin_headers(),
)
assert schedule_response.status_code == 200
# Create: the override comes back with its id and the posted shape. # Create: the override comes back with its id and the posted shape.
created = await client.post( created = await client.post(
@@ -412,7 +824,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
# Database proof: the row exists — and the RESOLVED presence for the # Database proof: the row exists — and the RESOLVED presence for the
# affected date flips: the override beats the Tuesday-absent pattern. # affected date flips: the override beats the Tuesday-absent pattern.
_schedule, overrides, engine = await _child_state(database, child_id) _patterns, overrides, engine = await _child_state(database, child_id)
assert [record.id for record in overrides] == [payload["id"]] assert [record.id for record in overrides] == [payload["id"]]
assert engine.is_present(child_id, TUESDAY) is True assert engine.is_present(child_id, TUESDAY) is True
# A date the override does not cover keeps the pattern's answer. # A date the override does not cover keeps the pattern's answer.
@@ -430,7 +842,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
await _dao_presence.PresenceOverridesDao(database).get(payload["id"]) await _dao_presence.PresenceOverridesDao(database).get(payload["id"])
is None is None
) )
_schedule, overrides, engine = await _child_state(database, child_id) _patterns, overrides, engine = await _child_state(database, child_id)
assert overrides == [] assert overrides == []
assert engine.is_present(child_id, TUESDAY) is False assert engine.is_present(child_id, TUESDAY) is False
@@ -438,19 +850,19 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
# --- error mapping: 404 vs 422 ---------------------------------------------- # --- error mapping: 404 vs 422 ----------------------------------------------
async def test_schedule_and_override_for_unknown_child_are_404( async def test_pattern_and_override_for_unknown_child_are_404(
admin_presence: SimpleNamespace, admin_presence: SimpleNamespace,
) -> None: ) -> None:
"""A presence write naming a child that does not exist is 404.""" """A presence write naming a child that does not exist is 404."""
client = admin_presence.client client = admin_presence.client
schedule = await client.put( pattern = await client.post(
"/api/v1/admin/children/999/presence-schedule", _patterns_url(999),
json=_schedule_body(), json=_pattern_body(),
headers=_admin_headers(), headers=_admin_headers(),
) )
assert schedule.status_code == 404 assert pattern.status_code == 404
assert schedule.json()["detail"] == "Child not found" assert pattern.json()["detail"] == "Child not found"
override = await client.post( override = await client.post(
"/api/v1/admin/presence-overrides", "/api/v1/admin/presence-overrides",
@@ -462,42 +874,70 @@ async def test_schedule_and_override_for_unknown_child_are_404(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"body", ("body", "field"),
[ [
_schedule_body(cycle_length_weeks=0), # below the 1..4 range (_pattern_body(cycle_length_weeks=0), "cycle_length_weeks"),
_schedule_body(cycle_length_weeks=5), # above the documented cap (_pattern_body(cycle_length_weeks=5), "cycle_length_weeks"),
# Strict JSON integers only: no float or string coercion.
(_pattern_body(cycle_length_weeks=2.5), "cycle_length_weeks"),
(_pattern_body(cycle_length_weeks="2"), "cycle_length_weeks"),
(_pattern_body(cycle_length_weeks=1.0), "cycle_length_weeks"),
# week 1 of the cycle missing: # week 1 of the cycle missing:
_schedule_body(cycle_length_weeks=2, pattern={"0": [0]}), (_pattern_body(cycle_length_weeks=2, pattern={"0": [0]}), "pattern"),
{ (_pattern_body(pattern={}), "pattern"),
"cycle_length_weeks": 1, (_pattern_body(anchor_date="2026-13-40"), "anchor_date"),
"anchor_date": "2026-13-40", (_pattern_body(anchor_date="05/01/2026"), "anchor_date"),
"pattern": {"0": [0]}, (_pattern_body(pattern={"0": [7]}), "pattern"), # weekday out of 0..6
}, # Strict JSON integer weekdays: no bool, string or float coercion.
_schedule_body(pattern={"0": [7]}), # weekday out of 0..6 (_pattern_body(pattern={"0": [True]}), "pattern"),
_schedule_body(pattern={"nope": [0]}), # week index not an integer (_pattern_body(pattern={"0": [False]}), "pattern"),
_schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list (_pattern_body(pattern={"0": ["1"]}), "pattern"),
(_pattern_body(pattern={"0": [1.0]}), "pattern"),
(_pattern_body(pattern={"nope": [0]}), "pattern"), # non-int week
(_pattern_body(pattern={"0": "0,2,4"}), "pattern"), # not a list
(_pattern_body(kind="sometimes"), "kind"),
(_pattern_body(kind="HOME"), "kind"),
(_pattern_body(name=""), "name"),
(_pattern_body(name=" "), "name"),
], ],
) )
async def test_rejected_schedule_is_422( async def test_rejected_pattern_is_422(
admin_presence: SimpleNamespace, body: dict[str, object] admin_presence: SimpleNamespace, body: dict[str, object], field: str
) -> None: ) -> None:
"""A rejected schedule is 422 and stores nothing.""" """A rejected pattern is 422 naming the field, and stores nothing."""
client = admin_presence.client client = admin_presence.client
child_id = await _create_child(client) child_id = await _create_child(client)
response = await client.put( response = await client.post(
f"/api/v1/admin/children/{child_id}/presence-schedule", _patterns_url(child_id),
json=body, json=body,
headers=_admin_headers(), headers=_admin_headers(),
) )
assert response.status_code == 422 assert response.status_code == 422, response.text
assert _detail_names(response, field), response.json()
assert ( assert (
await _dao_presence.PresenceSchedulesDao( await _dao_presence.PresencePatternsDao(
admin_presence.database admin_presence.database
).get_by_child(child_id) ).list_by_child(child_id)
is None == []
) )
@pytest.mark.parametrize("missing", ["name", "kind"])
async def test_pattern_missing_required_field_is_422(
admin_presence: SimpleNamespace, missing: str
) -> None:
"""The new required fields cannot be omitted (an old schedule body)."""
client = admin_presence.client
child_id = await _create_child(client)
body = _pattern_body()
del body[missing]
response = await client.post(
_patterns_url(child_id), json=body, headers=_admin_headers()
)
assert response.status_code == 422
assert _detail_names(response, missing)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"body", "body",
[ [
@@ -548,7 +988,7 @@ async def test_overlapping_override_is_422_and_first_survives(
headers=_admin_headers(), headers=_admin_headers(),
) )
assert response.status_code == 422 assert response.status_code == 422
_schedule, overrides, _engine = await _child_state( _patterns, overrides, _engine = await _child_state(
admin_presence.database, child_id admin_presence.database, child_id
) )
assert [record.id for record in overrides] == [created.json()["id"]] assert [record.id for record in overrides] == [created.json()["id"]]
+9 -7
View File
@@ -124,18 +124,20 @@ async def _create_daily_definition(
return created.json()["id"] return created.json()["id"]
async def _set_custody_schedule(client: object, child_id: int) -> None: async def _set_custody_pattern(client: object, child_id: int) -> None:
"""Anchor the two-week custody cycle on today (week 0 starts today).""" """Add the two-week custody home pattern anchored on today (week 0)."""
response = await client.put( response = await client.post(
f"/api/v1/admin/children/{child_id}/presence-schedule", f"/api/v1/admin/children/{child_id}/presence-patterns",
json={ json={
"name": "Custody",
"kind": "home",
"cycle_length_weeks": 2, "cycle_length_weeks": 2,
"anchor_date": _day(0), "anchor_date": _day(0),
"pattern": CUSTODY_PATTERN, "pattern": CUSTODY_PATTERN,
}, },
headers=_admin_headers(), headers=_admin_headers(),
) )
assert response.status_code == 200 assert response.status_code == 201
async def _materialize_horizon(database: object) -> None: async def _materialize_horizon(database: object) -> None:
@@ -210,7 +212,7 @@ async def custody(temp_db_path: str) -> SimpleNamespace:
client, child_id, "Feed the fish", ["afternoon"], client, child_id, "Feed the fish", ["afternoon"],
skip_on_away=False, skip_on_away=False,
) )
await _set_custody_schedule(client, child_id) await _set_custody_pattern(client, child_id)
await _materialize_horizon(database) await _materialize_horizon(database)
yield SimpleNamespace( yield SimpleNamespace(
client=client, client=client,
@@ -290,7 +292,7 @@ async def test_skip_on_away_false_definition_contributes_nothing(
client, child_id, "Feed the fish", ["morning"], client, child_id, "Feed the fish", ["morning"],
skip_on_away=False, skip_on_away=False,
) )
await _set_custody_schedule(client, child_id) await _set_custody_pattern(client, child_id)
await _materialize_horizon(database) await _materialize_horizon(database)
assert await _consequence(client, _body(child_id, 0, 14)) == 0 assert await _consequence(client, _body(child_id, 0, 14)) == 0
+96 -31
View File
@@ -1,14 +1,14 @@
"""Admin-plane presence READ route tests (task 07fde892). """Admin-plane presence READ route tests (task 07fde892).
``GET /api/v1/admin/children/{child_id}/presence-schedule`` and ``GET /api/v1/admin/children/{child_id}/presence-patterns`` and
``GET /api/v1/admin/presence-overrides`` against the ASGI app with ``GET /api/v1/admin/presence-overrides`` against the ASGI app with
httpx, through the shared stubbed-JWKS harness httpx, through the shared stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) — no network access. (:mod:`tests.admin_jwt_harness`) — no network access.
- The schedule read answers ``{"schedule": {...}}`` in the PUT - The patterns read answers ``{"patterns": [...]}`` in the POST
response's shape for a seeded schedule, ``{"schedule": null}`` for a response's shape for seeded patterns, ordered by id (a child may have
child with none (present every day — NOT a 404), and 404 for an several), ``{"patterns": []}`` for a child with none (present every
unknown child. day — NOT a 404), and 404 for an unknown child.
- The overrides read answers ``{"overrides": [...]}`` in the POST - The overrides read answers ``{"overrides": [...]}`` in the POST
response's shape, ordered by start date, child id, id; ``child_id`` response's shape, ordered by start date, child id, id; ``child_id``
and the ``start``/``end`` range each narrow the list; a malformed or and the ``start``/``end`` range each narrow the list; a malformed or
@@ -44,8 +44,8 @@ FORBIDDEN_DETAIL = (
) )
def _schedule_url(child_id: int) -> str: def _patterns_url(child_id: int) -> str:
return f"/api/v1/admin/children/{child_id}/presence-schedule" return f"/api/v1/admin/children/{child_id}/presence-patterns"
def _admin_headers() -> dict[str, str]: def _admin_headers() -> dict[str, str]:
@@ -99,20 +99,23 @@ async def reads(temp_db_path: str) -> SimpleNamespace:
@pytest.fixture @pytest.fixture
async def seeded(reads: SimpleNamespace) -> SimpleNamespace: async def seeded(reads: SimpleNamespace) -> SimpleNamespace:
"""Two children with interleaved overrides (Ada has a schedule).""" """Two children with interleaved overrides (Ada has a home pattern)."""
client = reads.client client = reads.client
ada = await _create_child(client, "Ada") ada = await _create_child(client, "Ada")
ben = await _create_child(client, "Ben") ben = await _create_child(client, "Ben")
put = await client.put( created = await client.post(
_schedule_url(ada), _patterns_url(ada),
json={ json={
"name": "Custody",
"kind": "home",
"cycle_length_weeks": 2, "cycle_length_weeks": 2,
"anchor_date": "2026-01-05", "anchor_date": "2026-01-05",
"pattern": {"0": [4, 0, 2], "1": []}, "pattern": {"0": [4, 0, 2], "1": []},
}, },
headers=_admin_headers(), headers=_admin_headers(),
) )
assert put.status_code == 200 assert created.status_code == 201, created.text
ada_custody = created.json()
ada_march = await _create_override( ada_march = await _create_override(
client, ada, "2026-03-01", "2026-03-07", note="grandparents" client, ada, "2026-03-01", "2026-03-07", note="grandparents"
) )
@@ -129,52 +132,114 @@ async def seeded(reads: SimpleNamespace) -> SimpleNamespace:
ben_january=ben_january, ben_january=ben_january,
ben_march=ben_march, ben_march=ben_march,
ada_may=ada_may, ada_may=ada_may,
ada_custody=ada_custody,
) )
# --- presence schedule read ------------------------------------------------ # --- presence patterns read ------------------------------------------------
async def test_schedule_read_returns_the_stored_schedule( async def test_patterns_read_returns_the_stored_patterns(
seeded: SimpleNamespace, seeded: SimpleNamespace,
) -> None: ) -> None:
"""A seeded schedule reads back in the PUT response's shape.""" """A seeded pattern reads back in the POST response's shape."""
response = await seeded.client.get( response = await seeded.client.get(
_schedule_url(seeded.ada), headers=_admin_headers() _patterns_url(seeded.ada), headers=_admin_headers()
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { assert response.json() == {
"schedule": { "patterns": [
"child_id": seeded.ada, {
"cycle_length_weeks": 2, "id": seeded.ada_custody["id"],
"anchor_date": "2026-01-05", "child_id": seeded.ada,
"pattern": {"0": [0, 2, 4], "1": []}, "name": "Custody",
} "kind": "home",
"cycle_length_weeks": 2,
"anchor_date": "2026-01-05",
"pattern": {"0": [0, 2, 4], "1": []},
}
]
} }
assert response.json()["patterns"] == [seeded.ada_custody]
async def test_schedule_read_without_schedule_is_null_not_404( async def test_patterns_read_lists_several_patterns_in_id_order(
seeded: SimpleNamespace, seeded: SimpleNamespace,
) -> None: ) -> None:
"""A child with no schedule (present every day) is schedule: null.""" """A child's patterns (zero or more) are listed ordered by id."""
response = await seeded.client.get( client = seeded.client
_schedule_url(seeded.ben), headers=_admin_headers() away = await client.post(
_patterns_url(seeded.ada),
json={
"name": "Camp",
"kind": "away",
"cycle_length_weeks": 1,
"anchor_date": "2026-01-05",
"pattern": {"0": [6, 5]},
},
headers=_admin_headers(),
)
assert away.status_code == 201, away.text
third = await client.post(
_patterns_url(seeded.ada),
json={
"name": "Weekday lessons",
"kind": "home",
"cycle_length_weeks": 1,
"anchor_date": "2026-01-05",
"pattern": {"0": [1]},
},
headers=_admin_headers(),
)
assert third.status_code == 201, third.text
response = await client.get(
_patterns_url(seeded.ada), headers=_admin_headers()
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"schedule": None} patterns = response.json()["patterns"]
assert patterns == [seeded.ada_custody, away.json(), third.json()]
ids = [pattern["id"] for pattern in patterns]
assert ids == sorted(ids)
assert patterns[1]["pattern"] == {"0": [5, 6]}
# Ben's list is untouched by Ada's patterns.
ben = await client.get(_patterns_url(seeded.ben), headers=_admin_headers())
assert ben.json() == {"patterns": []}
async def test_schedule_read_unknown_child_is_404( async def test_patterns_read_without_patterns_is_empty_not_404(
seeded: SimpleNamespace,
) -> None:
"""A child with no pattern (present every day) is an empty list."""
response = await seeded.client.get(
_patterns_url(seeded.ben), headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {"patterns": []}
async def test_patterns_read_unknown_child_is_404(
reads: SimpleNamespace, reads: SimpleNamespace,
) -> None: ) -> None:
"""An unknown child is 404 with the children routes' detail.""" """An unknown child is 404 with the children routes' detail."""
response = await reads.client.get( response = await reads.client.get(
_schedule_url(999), headers=_admin_headers() _patterns_url(999), headers=_admin_headers()
) )
assert response.status_code == 404 assert response.status_code == 404
assert response.json() == {"detail": "Child not found"} assert response.json() == {"detail": "Child not found"}
async def test_old_presence_schedule_read_route_is_gone(
seeded: SimpleNamespace,
) -> None:
"""The removed one-per-child schedule read no longer exists."""
response = await seeded.client.get(
f"/api/v1/admin/children/{seeded.ada}/presence-schedule",
headers=_admin_headers(),
)
assert response.status_code in (404, 405)
# --- presence overrides read ----------------------------------------------- # --- presence overrides read -----------------------------------------------
@@ -302,12 +367,12 @@ async def test_overrides_read_unknown_child_is_404(
# --- authentication: the router's ONE require_admin -------------------------- # --- authentication: the router's ONE require_admin --------------------------
@pytest.fixture(params=["schedule", "overrides"]) @pytest.fixture(params=["patterns", "overrides"])
async def read_url(request: pytest.FixtureRequest, reads: SimpleNamespace) -> str: async def read_url(request: pytest.FixtureRequest, reads: SimpleNamespace) -> str:
"""Each read route's URL (the schedule route for a real child).""" """Each read route's URL (the patterns route for a real child)."""
if request.param == "overrides": if request.param == "overrides":
return OVERRIDES_URL return OVERRIDES_URL
return _schedule_url(await _create_child(reads.client, "Ada")) return _patterns_url(await _create_child(reads.client, "Ada"))
async def test_read_without_credential_is_401( async def test_read_without_credential_is_401(
+5 -3
View File
@@ -141,7 +141,7 @@ async def _seed_household(
[bo.id], [bo.id],
[("morning", "17:00")], [("morning", "17:00")],
) )
# Today's instances first, while Ada has no presence schedule # Today's instances first, while Ada has no presence pattern
# (she is present every day, so her instance IS generated). # (she is present every day, so her instance IS generated).
await _materialize.materialize( await _materialize.materialize(
database, database,
@@ -183,12 +183,14 @@ async def _seed_household(
if record.due_time == "09:00" if record.due_time == "09:00"
) )
# Ada's custody schedule, added AFTER materialization (the # Ada's custody pattern (one home pattern), added AFTER materialization (the
# same order test_core_snapshot.py seeds): 2-week cycle # same order test_core_snapshot.py seeds): 2-week cycle
# anchored yesterday, empty week 0 covers today => absent # anchored yesterday, empty week 0 covers today => absent
# today; an override returns her on today+2. # today; an override returns her on today+2.
await _dao_presence.PresenceSchedulesDao(database).upsert_by_child( await _dao_presence.PresencePatternsDao(database).create(
ada.id, ada.id,
name="Custody",
kind="home",
cycle_length_weeks=2, cycle_length_weeks=2,
anchor_date=yesterday_iso, anchor_date=yesterday_iso,
pattern="|0,1,2,3,4,5,6", pattern="|0,1,2,3,4,5,6",
+20 -5
View File
@@ -36,7 +36,7 @@ from custom_components.nestquest.core.snapshot import (
from custom_components.nestquest.core.dao_instances import QuestInstancesDao from custom_components.nestquest.core.dao_instances import QuestInstancesDao
from custom_components.nestquest.core.dao_presence import ( from custom_components.nestquest.core.dao_presence import (
PresenceOverridesDao, PresenceOverridesDao,
PresenceSchedulesDao, PresencePatternsDao,
) )
from custom_components.nestquest.core.db import NestQuestDatabase from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.materialize import materialize from custom_components.nestquest.core.materialize import materialize
@@ -116,14 +116,16 @@ async def test_instance_payload_shape_and_missed_omission() -> None:
async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> None: async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> None:
"""The builder produces the documented shape as literals, pinned in time. """The builder produces the documented shape as literals, pinned in time.
Ada is AWAY today (a 2-week schedule whose absent week 0 covers today, Ada is AWAY today (a 2-week home pattern whose empty week 0 covers today,
plus a presence override marking her present on today+2) so plus a presence override marking her present on today+2) so
``present`` is False and ``next_present`` is the override date. Her ``present`` is False and ``next_present`` is the override date. Her
open quest due at 10:00 is OVERDUE under the pinned 12:00 ``now``. open quest due at 10:00 is OVERDUE under the pinned 12:00 ``now``.
Bo's quest due at 17:00 is completed at 12:00 (on time) so its Bo's quest due at 17:00 is completed at 12:00 (on time) so its
payload ``state`` is ``completed`` and ``on_time`` is True. The first payload ``state`` is ``completed`` and ``on_time`` is True. The first
scheduled active child is Ada, whose 2-week cycle anchored one day patterned active child is Ada, whose FIRST (lowest id) pattern is a
before today puts ``cycle_day`` at 2. 2-week cycle anchored one day before today, putting ``cycle_day`` at
2; her second pattern (a 3-week one anchored today, covering no day)
must not drive the cycle day.
""" """
# A standalone core database (the integration owns none): the # A standalone core database (the integration owns none): the
# builder and its seed run against it through the executor. # builder and its seed run against it through the executor.
@@ -172,12 +174,25 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
# covers today, so she is absent today; an override marks her present # covers today, so she is absent today; an override marks her present
# on today+2, so next_present resolves to that date and cycle_day is # on today+2, so next_present resolves to that date and cycle_day is
# ((today - (today-1)) % 14) + 1 == 2. # ((today - (today-1)) % 14) + 1 == 2.
await PresenceSchedulesDao(database).upsert_by_child( patterns = PresencePatternsDao(database)
await patterns.create(
ada.id, ada.id,
name="Home schedule",
kind="home",
cycle_length_weeks=2, cycle_length_weeks=2,
anchor_date=(today - datetime.timedelta(days=1)).isoformat(), anchor_date=(today - datetime.timedelta(days=1)).isoformat(),
pattern="|0,1,2,3,4,5,6", pattern="|0,1,2,3,4,5,6",
) )
# A later pattern covering no day: it changes no presence answer and,
# not being the first pattern, must not set cycle_day (it would be 1).
await patterns.create(
ada.id,
name="Placeholder away",
kind="away",
cycle_length_weeks=3,
anchor_date=today_iso,
pattern="||",
)
returns_date = today + datetime.timedelta(days=2) returns_date = today + datetime.timedelta(days=2)
await PresenceOverridesDao(database).create( await PresenceOverridesDao(database).create(
ada.id, ada.id,
+29 -18
View File
@@ -31,7 +31,7 @@ from custom_components.nestquest.core.dao_instances import (
) )
from custom_components.nestquest.core.dao_presence import ( from custom_components.nestquest.core.dao_presence import (
PresenceOverridesDao, PresenceOverridesDao,
PresenceSchedulesDao, PresencePatternsDao,
) )
from custom_components.nestquest.core.dao_rules import ( from custom_components.nestquest.core.dao_rules import (
ScheduleRulesDao, ScheduleRulesDao,
@@ -72,7 +72,7 @@ ALL_TABLES = (
"schedule_rules", "schedule_rules",
"quest_definitions", "quest_definitions",
"quest_definition_assignees", "quest_definition_assignees",
"presence_schedules", "presence_patterns",
"presence_overrides", "presence_overrides",
"quest_instances", "quest_instances",
"completion_events", "completion_events",
@@ -88,7 +88,7 @@ class World:
self.admins = AdminUsersDao(database) self.admins = AdminUsersDao(database)
self.rules = ScheduleRulesDao(database) self.rules = ScheduleRulesDao(database)
self.definitions = QuestDefinitionsDao(database) self.definitions = QuestDefinitionsDao(database)
self.schedules = PresenceSchedulesDao(database) self.patterns = PresencePatternsDao(database)
self.overrides = PresenceOverridesDao(database) self.overrides = PresenceOverridesDao(database)
self.instances = QuestInstancesDao(database) self.instances = QuestInstancesDao(database)
self.events = CompletionEventsDao(database) self.events = CompletionEventsDao(database)
@@ -105,8 +105,8 @@ class World:
_stamp(), _stamp(),
assignee_child_ids=[self.child.id], assignee_child_ids=[self.child.id],
) )
self.schedule = await self.schedules.upsert_by_child( self.pattern = await self.patterns.create(
self.child.id, 2, D1, "0,2,4|1,3" self.child.id, "Home schedule", "home", 2, D1, "0,2,4|1,3"
) )
self.override = await self.overrides.create( self.override = await self.overrides.create(
self.child.id, D1, D2, True, note="test" self.child.id, D1, D2, True, note="test"
@@ -244,28 +244,39 @@ def test_matrix_quest_definitions_crud_and_constraints(tmp_path) -> None:
assert _with_world(tmp_path / "m-definitions.db")(_body) is True assert _with_world(tmp_path / "m-definitions.db")(_body) is True
def test_matrix_presence_schedules_crud_and_constraints(tmp_path) -> None: def test_matrix_presence_patterns_crud_and_constraints(tmp_path) -> None:
async def _body(w: World): async def _body(w: World):
# insert (seeded) + read # insert (seeded) + read
fetched = await w.schedules.get_by_child(w.child.id) fetched = await w.patterns.get(w.pattern.id)
assert fetched == w.pattern
assert fetched.pattern == "0,2,4|1,3" assert fetched.pattern == "0,2,4|1,3"
# update path: upsert replaces in place (same row id) # a second pattern for the same child is allowed (schema 9)
replaced = await w.schedules.upsert_by_child( away = await w.patterns.create(
w.child.id, 2, D2, "1,3|0,2,4" w.child.id, "Weekend away", "away", 1, D1, "5,6"
)
assert [r.id for r in await w.patterns.list_by_child(w.child.id)] == [
w.pattern.id,
away.id,
]
# update path: rewrites in place (same row id)
replaced = await w.patterns.update(
w.pattern.id, "Home schedule", "home", 2, D2, "1,3|0,2,4"
) )
assert replaced.id == fetched.id assert replaced.id == fetched.id
assert (await w.schedules.get_by_child(w.child.id)).pattern == ( assert (await w.patterns.get(w.pattern.id)).pattern == "1,3|0,2,4"
"1,3|0,2,4"
)
# constraint: malformed pattern CHECK # constraint: malformed pattern CHECK
with pytest.raises(sqlite3.IntegrityError): with pytest.raises(sqlite3.IntegrityError):
await w.schedules.upsert_by_child(w.child.id, 2, D1, "0,2") await w.patterns.create(w.child.id, "Bad", "home", 2, D1, "0,2")
# delete: row gone, child present every day # constraint: kind CHECK
assert await w.schedules.delete(w.child.id) is True with pytest.raises(sqlite3.IntegrityError):
assert await w.schedules.get_by_child(w.child.id) is None await w.patterns.create(w.child.id, "Bad", "maybe", 1, D1, "0")
# delete: rows gone, child present every day
assert await w.patterns.delete(w.pattern.id) is True
assert await w.patterns.delete(away.id) is True
assert await w.patterns.list_by_child(w.child.id) == []
return True return True
assert _with_world(tmp_path / "m-schedules.db")(_body) is True assert _with_world(tmp_path / "m-patterns.db")(_body) is True
def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None: def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None:
+313 -148
View File
@@ -1,4 +1,4 @@
"""Tests for dao_presence.py: presence schedules and overrides DAO.""" """Tests for dao_presence.py: presence patterns and overrides DAO."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -10,8 +10,8 @@ from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_presence import ( from custom_components.nestquest.core.dao_presence import (
PresenceOverrideRecord, PresenceOverrideRecord,
PresenceOverridesDao, PresenceOverridesDao,
PresenceScheduleRecord, PresencePatternRecord,
PresenceSchedulesDao, PresencePatternsDao,
) )
from custom_components.nestquest.core.db import NestQuestDatabase from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations from custom_components.nestquest.core.migrations import apply_migrations
@@ -36,22 +36,22 @@ async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path) await database.open(path)
await apply_migrations(database) await apply_migrations(database)
schedules = PresenceSchedulesDao(database) patterns = PresencePatternsDao(database)
overrides = PresenceOverridesDao(database) overrides = PresenceOverridesDao(database)
children = ChildrenDao(database) children = ChildrenDao(database)
child = await children.create("Ada", NOW) child = await children.create("Ada", NOW)
return database, schedules, overrides, children, child return database, patterns, overrides, children, child
def _with_db(tmp_path, name): def _with_db(tmp_path, name):
def _run_test(body): def _run_test(body):
async def _main(): async def _main():
database, schedules, overrides, children, child = await _prepare( database, patterns, overrides, children, child = await _prepare(
tmp_path / name tmp_path / name
) )
try: try:
return await body( return await body(
database, schedules, overrides, children, child database, patterns, overrides, children, child
) )
finally: finally:
await database.close() await database.close()
@@ -62,105 +62,152 @@ def _with_db(tmp_path, name):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# presence_schedules: upsert by child # presence_patterns: create / list by child
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_upsert_creates_then_updates_same_row(tmp_path) -> None: def test_create_returns_typed_record_fields(tmp_path) -> None:
"""Second upsert for the same child UPDATES, not raises.""" async def _body(database, patterns, overrides, children, child):
async def _body(database, schedules, overrides, children, child): record = await patterns.create(
first = await schedules.upsert_by_child( child.id, "Mum's rota", "home", 4, "2026-09-07", "0|1|2|3"
child.id, 2, "2026-09-07", "0,2,4|1,3"
)
assert isinstance(first, PresenceScheduleRecord)
assert first.child_id == child.id
second = await schedules.upsert_by_child(
child.id, 2, "2026-09-14", "1,3|0,2,4"
)
assert second.id == first.id, "upsert must reuse the existing row"
fetched = await schedules.get_by_child(child.id)
assert fetched.anchor_date == "2026-09-14"
assert fetched.pattern == "1,3|0,2,4"
rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_schedules"
)
assert rows == (1,)
return second
_with_db(tmp_path, "upsert.db")(_body)
def test_upsert_typed_record_fields(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
record = await schedules.upsert_by_child(
child.id, 4, "2026-09-07", "0|1|2|3"
) )
assert isinstance(record, PresencePatternRecord)
assert record.id == 1 assert record.id == 1
assert record.child_id == child.id assert record.child_id == child.id
assert record.name == "Mum's rota"
assert record.kind == "home"
assert record.cycle_length_weeks == 4 assert record.cycle_length_weeks == 4
assert record.anchor_date == "2026-09-07" assert record.anchor_date == "2026-09-07"
assert record.pattern == "0|1|2|3" assert record.pattern == "0|1|2|3"
assert await patterns.get(record.id) == record
return record return record
_with_db(tmp_path, "upsert-fields.db")(_body) _with_db(tmp_path, "create-fields.db")(_body)
def test_upsert_unknown_child_raises_value_error(tmp_path) -> None: def test_create_many_patterns_for_one_child(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): """Schema 9 drops the one-per-child UNIQUE: a second create for the
with pytest.raises(ValueError, match="does not exist"): same child adds a row instead of raising or replacing."""
await schedules.upsert_by_child( async def _body(database, patterns, overrides, children, child):
999, 2, "2026-09-07", "0,2|1,3" first = await patterns.create(
child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3"
)
second = await patterns.create(
child.id, "Thu/Fri away", "away", 1, "2026-09-07", "3,4"
)
third = await patterns.create(
child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3"
)
assert len({first.id, second.id, third.id}) == 3
listed = await patterns.list_by_child(child.id)
assert listed == [first, second, third]
rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_patterns"
)
assert rows == (3,)
return listed
_with_db(tmp_path, "create-many.db")(_body)
def test_create_unknown_child_raises_value_error(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="child 999 does not exist"):
await patterns.create(
999, "Home", "home", 2, "2026-09-07", "0,2|1,3"
) )
return None return None
_with_db(tmp_path, "upsert-bad-child.db")(_body) _with_db(tmp_path, "create-bad-child.db")(_body)
def test_upsert_malformed_anchor_date_raises(tmp_path) -> None: def test_create_malformed_anchor_date_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
for bad in ("not-a-date", "2026-9-7", "2026-13-01", ""): for bad in ("not-a-date", "2026-9-7", "2026-13-01", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"): with pytest.raises(ValueError, match="YYYY-MM-DD"):
await schedules.upsert_by_child(child.id, 2, bad, "0|1") await patterns.create(child.id, "Home", "home", 2, bad, "0|1")
assert await patterns.list_by_child(child.id) == []
return None return None
_with_db(tmp_path, "upsert-bad-date.db")(_body) _with_db(tmp_path, "create-bad-date.db")(_body)
def test_upsert_malformed_pattern_rejected_by_schema(tmp_path) -> None: def test_create_malformed_pattern_rejected_by_schema(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"): with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await schedules.upsert_by_child( await patterns.create(
child.id, 2, "2026-09-07", "0,2" child.id, "Home", "home", 2, "2026-09-07", "0,2"
) )
return None return None
_with_db(tmp_path, "upsert-bad-pattern.db")(_body) _with_db(tmp_path, "create-bad-pattern.db")(_body)
def test_upsert_two_children_have_distinct_rows(tmp_path) -> None: @pytest.mark.parametrize("bad_kind", ["present", "Home", "AWAY", ""])
async def _body(database, schedules, overrides, children, child): def test_create_unknown_kind_rejected_by_schema(tmp_path, bad_kind) -> None:
async def _body(database, patterns, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.create(
child.id, "Home", bad_kind, 1, "2026-09-07", "0"
)
assert await patterns.list_by_child(child.id) == []
return None
_with_db(tmp_path, "create-bad-kind.db")(_body)
def test_list_by_child_two_children_are_isolated(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
other = await children.create("Bo", NOW) other = await children.create("Bo", NOW)
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3") await patterns.create(
await schedules.upsert_by_child(other.id, 2, "2026-09-07", "1,3|0,2") child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
mine = await schedules.get_by_child(child.id) )
theirs = await schedules.get_by_child(other.id) await patterns.create(
assert mine.id != theirs.id other.id, "Home", "home", 2, "2026-09-07", "1,3|0,2"
assert mine.pattern == "0,2|1,3" )
assert theirs.pattern == "1,3|0,2" mine = await patterns.list_by_child(child.id)
theirs = await patterns.list_by_child(other.id)
assert [r.child_id for r in mine] == [child.id]
assert [r.child_id for r in theirs] == [other.id]
assert mine[0].id != theirs[0].id
assert mine[0].pattern == "0,2|1,3"
assert theirs[0].pattern == "1,3|0,2"
return mine return mine
_with_db(tmp_path, "upsert-two-children.db")(_body) _with_db(tmp_path, "list-two-children.db")(_body)
def test_upsert_concurrent_same_child_serializes(tmp_path) -> None: def test_list_by_child_orders_by_id(tmp_path) -> None:
"""Racing upserts for one child both succeed; exactly one row. """Ordering is by id (creation order), not by name, kind or anchor."""
async def _body(database, patterns, overrides, children, child):
created = [
await patterns.create(
child.id, "Zed", "away", 1, "2026-09-21", "6"
),
await patterns.create(
child.id, "Alpha", "home", 1, "2026-09-07", "0"
),
await patterns.create(
child.id, "Mid", "away", 2, "2026-09-14", "1|2"
),
]
listed = await patterns.list_by_child(child.id)
assert [r.id for r in listed] == sorted(r.id for r in created)
assert [r.name for r in listed] == ["Zed", "Alpha", "Mid"]
return listed
_with_db(tmp_path, "list-order.db")(_body)
def test_create_concurrent_same_child_both_land(tmp_path) -> None:
"""Racing creates for one child both succeed as DISTINCT rows.
Deterministic interleaving: a controllable executor gates task A's Deterministic interleaving: a controllable executor gates task A's
job AFTER it has acquired the connection lock and begun its job AFTER it has acquired the connection lock and begun its
transaction, so task B is created while A provably owns the transaction, so task B is created while A provably owns the
transaction and must queue on the lock. Both upserts land, and transaction and must queue on the lock. With no UNIQUE on
the table holds exactly one row (the ON CONFLICT upsert, not two ``child_id`` (schema 9) both INSERTs land and the child lists two
plain INSERTs, is what avoids a UNIQUE violation). patterns in id order.
""" """
async def _main(): async def _main():
from unittest.mock import MagicMock from unittest.mock import MagicMock
@@ -180,13 +227,6 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
a_started = asyncio.Event() a_started = asyncio.Event()
async def _gated_executor(fn, *args): async def _gated_executor(fn, *args):
# Gate the SECOND gated-window job: the first is the
# transaction's BEGIN (submitted and awaited by
# transaction() before upsert_by_child's child-SELECT
# runs). Pausing the child-existence SELECT proves the
# BEGIN has EXECUTED — SQLite is actually inside the open
# transaction, with A holding the connection lock — when
# task B gets scheduled and must queue.
if armed["active"]: if armed["active"]:
name = getattr(fn, "__name__", "") name = getattr(fn, "__name__", "")
if name == "_fetch_one" and not armed["gated"]: if name == "_fetch_one" and not armed["gated"]:
@@ -197,17 +237,17 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
hass.async_add_executor_job = _gated_executor hass.async_add_executor_job = _gated_executor
database = NestQuestDatabase(hass.async_add_executor_job) database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "upsert-race.db") await database.open(tmp_path / "create-race.db")
try: try:
await apply_migrations(database) await apply_migrations(database)
children = ChildrenDao(database) children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database) patterns = PresencePatternsDao(database)
child = await children.create("Ada", NOW) child = await children.create("Ada", NOW)
armed["active"] = True armed["active"] = True
task_a = asyncio.ensure_future( task_a = asyncio.ensure_future(
schedules.upsert_by_child( patterns.create(
child.id, 2, "2026-09-07", "0,2|1,3" child.id, "A", "home", 2, "2026-09-07", "0,2|1,3"
) )
) )
# Wait until task A's child-existence SELECT (inside its # Wait until task A's child-existence SELECT (inside its
@@ -217,81 +257,197 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
await a_started.wait() await a_started.wait()
armed["active"] = False armed["active"] = False
task_b = asyncio.ensure_future( task_b = asyncio.ensure_future(
schedules.upsert_by_child( patterns.create(
child.id, 2, "2026-09-14", "1,3|0,2" child.id, "B", "away", 1, "2026-09-14", "5,6"
) )
) )
await asyncio.sleep(0) await asyncio.sleep(0)
# Release A; B's upsert must wait for A's commit and then # Release A; B must wait for A's commit and then insert its
# land as the ON CONFLICT update path. # own row.
gate_open.set() gate_open.set()
record_a, record_b = await asyncio.gather(task_a, task_b) record_a, record_b = await asyncio.gather(task_a, task_b)
assert record_a.id == record_b.id assert record_a.id != record_b.id
fetched = await schedules.get_by_child(child.id) assert (record_a.name, record_b.name) == ("A", "B")
assert fetched is not None listed = await patterns.list_by_child(child.id)
rows = await database.fetch_one( rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_schedules" "SELECT COUNT(*) FROM presence_patterns"
) )
assert rows == (1,) assert rows == (2,)
return fetched return record_a, record_b, listed
finally: finally:
await database.close() await database.close()
fetched = _run(_main()) record_a, record_b, listed = _run(_main())
assert fetched.pattern in {"0,2|1,3", "1,3|0,2"} assert listed == [record_a, record_b]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# presence_schedules: get / delete # presence_patterns: list / get / update / delete
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_get_by_child_missing_schedule_returns_none(tmp_path) -> None: def test_list_by_child_without_patterns_returns_empty(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
return await schedules.get_by_child(child.id) return await patterns.list_by_child(child.id)
assert _with_db(tmp_path, "list-none.db")(_body) == []
def test_list_by_child_unknown_child_raises(tmp_path) -> None:
"""[] must mean ONLY 'no pattern rows': an unknown child id is a
caller bug, and silently reading a nonexistent child as 'present
every day' would be wrong — so list raises instead.
"""
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="child 999 does not exist"):
await patterns.list_by_child(999)
return None
_with_db(tmp_path, "list-unknown.db")(_body)
def test_get_missing_pattern_returns_none(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
return await patterns.get(999)
assert _with_db(tmp_path, "get-none.db")(_body) is None assert _with_db(tmp_path, "get-none.db")(_body) is None
def test_get_by_child_unknown_child_raises(tmp_path) -> None: def test_update_rewrites_every_field_but_child(tmp_path) -> None:
"""None must mean ONLY 'no schedule row': an unknown child id is a async def _body(database, patterns, overrides, children, child):
caller bug, and silently reading a nonexistent child as 'present record = await patterns.create(
every day' would be wrong — so get raises instead. child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3"
""" )
async def _body(database, schedules, overrides, children, child): updated = await patterns.update(
with pytest.raises(ValueError, match="does not exist"): record.id, "Weekends away", "away", 1, "2026-09-12", "5,6"
await schedules.get_by_child(999) )
assert updated == PresencePatternRecord(
id=record.id,
child_id=child.id,
name="Weekends away",
kind="away",
cycle_length_weeks=1,
anchor_date="2026-09-12",
pattern="5,6",
)
assert await patterns.get(record.id) == updated
assert await patterns.list_by_child(child.id) == [updated]
return updated
_with_db(tmp_path, "update.db")(_body)
def test_update_touches_only_the_addressed_pattern(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
first = await patterns.create(
child.id, "Home", "home", 1, "2026-09-07", "0,1,2"
)
second = await patterns.create(
child.id, "Away", "away", 1, "2026-09-07", "5"
)
updated = await patterns.update(
second.id, "Away", "away", 1, "2026-09-07", "5,6"
)
assert await patterns.list_by_child(child.id) == [first, updated]
return updated
_with_db(tmp_path, "update-one.db")(_body)
def test_update_unknown_id_returns_none(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
result = await patterns.update(
999, "Home", "home", 1, "2026-09-07", "0"
)
assert await patterns.list_by_child(child.id) == []
return result
assert _with_db(tmp_path, "update-unknown.db")(_body) is None
def test_update_malformed_anchor_date_raises(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home", "home", 1, "2026-09-07", "0"
)
for bad in ("not-a-date", "2026-9-7", "2026-13-01", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await patterns.update(record.id, "Home", "home", 1, bad, "0")
assert await patterns.get(record.id) == record
return None return None
_with_db(tmp_path, "get-unknown.db")(_body) _with_db(tmp_path, "update-bad-date.db")(_body)
def test_update_bad_kind_and_pattern_rejected_by_schema(tmp_path) -> None:
"""The CHECKs apply on UPDATE exactly as on INSERT, and a rejected
update leaves the stored row unchanged."""
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.update(
record.id, "Home", "present", 2, "2026-09-07", "0,2|1,3"
)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.update(
record.id, "Home", "home", 2, "2026-09-07", "0,2"
)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.update(
record.id, "Home", "home", 5, "2026-09-07", "0|1|2|3|4"
)
assert await patterns.get(record.id) == record
return None
_with_db(tmp_path, "update-bad-check.db")(_body)
def test_delete_removes_and_returns_true_then_false(tmp_path) -> None: def test_delete_removes_and_returns_true_then_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3") record = await patterns.create(
assert await schedules.delete(child.id) is True child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
assert await schedules.get_by_child(child.id) is None )
assert await schedules.delete(child.id) is False assert await patterns.delete(record.id) is True
assert await patterns.get(record.id) is None
assert await patterns.list_by_child(child.id) == []
assert await patterns.delete(record.id) is False
return None return None
_with_db(tmp_path, "delete.db")(_body) _with_db(tmp_path, "delete.db")(_body)
def test_delete_child_without_schedule_returns_false(tmp_path) -> None: def test_delete_missing_pattern_returns_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
return await schedules.delete(child.id) return await patterns.delete(999)
assert _with_db(tmp_path, "delete-none.db")(_body) is False assert _with_db(tmp_path, "delete-none.db")(_body) is False
def test_delete_then_upsert_creates_fresh_row(tmp_path) -> None: def test_delete_leaves_the_childs_other_patterns(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
first = await schedules.upsert_by_child( first = await patterns.create(
child.id, 2, "2026-09-07", "0,2|1,3" child.id, "Home", "home", 1, "2026-09-07", "0,1,2"
) )
await schedules.delete(child.id) second = await patterns.create(
second = await schedules.upsert_by_child( child.id, "Away", "away", 1, "2026-09-07", "5"
child.id, 2, "2026-09-07", "0,2|1,3" )
assert await patterns.delete(first.id) is True
assert await patterns.list_by_child(child.id) == [second]
return None
_with_db(tmp_path, "delete-others.db")(_body)
def test_delete_then_create_makes_fresh_row(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
first = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
await patterns.delete(first.id)
second = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
) )
assert second.id != first.id assert second.id != first.id
return second return second
@@ -305,7 +461,7 @@ def test_delete_then_upsert_creates_fresh_row(tmp_path) -> None:
def test_override_create_returns_typed_record(tmp_path) -> None: def test_override_create_returns_typed_record(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
record = await overrides.create( record = await overrides.create(
child.id, "2026-12-21", "2027-01-04", False, child.id, "2026-12-21", "2027-01-04", False,
note="Holiday abroad", note="Holiday abroad",
@@ -322,7 +478,7 @@ def test_override_create_returns_typed_record(tmp_path) -> None:
def test_override_create_single_day_same_start_and_end(tmp_path) -> None: def test_override_create_single_day_same_start_and_end(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
record = await overrides.create( record = await overrides.create(
child.id, "2026-09-14", "2026-09-14", True child.id, "2026-09-14", "2026-09-14", True
) )
@@ -335,7 +491,7 @@ def test_override_create_single_day_same_start_and_end(tmp_path) -> None:
def test_override_create_unknown_child_raises(tmp_path) -> None: def test_override_create_unknown_child_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"): with pytest.raises(ValueError, match="does not exist"):
await overrides.create( await overrides.create(
999, "2026-09-14", "2026-09-14", True 999, "2026-09-14", "2026-09-14", True
@@ -346,7 +502,7 @@ def test_override_create_unknown_child_raises(tmp_path) -> None:
def test_override_create_malformed_dates_raise(tmp_path) -> None: def test_override_create_malformed_dates_raise(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
for bad in ("not-a-date", "2026-9-14", ""): for bad in ("not-a-date", "2026-9-14", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"): with pytest.raises(ValueError, match="YYYY-MM-DD"):
await overrides.create(child.id, bad, "2026-09-14", True) await overrides.create(child.id, bad, "2026-09-14", True)
@@ -358,7 +514,7 @@ def test_override_create_malformed_dates_raise(tmp_path) -> None:
def test_override_end_before_start_rejected_by_schema(tmp_path) -> None: def test_override_end_before_start_rejected_by_schema(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"): with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await overrides.create( await overrides.create(
child.id, "2026-09-14", "2026-09-13", True child.id, "2026-09-14", "2026-09-13", True
@@ -371,7 +527,7 @@ def test_override_end_before_start_rejected_by_schema(tmp_path) -> None:
def test_override_list_by_child_and_range_filters_correctly( def test_override_list_by_child_and_range_filters_correctly(
tmp_path, tmp_path,
) -> None: ) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
await overrides.create( await overrides.create(
child.id, "2026-09-01", "2026-09-05", False, note="before" child.id, "2026-09-01", "2026-09-05", False, note="before"
) )
@@ -400,7 +556,7 @@ def test_override_list_by_child_and_range_filters_correctly(
def test_override_list_orders_by_start_date(tmp_path) -> None: def test_override_list_orders_by_start_date(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
late = await overrides.create( late = await overrides.create(
child.id, "2026-09-20", "2026-09-21", True child.id, "2026-09-20", "2026-09-21", True
) )
@@ -417,7 +573,7 @@ def test_override_list_orders_by_start_date(tmp_path) -> None:
def test_override_list_per_child_isolation(tmp_path) -> None: def test_override_list_per_child_isolation(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
other = await children.create("Bo", NOW) other = await children.create("Bo", NOW)
await overrides.create(child.id, "2026-09-10", "2026-09-11", True) await overrides.create(child.id, "2026-09-10", "2026-09-11", True)
await overrides.create(other.id, "2026-09-10", "2026-09-11", False) await overrides.create(other.id, "2026-09-10", "2026-09-11", False)
@@ -432,7 +588,7 @@ def test_override_list_per_child_isolation(tmp_path) -> None:
def test_override_list_malformed_range_raises(tmp_path) -> None: def test_override_list_malformed_range_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="YYYY-MM-DD"): with pytest.raises(ValueError, match="YYYY-MM-DD"):
await overrides.list_by_child_and_range( await overrides.list_by_child_and_range(
child.id, "oops", "2026-09-30" child.id, "oops", "2026-09-30"
@@ -447,7 +603,7 @@ def test_override_list_malformed_range_raises(tmp_path) -> None:
def test_override_list_unknown_child_returns_empty(tmp_path) -> None: def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
return await overrides.list_by_child_and_range( return await overrides.list_by_child_and_range(
999, "2026-09-01", "2026-09-30" 999, "2026-09-01", "2026-09-30"
) )
@@ -456,7 +612,7 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
def test_override_get_round_trip(tmp_path) -> None: def test_override_get_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
record = await overrides.create( record = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True, note="camp" child.id, "2026-09-10", "2026-09-11", True, note="camp"
) )
@@ -470,7 +626,7 @@ def test_override_get_round_trip(tmp_path) -> None:
def test_override_delete_round_trip(tmp_path) -> None: def test_override_delete_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
record = await overrides.create( record = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True child.id, "2026-09-10", "2026-09-11", True
) )
@@ -486,14 +642,14 @@ def test_override_delete_round_trip(tmp_path) -> None:
def test_override_delete_missing_returns_false(tmp_path) -> None: def test_override_delete_missing_returns_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
return await overrides.delete(999) return await overrides.delete(999)
assert _with_db(tmp_path, "override-delete-missing.db")(_body) is False assert _with_db(tmp_path, "override-delete-missing.db")(_body) is False
def test_override_delete_leaves_other_overrides(tmp_path) -> None: def test_override_delete_leaves_other_overrides(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
first = await overrides.create( first = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True child.id, "2026-09-10", "2026-09-11", True
) )
@@ -510,18 +666,25 @@ def test_override_delete_leaves_other_overrides(tmp_path) -> None:
_with_db(tmp_path, "override-delete-others.db")(_body) _with_db(tmp_path, "override-delete-others.db")(_body)
def test_schedule_and_override_independent_for_same_child(tmp_path) -> None: def test_pattern_and_override_independent_for_same_child(tmp_path) -> None:
"""Deleting the schedule does not touch overrides and vice versa.""" """Deleting a pattern does not touch overrides and vice versa."""
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3") pattern = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
override = await overrides.create( override = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True child.id, "2026-09-10", "2026-09-11", True
) )
assert await schedules.delete(child.id) is True assert await patterns.delete(pattern.id) is True
found = await overrides.list_by_child_and_range( found = await overrides.list_by_child_and_range(
child.id, "2026-09-01", "2026-09-30" child.id, "2026-09-01", "2026-09-30"
) )
assert [r.id for r in found] == [override.id] assert [r.id for r in found] == [override.id]
second = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
assert await overrides.delete(override.id) is True
assert await patterns.list_by_child(child.id) == [second]
return found return found
_with_db(tmp_path, "independent.db")(_body) _with_db(tmp_path, "independent.db")(_body)
@@ -533,7 +696,7 @@ def test_schedule_and_override_independent_for_same_child(tmp_path) -> None:
def test_presence_sql_lives_only_in_dao_module() -> None: def test_presence_sql_lives_only_in_dao_module() -> None:
"""Guardrail: presence_schedules/presence_overrides SQL may appear """Guardrail: presence_patterns/presence_overrides SQL may appear
only in the DAO module (and the schema DDL declarations). only in the DAO module (and the schema DDL declarations).
RECURSIVE scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/ RECURSIVE scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/
JOIN pattern, with exact repo-relative exclusions justified by JOIN pattern, with exact repo-relative exclusions justified by
@@ -562,7 +725,7 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
} }
sql_pattern = re.compile( sql_pattern = re.compile(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?" r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
r"(presence_schedules|presence_overrides)\b", r"(presence_patterns|presence_overrides)\b",
re.IGNORECASE, re.IGNORECASE,
) )
offenders: list[str] = [] offenders: list[str] = []
@@ -574,7 +737,7 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
if sql_pattern.search(py.read_text()): if sql_pattern.search(py.read_text()):
offenders.append(relative) offenders.append(relative)
assert offenders == [], ( assert offenders == [], (
f"SQL touching presence_schedules/presence_overrides leaked " f"SQL touching presence_patterns/presence_overrides leaked "
f"into: {offenders}" f"into: {offenders}"
) )
@@ -582,8 +745,9 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None: def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
"""This test module must exercise the DAO, not raw SQL, for these """This test module must exercise the DAO, not raw SQL, for these
tables. Exemptions are EXACT lines, not whole-function spans: the tables. Exemptions are EXACT lines, not whole-function spans: the
two single-statement COUNT(*) row probes (the only honest way to single-statement COUNT(*) row probes (the only honest way to prove
prove 'exactly one row exists' without a listing method) and the how many rows the whole table holds, which a per-child listing
cannot) and the
guard functions' own regex spans. Any other raw SQL added anywhere guard functions' own regex spans. Any other raw SQL added anywhere
in this file fails the guard. in this file fails the guard.
""" """
@@ -615,7 +779,7 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
isinstance(sub, ast.Constant) isinstance(sub, ast.Constant)
and isinstance(sub.value, str) and isinstance(sub.value, str)
and sub.value.strip() and sub.value.strip()
== "SELECT COUNT(*) FROM presence_schedules" == "SELECT COUNT(*) FROM presence_patterns"
and sub.lineno is not None and sub.lineno is not None
): ):
excluded.add(sub.lineno) excluded.add(sub.lineno)
@@ -627,7 +791,8 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
) )
sql_pattern = re.compile( sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(presence_schedules|presence_overrides)" r"FROM|JOIN)\s+[`'\"]*(\[)?"
r"(presence_patterns|presence_overrides)"
r"\b", r"\b",
re.IGNORECASE, re.IGNORECASE,
) )
@@ -641,7 +806,7 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
def test_override_create_round_trip_single_and_range(tmp_path) -> None: def test_override_create_round_trip_single_and_range(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
single = await overrides.create( single = await overrides.create(
child.id, "2026-07-20", "2026-07-20", False child.id, "2026-07-20", "2026-07-20", False
) )
@@ -662,7 +827,7 @@ def test_override_create_round_trip_single_and_range(tmp_path) -> None:
def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None: def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
existing = await overrides.create( existing = await overrides.create(
child.id, "2026-07-20", "2026-07-24", False, note="holiday" child.id, "2026-07-20", "2026-07-24", False, note="holiday"
) )
@@ -698,7 +863,7 @@ def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None:
def test_override_overlap_is_per_child(tmp_path) -> None: def test_override_overlap_is_per_child(tmp_path) -> None:
"""Different children's overrides may cover the same dates: the """Different children's overrides may cover the same dates: the
conflict rule is per child.""" conflict rule is per child."""
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
other = await children.create("Bo", NOW) other = await children.create("Bo", NOW)
await overrides.create(child.id, "2026-07-20", "2026-07-24", False) await overrides.create(child.id, "2026-07-20", "2026-07-24", False)
twin = await overrides.create( twin = await overrides.create(
@@ -715,7 +880,7 @@ def test_override_overlap_is_per_child(tmp_path) -> None:
def test_override_remove_then_recreate_overlapping(tmp_path) -> None: def test_override_remove_then_recreate_overlapping(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child): async def _body(database, patterns, overrides, children, child):
first = await overrides.create( first = await overrides.create(
child.id, "2026-07-20", "2026-07-24", False child.id, "2026-07-20", "2026-07-24", False
) )
+162 -17
View File
@@ -12,7 +12,7 @@ from custom_components.nestquest.core.dao_instances import (
CompletionEventsDao, CompletionEventsDao,
QuestInstancesDao, QuestInstancesDao,
) )
from custom_components.nestquest.core.dao_presence import PresenceSchedulesDao from custom_components.nestquest.core.dao_presence import PresencePatternsDao
from custom_components.nestquest.core.dao_rules import ( from custom_components.nestquest.core.dao_rules import (
QuestDefinitionsDao, QuestDefinitionsDao,
schedule_rule_to_storage, schedule_rule_to_storage,
@@ -28,6 +28,10 @@ from custom_components.nestquest.core.quest_definitions import (
create_quest_definition, create_quest_definition,
edit_quest_definition, edit_quest_definition,
) )
from custom_components.nestquest.core.presence_management import (
create_presence_pattern,
delete_presence_pattern,
)
from custom_components.nestquest.core.recurrence import ( from custom_components.nestquest.core.recurrence import (
RuleType, RuleType,
ScheduleRule, ScheduleRule,
@@ -83,7 +87,7 @@ def _build_expected(start, def1_id, def2_id, a_id, b_id, c_id) -> set:
due_time) tuples over the four-week window. due_time) tuples over the four-week window.
Custody: A is present week 0 and 2 of the two-week cycle, absent Custody: A is present week 0 and 2 of the two-week cycle, absent
1 and 3; B is the mirror; C has no schedule (present every day). 1 and 3; B is the mirror; C has no pattern (present every day).
""" """
expected: set = set() expected: set = set()
start_dt = datetime.date.fromisoformat(start) start_dt = datetime.date.fromisoformat(start)
@@ -128,21 +132,21 @@ async def _collect_instances(database, child_ids, start, end) -> set:
def test_materialize_two_custody_one_always_present(tmp_path) -> None: def test_materialize_two_custody_one_always_present(tmp_path) -> None:
async def _body(database): async def _body(database):
children = ChildrenDao(database) children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database) patterns = PresencePatternsDao(database)
a = await children.create("Ada", NOW) # present weeks 0, 2 a = await children.create("Ada", NOW) # present weeks 0, 2
b = await children.create("Bo", NOW) # present weeks 1, 3 b = await children.create("Bo", NOW) # present weeks 1, 3
c = await children.create("Cleo", NOW) # no schedule: always present c = await children.create("Cleo", NOW) # no pattern: always present
start = _future_monday() start = _future_monday()
start_iso = start.isoformat() start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=27)).isoformat() end_iso = (start + datetime.timedelta(days=27)).isoformat()
await schedules.upsert_by_child( await patterns.create(
a.id, 2, start_iso, "0,1,2,3,4,5,6|" a.id, "Home schedule", "home", 2, start_iso, "0,1,2,3,4,5,6|"
) )
await schedules.upsert_by_child( await patterns.create(
b.id, 2, start_iso, "|0,1,2,3,4,5,6" b.id, "Home schedule", "home", 2, start_iso, "|0,1,2,3,4,5,6"
) )
def1 = await create_quest_definition( def1 = await create_quest_definition(
@@ -181,6 +185,136 @@ def test_materialize_two_custody_one_always_present(tmp_path) -> None:
_with_db(tmp_path, "materialize.db")(_body) _with_db(tmp_path, "materialize.db")(_body)
async def _due_dates(database, child_id, start_iso, end_iso) -> set[str]:
records = await QuestInstancesDao(database).list_by_date_range(
child_id, start_iso, end_iso
)
return {record.due_date for record in records}
def test_materialize_home_and_away_patterns_combine(tmp_path) -> None:
"""Several patterns per child combine: a covering ``away`` beats a
covering ``home``. An every-day home pattern plus an away Thu+Fri
pattern leaves the child absent exactly on Thursday and Friday, so a
skip_on_away definition skips those days while a non-skipping one
still generates them."""
async def _body(database):
child = await ChildrenDao(database).create("Ada", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=6)).isoformat()
patterns = PresencePatternsDao(database)
await patterns.create(
child.id, "Home schedule", "home", 1, start_iso, "0,1,2,3,4,5,6"
)
await patterns.create(
child.id, "Thu-Fri away", "away", 1, start_iso, "3,4"
)
skipping = await create_quest_definition(
database,
"Skipping chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
skip_on_away=True,
)
keeping = await create_quest_definition(
database,
"Keeping chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["evening"],
skip_on_away=False,
)
count = await materialize(database, start_iso, end_iso)
assert count == 5 + 7
records = await QuestInstancesDao(database).list_by_date_range(
child.id, start_iso, end_iso
)
by_definition: dict[int, set[str]] = {}
for record in records:
by_definition.setdefault(record.definition_id, set()).add(
record.due_date
)
thursday_friday = {
(start + datetime.timedelta(days=offset)).isoformat()
for offset in (3, 4)
}
all_week = {
(start + datetime.timedelta(days=offset)).isoformat()
for offset in range(7)
}
assert by_definition[skipping.definition.id] == (
all_week - thursday_friday
)
assert by_definition[keeping.definition.id] == all_week
_with_db(tmp_path, "materialize-multi-pattern.db")(_body)
def test_presence_pattern_create_and_delete_regenerate_instances(
tmp_path,
) -> None:
"""Creating and deleting patterns re-materializes the child's future.
Home Mon..Wed plus away Tuesday leaves Monday and Wednesday; deleting
the away pattern restores Tuesday; deleting the last (home) pattern
makes the child present every day again.
"""
async def _body(database):
child = await ChildrenDao(database).create("Ada", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=6)).isoformat()
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
)
window = {"today": start, "horizon_days": 6}
def days(*offsets: int) -> set[str]:
return {
(start + datetime.timedelta(days=offset)).isoformat()
for offset in offsets
}
home = await create_presence_pattern(
database, child.id, "Home schedule", "home", 1, start_iso,
{0: {0, 1, 2}}, **window,
)
assert await _due_dates(database, child.id, start_iso, end_iso) == (
days(0, 1, 2)
)
away = await create_presence_pattern(
database, child.id, "Tuesday away", "away", 1, start_iso,
{0: {1}}, **window,
)
assert await _due_dates(database, child.id, start_iso, end_iso) == (
days(0, 2)
)
deleted = await delete_presence_pattern(database, away.id, **window)
assert deleted == away
assert await _due_dates(database, child.id, start_iso, end_iso) == (
days(0, 1, 2)
)
await delete_presence_pattern(database, home.id, **window)
assert await PresencePatternsDao(database).list_by_child(
child.id
) == []
assert await _due_dates(database, child.id, start_iso, end_iso) == (
days(*range(7))
)
_with_db(tmp_path, "materialize-pattern-delete.db")(_body)
def test_materialize_shares_one_generated_at_per_run(tmp_path) -> None: def test_materialize_shares_one_generated_at_per_run(tmp_path) -> None:
async def _body(database): async def _body(database):
children = ChildrenDao(database) children = ChildrenDao(database)
@@ -638,7 +772,7 @@ def test_materialize_honours_overrides(tmp_path) -> None:
start = _future_monday() start = _future_monday()
start_iso = start.isoformat() start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=6)).isoformat() end_iso = (start + datetime.timedelta(days=6)).isoformat()
# No schedule means the child is present every day; a blocking # No presence pattern means the child is present every day; a blocking
# override covering the whole week must still suppress every day. # override covering the whole week must still suppress every day.
await overrides.create( await overrides.create(
child.id, start_iso, end_iso, False, note="away all week" child.id, start_iso, end_iso, False, note="away all week"
@@ -1623,27 +1757,38 @@ def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None:
from custom_components.nestquest.core.dao_presence import ( from custom_components.nestquest.core.dao_presence import (
PresenceOverridesDao, PresenceOverridesDao,
) )
from custom_components.nestquest.core.presence import PresenceSchedule from custom_components.nestquest.core.presence import PresencePattern
children = ChildrenDao(database) children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database) patterns = PresencePatternsDao(database)
overrides = PresenceOverridesDao(database) overrides = PresenceOverridesDao(database)
a = await children.create("Ada", NOW) # present weeks 0, 2 a = await children.create("Ada", NOW) # present weeks 0, 2
b = await children.create("Bo", NOW) # present weeks 1, 3 b = await children.create("Bo", NOW) # present weeks 1, 3
c = await children.create("Cleo", NOW) # no schedule: always present c = await children.create("Cleo", NOW) # no pattern: always present
anchor = datetime.date.fromisoformat(_E2E_ANCHOR) anchor = datetime.date.fromisoformat(_E2E_ANCHOR)
start_iso = anchor.isoformat() start_iso = anchor.isoformat()
end_iso = (anchor + datetime.timedelta(days=27)).isoformat() end_iso = (anchor + datetime.timedelta(days=27)).isoformat()
# Two-week custody with OPPOSITE weeks, encoded through the # Two-week custody with OPPOSITE weeks, encoded through the
# PresenceSchedule model on the fixed Monday anchor. # PresencePattern model (one ``home`` pattern each) on the fixed
# Monday anchor.
all_week = frozenset(range(7)) all_week = frozenset(range(7))
a_schedule = PresenceSchedule(a.id, 2, anchor, {0: all_week, 1: frozenset()}) a_home = PresencePattern(
b_schedule = PresenceSchedule(b.id, 2, anchor, {0: frozenset(), 1: all_week}) a.id, "Home schedule", "home", 2, anchor,
await schedules.upsert_by_child(a.id, 2, start_iso, a_schedule.encode()) {0: all_week, 1: frozenset()},
await schedules.upsert_by_child(b.id, 2, start_iso, b_schedule.encode()) )
b_home = PresencePattern(
b.id, "Home schedule", "home", 2, anchor,
{0: frozenset(), 1: all_week},
)
await patterns.create(
a.id, a_home.name, a_home.kind, 2, start_iso, a_home.encode()
)
await patterns.create(
b.id, b_home.name, b_home.kind, 2, start_iso, b_home.encode()
)
def_ids = {} def_ids = {}
def_ids["daily"] = (await create_quest_definition( def_ids["daily"] = (await create_quest_definition(
+336 -9
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import datetime
import hashlib import hashlib
import logging import logging
import sqlite3 import sqlite3
@@ -12,6 +13,9 @@ import pytest
from custom_components.nestquest.core.db import NestQuestDatabase from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.schema import SCHEMA_V7_META_STATE_DDL from custom_components.nestquest.core.schema import SCHEMA_V7_META_STATE_DDL
from custom_components.nestquest.core.migrations import ( from custom_components.nestquest.core.migrations import (
MIGRATED_PRESENCE_PATTERN_NAME,
MIGRATION_1_V1_DDL,
MIGRATION_9_PRESENCE_PATTERNS,
MIGRATIONS, MIGRATIONS,
VERSION_TABLE, VERSION_TABLE,
VERSION_TABLE_DDL, VERSION_TABLE_DDL,
@@ -69,7 +73,7 @@ def _counts(database) -> dict[str, int]:
"quest_definitions", "quest_definitions",
"quest_definition_assignees", "quest_definition_assignees",
"quest_definition_windows", "quest_definition_windows",
"presence_schedules", "presence_patterns",
"presence_overrides", "presence_overrides",
"quest_instances", "quest_instances",
"completion_events", "completion_events",
@@ -107,7 +111,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None:
"quest_definitions", "quest_definitions",
"quest_definition_assignees", "quest_definition_assignees",
"quest_definition_windows", "quest_definition_windows",
"presence_schedules", "presence_patterns",
"presence_overrides", "presence_overrides",
"quest_instances", "quest_instances",
"completion_events", "completion_events",
@@ -154,10 +158,26 @@ def test_migration_list_shape() -> None:
model, migration 4 adds the windows table, migration 5 rebuilds model, migration 4 adds the windows table, migration 5 rebuilds
quest_instances onto the widened (definition, child, date, window) quest_instances onto the widened (definition, child, date, window)
key, migration 6 adds actor_child_id to completion_events, key, migration 6 adds actor_child_id to completion_events,
migration 7 adds the meta_state table and migration 8 adds migration 7 adds the meta_state table, migration 8 adds
skip_on_away to quest_definitions. skip_on_away to quest_definitions and migration 9 retires
presence_schedules for presence_patterns.
Migration 1 is the v1 DDL as it shipped: the canonical statements
except that it still creates the retired presence_schedules table
where the canonical list now creates presence_patterns.
""" """
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS assert MIGRATIONS[0] is MIGRATION_1_V1_DDL
assert len(MIGRATION_1_V1_DDL) == len(SCHEMA_V1_STATEMENTS)
differing = [
(shipped, canonical)
for shipped, canonical in zip(MIGRATION_1_V1_DDL, SCHEMA_V1_STATEMENTS)
if shipped != canonical
]
assert len(differing) == 1
shipped, canonical = differing[0]
assert "CREATE TABLE IF NOT EXISTS presence_schedules" in shipped
assert "CREATE TABLE IF NOT EXISTS presence_patterns" in canonical
assert not any("presence_patterns" in sql for sql in MIGRATION_1_V1_DDL)
assert callable(MIGRATIONS[1]) assert callable(MIGRATIONS[1])
assert callable(MIGRATIONS[2]) assert callable(MIGRATIONS[2])
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
@@ -165,7 +185,8 @@ def test_migration_list_shape() -> None:
assert callable(MIGRATIONS[5]) assert callable(MIGRATIONS[5])
assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL
assert callable(MIGRATIONS[7]) assert callable(MIGRATIONS[7])
assert len(MIGRATIONS) == 8 assert MIGRATIONS[8] is MIGRATION_9_PRESENCE_PATTERNS
assert len(MIGRATIONS) == 9
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -474,7 +495,8 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
""" """
# Freeze the version-2 shape: definitions carry child_id, the # Freeze the version-2 shape: definitions carry child_id, the
# instance table has no window column, and the assignees/windows # instance table has no window column, and the assignees/windows
# tables do not exist yet — everything else matches the current DDL. # tables do not exist yet — everything else matches the shipped v1
# DDL (migration 1, which still creates presence_schedules).
_v2_replaced = ( _v2_replaced = (
"quest_definitions ", "quest_definitions ",
"quest_definition_assignees ", "quest_definition_assignees ",
@@ -482,7 +504,7 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
"quest_instances ", "quest_instances ",
"completion_events ", "completion_events ",
) )
for sql in SCHEMA_V1_STATEMENTS: for sql in MIGRATION_1_V1_DDL:
if any(name in sql for name in _v2_replaced): if any(name in sql for name in _v2_replaced):
continue continue
_run(database.execute(sql)) _run(database.execute(sql))
@@ -694,7 +716,7 @@ def test_v7_definitions_gain_skip_on_away_defaulting_true(tmp_path) -> None:
before = _version(database) before = _version(database)
assert before == 7 assert before == 7
final = _migrate(database) final = _migrate(database, MIGRATIONS[:8])
assert final == before + 1 assert final == before + 1
assert _version(database) == before + 1 assert _version(database) == before + 1
@@ -728,6 +750,311 @@ def test_skip_on_away_migration_is_noop_on_fresh_schema(tmp_path) -> None:
_run(database.close()) _run(database.close())
# ---------------------------------------------------------------------------
# Migration 9: presence_schedules -> presence_patterns
# ---------------------------------------------------------------------------
#: Pre-migration presence_schedules rows: (id, child_id, cycle, anchor,
#: pattern). Covers a 1-, 2- and 4-week cycle, an anchor that is not a
#: Monday, and the all-absent empty pattern; child 4 has no schedule.
_V8_SCHEDULES = (
(1, 1, 2, "2026-01-05", "0,1,2,3,4|5,6"),
(2, 2, 1, "2025-12-31", "0,2,4"),
(5, 3, 4, "2024-02-26", "0|1,2|3,4,5|6"),
(7, 5, 1, "2026-03-01", ""),
)
#: Pre-migration overrides: they must keep beating the migrated patterns.
_V8_OVERRIDES = (
(1, "2026-02-02", "2026-02-08", 1),
(4, "2026-02-10", "2026-02-11", 0),
)
def _seed_v8_database(database) -> None:
"""Build a genuine version-8 database holding presence schedules.
Migrations 1..8 run first — migration 1 still creates the retired
presence_schedules table — then five children, the schedules and
overrides above are written through the old table's column list.
"""
assert _migrate(database, MIGRATIONS[:8]) == 8
tables = _tables(database)
assert "presence_schedules" in tables
assert "presence_patterns" not in tables
for child_id in range(1, 6):
_run(
database.execute(
"INSERT INTO children (id, display_name, created_at) "
"VALUES (?, ?, '2026-01-01T00:00:00+00:00')",
(child_id, f"Child {child_id}"),
)
)
for row in _V8_SCHEDULES:
_run(
database.execute(
"INSERT INTO presence_schedules (id, child_id, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (?, ?, ?, ?, ?)",
row,
)
)
for row in _V8_OVERRIDES:
_run(
database.execute(
"INSERT INTO presence_overrides (child_id, start_date, "
"end_date, is_present) VALUES (?, ?, ?, ?)",
row,
)
)
def _v8_presence(database, child_id: int, day: datetime.date) -> bool:
"""Presence as a version-8 build answered it, read from the OLD rows.
The retired single-schedule rule, spelled out independently of the
new engine: a covering override wins; a child with no schedule row
is present; otherwise present iff the weekday is in the segment of
the anchor-arithmetic cycle week.
"""
iso = day.isoformat()
override = _run(
database.fetch_one(
"SELECT is_present FROM presence_overrides WHERE child_id = ? "
"AND start_date <= ? AND end_date >= ? "
"ORDER BY start_date DESC LIMIT 1",
(child_id, iso, iso),
)
)
if override is not None:
return bool(override[0])
row = _run(
database.fetch_one(
"SELECT cycle_length_weeks, anchor_date, pattern "
"FROM presence_schedules WHERE child_id = ?",
(child_id,),
)
)
if row is None:
return True
cycle, anchor, pattern = row
days = (day - datetime.date.fromisoformat(anchor)).days
segment = pattern.split("|")[(days // 7) % cycle]
return str(day.weekday()) in segment.split(",")
def _v9_engine(database):
"""Build the new PresenceEngine from the migrated tables."""
from custom_components.nestquest.core.dao_presence import (
PresenceOverridesDao,
PresencePatternsDao,
)
from custom_components.nestquest.core.presence import (
PresenceEngine,
PresenceOverride,
)
from custom_components.nestquest.core.presence_management import (
decode_pattern_record,
)
patterns = {}
overrides = {}
for child_id in range(1, 6):
records = _run(PresencePatternsDao(database).list_by_child(child_id))
if records:
patterns[child_id] = [decode_pattern_record(r) for r in records]
stored = _run(
PresenceOverridesDao(database).list_filtered(child_id=child_id)
)
if stored:
overrides[child_id] = [
PresenceOverride(
r.child_id, r.start_date, r.end_date, r.is_present
)
for r in stored
]
return PresenceEngine(patterns, overrides)
#: Every day from 2023-12-25 through 2027-01-10: before and after each
#: anchor, across three year boundaries and the 2024 leap day.
_COMPARE_DAYS = [
datetime.date(2023, 12, 25) + datetime.timedelta(days=offset)
for offset in range(
(datetime.date(2027, 1, 10) - datetime.date(2023, 12, 25)).days + 1
)
]
def test_v8_presence_is_unchanged_after_migration_9(tmp_path) -> None:
"""Presence for a pre-migration fixture is identical after migrating.
The old table is built and filled, every child's presence is
recorded day by day under the version-8 rule, the database is
migrated, and the new engine must answer the SAME for every child
(including the one with no schedule and the all-absent one) on
every day of the three-year window.
"""
database = _open_db(tmp_path / "v8-presence.db")
try:
_seed_v8_database(database)
before = {
(child_id, day): _v8_presence(database, child_id, day)
for child_id in range(1, 6)
for day in _COMPARE_DAYS
}
# The fixture must exercise both answers for the scheduled
# children, or "unchanged" would be vacuous.
for child_id in (1, 2, 3):
answers = {
before[(child_id, day)] for day in _COMPARE_DAYS
}
assert answers == {True, False}
assert _migrate(database) == 9
engine = _v9_engine(database)
after = {
(child_id, day): engine.is_present(child_id, day)
for child_id in range(1, 6)
for day in _COMPARE_DAYS
}
changed = [key for key in before if before[key] != after[key]]
assert changed == []
finally:
_run(database.close())
def test_migration_9_copies_every_schedule_verbatim(tmp_path) -> None:
database = _open_db(tmp_path / "v8-copy.db")
try:
_seed_v8_database(database)
assert _migrate(database) == 9
tables = _tables(database)
assert "presence_schedules" not in tables
assert "presence_patterns" in tables
rows = _run(
database.fetch_all(
"SELECT id, child_id, name, kind, cycle_length_weeks, "
"anchor_date, pattern FROM presence_patterns ORDER BY id"
)
)
assert rows == [
(
schedule_id,
child_id,
MIGRATED_PRESENCE_PATTERN_NAME,
"home",
cycle,
anchor,
pattern,
)
for schedule_id, child_id, cycle, anchor, pattern in _V8_SCHEDULES
]
assert MIGRATED_PRESENCE_PATTERN_NAME == "Home schedule"
assert _counts(database)["presence_overrides"] == len(_V8_OVERRIDES)
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
finally:
_run(database.close())
def test_migrated_database_accepts_several_patterns_per_child(
tmp_path,
) -> None:
"""The migrated table drops the retired UNIQUE(child_id)."""
database = _open_db(tmp_path / "v8-multi.db")
try:
_seed_v8_database(database)
_migrate(database)
_run(
database.execute(
"INSERT INTO presence_patterns (child_id, name, kind, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (1, 'Thu+Fri away', 'away', 1, '2026-01-05', '3,4')"
)
)
row = _run(
database.fetch_one(
"SELECT COUNT(*) FROM presence_patterns WHERE child_id = 1"
)
)
assert row == (2,)
finally:
_run(database.close())
def test_fresh_file_walks_through_and_retires_presence_schedules(
tmp_path,
) -> None:
"""Migration 1 still makes the old table; migration 9 retires it."""
database = _open_db(tmp_path / "fresh-v9.db")
try:
assert _migrate(database, MIGRATIONS[:8]) == 8
assert "presence_schedules" in _tables(database)
assert "presence_patterns" not in _tables(database)
assert _migrate(database) == 9
assert "presence_schedules" not in _tables(database)
assert _counts(database)["presence_patterns"] == 0
finally:
_run(database.close())
def test_migrated_presence_patterns_match_the_canonical_ddl(tmp_path) -> None:
"""A migrated table and one made from schema.py are the same shape."""
migrated = _open_db(tmp_path / "migrated-shape.db")
canonical = _open_db(tmp_path / "canonical-shape.db")
try:
_migrate(migrated)
for sql in SCHEMA_V1_STATEMENTS:
_run(canonical.execute(sql))
for pragma in ("table_info", "foreign_key_list", "index_list"):
assert _run(
migrated.fetch_all(f"PRAGMA {pragma}(presence_patterns)")
) == _run(
canonical.fetch_all(f"PRAGMA {pragma}(presence_patterns)")
)
finally:
_run(migrated.close())
_run(canonical.close())
def test_failed_migration_9_keeps_the_schedules(tmp_path) -> None:
"""Migration 9 is atomic: a failure after the copy loses nothing.
The real migration 9 statements are followed by a broken one in
the SAME step, so the create, the copy and the drop have all run
when it fails — the runner's one transaction must roll every one
of them back together with the stamp.
"""
database = _open_db(tmp_path / "v8-atomic.db")
try:
_seed_v8_database(database)
broken = [
*MIGRATIONS[:8],
[*MIGRATION_9_PRESENCE_PATTERNS, "CREATE TABLE broken ("],
]
with pytest.raises(sqlite3.OperationalError):
_migrate(database, broken)
assert _version(database) == 8
tables = _tables(database)
assert "presence_patterns" not in tables
rows = _run(
database.fetch_all(
"SELECT id, child_id, cycle_length_weeks, anchor_date, "
"pattern FROM presence_schedules ORDER BY id"
)
)
assert rows == list(_V8_SCHEDULES)
assert _migrate(database) == 9
assert _counts(database)["presence_patterns"] == len(_V8_SCHEDULES)
finally:
_run(database.close())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Idempotence: second start is a no-op # Idempotence: second start is a no-op
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+319 -136
View File
@@ -7,133 +7,173 @@ import pytest
from custom_components.nestquest.core.presence import ( from custom_components.nestquest.core.presence import (
MAX_CYCLE_LENGTH_WEEKS, MAX_CYCLE_LENGTH_WEEKS,
PATTERN_KINDS,
PresenceEngine, PresenceEngine,
PresenceOverride, PresenceOverride,
PresenceSchedule, PresencePattern,
cycle_week_index, cycle_week_index,
) )
ANCHOR = datetime.date(2026, 1, 5) # a Monday ANCHOR = datetime.date(2026, 1, 5) # a Monday
#: Every pre-schema-9 single schedule is exactly one ``home`` pattern
#: (migration 9 carries them across that way), so the model and engine
#: tests below use this name/kind unless they are about name or kind.
NAME = "Home schedule"
HOME = "home"
TWO_WEEK_PATTERN = {0: {0, 2, 4}, 1: {1, 3}} TWO_WEEK_PATTERN = {0: {0, 2, 4}, 1: {1, 3}}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# PresenceSchedule construction # PresencePattern construction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_schedule_holds_validated_fields() -> None: def test_pattern_holds_validated_fields() -> None:
schedule = PresenceSchedule( pattern = PresencePattern(
3, 2, "2026-01-05", {0: {0, 2, 4}, 1: {1, 3}} 3, "Dad's weekends", "away", 2, "2026-01-05",
{0: {0, 2, 4}, 1: {1, 3}},
) )
assert schedule.child_id == 3 assert pattern.child_id == 3
assert schedule.cycle_length_weeks == 2 assert pattern.name == "Dad's weekends"
assert schedule.anchor_date == ANCHOR assert pattern.kind == "away"
assert schedule.pattern == {0: frozenset({0, 2, 4}), 1: frozenset({1, 3})} assert pattern.cycle_length_weeks == 2
assert pattern.anchor_date == ANCHOR
assert pattern.pattern == {0: frozenset({0, 2, 4}), 1: frozenset({1, 3})}
def test_schedule_accepts_date_objects() -> None: def test_pattern_accepts_date_objects() -> None:
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) pattern = PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}})
assert schedule.anchor_date is ANCHOR assert pattern.anchor_date is ANCHOR
def test_schedule_rejects_cycle_length_below_one() -> None: def test_pattern_name_is_stored_trimmed() -> None:
pattern = PresencePattern(1, " Mum's week \t", HOME, 1, ANCHOR, {0: {0}})
assert pattern.name == "Mum's week"
@pytest.mark.parametrize("bad", ["", " ", "\t\n"])
def test_pattern_rejects_blank_name(bad) -> None:
with pytest.raises(ValueError, match="name must not be empty"):
PresencePattern(1, bad, HOME, 1, ANCHOR, {0: {0}})
@pytest.mark.parametrize("bad", [None, 5, b"Home", ["Home"]])
def test_pattern_rejects_non_string_name(bad) -> None:
with pytest.raises(ValueError, match="name must be a string"):
PresencePattern(1, bad, HOME, 1, ANCHOR, {0: {0}})
def test_pattern_kinds_are_home_and_away() -> None:
"""The model's kinds match the schema's CHECK exactly."""
assert PATTERN_KINDS == ("home", "away")
for kind in PATTERN_KINDS:
assert PresencePattern(1, NAME, kind, 1, ANCHOR, {0: {0}}).kind == kind
@pytest.mark.parametrize("bad", ["Home", "AWAY", "present", "", None, True, 1])
def test_pattern_rejects_unknown_kind(bad) -> None:
with pytest.raises(ValueError, match="kind must be one of home, away"):
PresencePattern(1, NAME, bad, 1, ANCHOR, {0: {0}})
def test_pattern_rejects_cycle_length_below_one() -> None:
with pytest.raises(ValueError, match="between 1 and"): with pytest.raises(ValueError, match="between 1 and"):
PresenceSchedule(1, 0, ANCHOR, {}) PresencePattern(1, NAME, HOME, 0, ANCHOR, {})
with pytest.raises(ValueError, match="between 1 and"): with pytest.raises(ValueError, match="between 1 and"):
PresenceSchedule(1, -1, ANCHOR, {}) PresencePattern(1, NAME, HOME, -1, ANCHOR, {})
def test_schedule_rejects_cycle_length_above_documented_cap() -> None: def test_pattern_rejects_cycle_length_above_documented_cap() -> None:
"""The 4 cap matches the schema's CHECK — both documents agree.""" """The 4 cap matches the schema's CHECK — both documents agree."""
with pytest.raises(ValueError, match="between 1 and"): with pytest.raises(ValueError, match="between 1 and"):
PresenceSchedule(1, MAX_CYCLE_LENGTH_WEEKS + 1, ANCHOR, {}) PresencePattern(1, NAME, HOME, MAX_CYCLE_LENGTH_WEEKS + 1, ANCHOR, {})
assert MAX_CYCLE_LENGTH_WEEKS == 4 assert MAX_CYCLE_LENGTH_WEEKS == 4
@pytest.mark.parametrize("bad", [True, "2", 1.0, None]) @pytest.mark.parametrize("bad", [True, "2", 1.0, None])
def test_schedule_rejects_non_int_cycle_length(bad) -> None: def test_pattern_rejects_non_int_cycle_length(bad) -> None:
with pytest.raises(ValueError, match="cycle_length_weeks must be an integer"): with pytest.raises(ValueError, match="cycle_length_weeks must be an integer"):
PresenceSchedule(1, bad, ANCHOR, {0: {0}}) PresencePattern(1, NAME, HOME, bad, ANCHOR, {0: {0}})
def test_schedule_rejects_pattern_missing_week_indices() -> None: def test_pattern_rejects_pattern_missing_week_indices() -> None:
with pytest.raises(ValueError, match="missing \\[1\\]"): with pytest.raises(ValueError, match="missing \\[1\\]"):
PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}}) PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}})
def test_schedule_rejects_pattern_with_extra_week_indices() -> None: def test_pattern_rejects_pattern_with_extra_week_indices() -> None:
with pytest.raises(ValueError, match="week index must be in 0"): with pytest.raises(ValueError, match="week index must be in 0"):
PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}, 2: {2}}) PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0}, 1: {1}, 2: {2}})
def test_schedule_rejects_pattern_with_non_int_week_keys() -> None: def test_pattern_rejects_pattern_with_non_int_week_keys() -> None:
with pytest.raises(ValueError, match="week index must be an integer"): with pytest.raises(ValueError, match="week index must be an integer"):
PresenceSchedule(1, 1, ANCHOR, {"0": {0}}) PresencePattern(1, NAME, HOME, 1, ANCHOR, {"0": {0}})
def test_schedule_rejects_pattern_with_bool_week_key() -> None: def test_pattern_rejects_pattern_with_bool_week_key() -> None:
"""True would silently be week 1; bools are never valid indices.""" """True would silently be week 1; bools are never valid indices."""
with pytest.raises(ValueError, match="week index must be an integer"): with pytest.raises(ValueError, match="week index must be an integer"):
PresenceSchedule(1, 2, ANCHOR, {0: {0}, True: {1}}) PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0}, True: {1}})
@pytest.mark.parametrize("bad_weekday", [-1, 7, 100]) @pytest.mark.parametrize("bad_weekday", [-1, 7, 100])
def test_schedule_rejects_weekday_outside_zero_to_six(bad_weekday) -> None: def test_pattern_rejects_weekday_outside_zero_to_six(bad_weekday) -> None:
with pytest.raises(ValueError, match="weekday must be in 0..6"): with pytest.raises(ValueError, match="weekday must be in 0..6"):
PresenceSchedule(1, 1, ANCHOR, {0: {bad_weekday}}) PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {bad_weekday}})
@pytest.mark.parametrize("bad", [True, "1", 1.5, None]) @pytest.mark.parametrize("bad", [True, "1", 1.5, None])
def test_schedule_rejects_non_int_weekdays(bad) -> None: def test_pattern_rejects_non_int_weekdays(bad) -> None:
with pytest.raises(ValueError, match="weekday must be an int"): with pytest.raises(ValueError, match="weekday must be an int"):
PresenceSchedule(1, 1, ANCHOR, {0: [bad]}) PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: [bad]})
def test_schedule_rejects_string_and_none_week_sets() -> None: def test_pattern_rejects_string_and_none_week_sets() -> None:
"""A string is iterable — "02" would silently mean {0, 2}.""" """A string is iterable — "02" would silently mean {0, 2}."""
with pytest.raises(ValueError, match="iterable of weekday ints"): with pytest.raises(ValueError, match="iterable of weekday ints"):
PresenceSchedule(1, 1, ANCHOR, {0: "02"}) PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: "02"})
with pytest.raises(ValueError, match="iterable of weekday ints"): with pytest.raises(ValueError, match="iterable of weekday ints"):
PresenceSchedule(1, 1, ANCHOR, {0: None}) PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: None})
def test_schedule_rejects_non_dict_pattern() -> None: def test_pattern_rejects_non_dict_pattern() -> None:
with pytest.raises(ValueError, match="must map week index"): with pytest.raises(ValueError, match="must map week index"):
PresenceSchedule(1, 1, ANCHOR, "0,2") PresencePattern(1, NAME, HOME, 1, ANCHOR, "0,2")
@pytest.mark.parametrize("bad", [True, 1.0, "2026-01-05", None]) @pytest.mark.parametrize("bad", [True, 1.0, "2026-01-05", None])
def test_schedule_rejects_non_int_child_id(bad) -> None: def test_pattern_rejects_non_int_child_id(bad) -> None:
with pytest.raises(ValueError, match="child_id must be an integer"): with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceSchedule(bad, 1, ANCHOR, {0: {0}}) PresencePattern(bad, NAME, HOME, 1, ANCHOR, {0: {0}})
@pytest.mark.parametrize( @pytest.mark.parametrize(
"bad", ["2026-1-5", "2026/01/05", "not-a-date", 5, None, True] "bad", ["2026-1-5", "2026/01/05", "not-a-date", 5, None, True]
) )
def test_schedule_rejects_non_strict_anchor_dates(bad) -> None: def test_pattern_rejects_non_strict_anchor_dates(bad) -> None:
with pytest.raises(ValueError, match="anchor_date"): with pytest.raises(ValueError, match="anchor_date"):
PresenceSchedule(1, 1, bad, {0: {0}}) PresencePattern(1, NAME, HOME, 1, bad, {0: {0}})
def test_schedule_empty_week_means_absent_week() -> None: def test_pattern_empty_week_means_absent_week() -> None:
"""An explicitly empty week is valid: absent every day of it — """An explicitly empty week is valid: absent every day of it —
distinct from having no schedule (the engine's default).""" distinct from having no pattern (the engine's default)."""
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()}) pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}, 1: set()})
assert schedule.pattern[1] == frozenset() assert pattern.pattern[1] == frozenset()
def test_schedule_empty_week_encodes_as_empty_segment() -> None: def test_pattern_empty_week_encodes_as_empty_segment() -> None:
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()}) pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}, 1: set()})
assert schedule.encode() == "0,2|" assert pattern.encode() == "0,2|"
def test_schedule_equal_patterns_are_equal_and_encode_identically() -> None: def test_pattern_equal_patterns_are_equal_and_encode_identically() -> None:
one = PresenceSchedule(1, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}}) one = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}})
two = PresenceSchedule(2, 2, ANCHOR, {0: {0, 2, 4}, 1: {1, 3}}) two = PresencePattern(2, NAME, HOME, 2, ANCHOR, {0: {0, 2, 4}, 1: {1, 3}})
assert one.encode() == two.encode() == "0,2,4|1,3" assert one.encode() == two.encode() == "0,2,4|1,3"
@@ -154,34 +194,60 @@ def test_schedule_equal_patterns_are_equal_and_encode_identically() -> None:
"0,1,2,3,4,5,6|", "0,1,2,3,4,5,6|",
], ],
) )
def test_schedule_decode_encode_round_trips_losslessly(encoded) -> None: def test_pattern_decode_encode_round_trips_losslessly(encoded) -> None:
schedule = PresenceSchedule.decode(1, ANCHOR, encoded) pattern = PresencePattern.decode(1, NAME, HOME, ANCHOR, encoded)
assert schedule.encode() == encoded assert pattern.encode() == encoded
again = PresenceSchedule.decode(1, ANCHOR, schedule.encode()) again = PresencePattern.decode(1, NAME, HOME, ANCHOR, pattern.encode())
assert again == schedule assert again == pattern
def test_schedule_decode_sets_cycle_length_from_segments() -> None: def test_pattern_decode_sets_cycle_length_from_segments() -> None:
one_week = PresenceSchedule.decode(1, ANCHOR, "0,2,4") one_week = PresencePattern.decode(1, NAME, HOME, ANCHOR, "0,2,4")
assert one_week.cycle_length_weeks == 1 assert one_week.cycle_length_weeks == 1
four_week = PresenceSchedule.decode(1, ANCHOR, "0|1|2|3,4") four_week = PresencePattern.decode(1, NAME, HOME, ANCHOR, "0|1|2|3,4")
assert four_week.cycle_length_weeks == 4 assert four_week.cycle_length_weeks == 4
def test_schedule_decode_rejects_malformed_segments() -> None: def test_pattern_decode_rejects_malformed_segments() -> None:
for bad in ("0,12|1", "0,,2|1", "x|1", "0|-1|2", "8", "0,|1"): for bad in ("0,12|1", "0,,2|1", "x|1", "0|-1|2", "8", "0,|1"):
with pytest.raises(ValueError, match="invalid weekday"): with pytest.raises(ValueError, match="invalid weekday"):
PresenceSchedule.decode(1, ANCHOR, bad) PresencePattern.decode(1, NAME, HOME, ANCHOR, bad)
def test_schedule_decode_rejects_non_string() -> None: def test_pattern_decode_rejects_non_string() -> None:
with pytest.raises(ValueError, match="must be a string"): with pytest.raises(ValueError, match="must be a string"):
PresenceSchedule.decode(1, ANCHOR, None) PresencePattern.decode(1, NAME, HOME, ANCHOR, None)
def test_schedule_decode_rejects_too_many_segments(tmp_path) -> None: def test_pattern_decode_rejects_too_many_segments(tmp_path) -> None:
with pytest.raises(ValueError, match="between 1 and"): with pytest.raises(ValueError, match="between 1 and"):
PresenceSchedule.decode(1, ANCHOR, "0|1|2|3|4") PresencePattern.decode(1, NAME, HOME, ANCHOR, "0|1|2|3|4")
def test_pattern_decode_carries_and_validates_name_and_kind() -> None:
"""decode is ordinary construction: name trimmed, kind checked."""
decoded = PresencePattern.decode(1, " Away days ", "away", ANCHOR, "3,4")
assert (decoded.name, decoded.kind) == ("Away days", "away")
with pytest.raises(ValueError, match="name must not be empty"):
PresencePattern.decode(1, " ", "away", ANCHOR, "3,4")
with pytest.raises(ValueError, match="kind must be one of"):
PresencePattern.decode(1, NAME, "absent", ANCHOR, "3,4")
def test_pattern_covers_follows_the_cycle_week() -> None:
"""covers() is the weekday-in-this-cycle-week test, independent of
kind: an away pattern covers its days exactly like a home one."""
for kind in PATTERN_KINDS:
pattern = PresencePattern(1, NAME, kind, 2, ANCHOR, {0: {1, 3}, 1: {2}})
# Tue Jan 6: week 0, weekday 1 -> covered.
assert pattern.covers(datetime.date(2026, 1, 6)) is True
# Wed Jan 7: week 0, weekday 2 -> not covered.
assert pattern.covers(datetime.date(2026, 1, 7)) is False
# Wed Jan 14: week 1, weekday 2 -> covered.
assert pattern.covers(datetime.date(2026, 1, 14)) is True
# Tue Dec 30 2025: before the anchor, walks back into week 1.
assert pattern.covers(datetime.date(2025, 12, 30)) is False
assert pattern.covers(datetime.date(2025, 12, 31)) is True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -231,11 +297,11 @@ def test_override_rejects_non_strict_dates(start, end) -> None:
PresenceOverride(1, start, end, True) PresenceOverride(1, start, end, True)
def test_schedule_rejects_datetime_anchor() -> None: def test_pattern_rejects_datetime_anchor() -> None:
"""datetime.datetime subclasses date: a bare isinstance check would """datetime.datetime subclasses date: a bare isinstance check would
store a time component and every date comparison would TypeError.""" store a time component and every date comparison would TypeError."""
with pytest.raises(ValueError, match="plain calendar date"): with pytest.raises(ValueError, match="plain calendar date"):
PresenceSchedule(1, 1, datetime.datetime(2026, 1, 5, 9, 30), {0: {0}}) PresencePattern(1, NAME, HOME, 1, datetime.datetime(2026, 1, 5, 9, 30), {0: {0}})
def test_override_rejects_datetime_bounds() -> None: def test_override_rejects_datetime_bounds() -> None:
@@ -245,23 +311,23 @@ def test_override_rejects_datetime_bounds() -> None:
) )
def test_schedule_pattern_is_immutable_after_construction() -> None: def test_pattern_pattern_is_immutable_after_construction() -> None:
"""The frozen schedule must not leak a mutable dict: injecting """The frozen pattern must not leak a mutable dict: injecting
unvalidated weekdays or dropping required weeks after construction unvalidated weekdays or dropping required weeks after construction
would bypass total validation.""" would bypass total validation."""
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: {1}}) pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}, 1: {1}})
with pytest.raises(TypeError): with pytest.raises(TypeError):
schedule.pattern[0] = {9} pattern.pattern[0] = {9}
with pytest.raises(TypeError): with pytest.raises(TypeError):
del schedule.pattern[1] del pattern.pattern[1]
# mappingproxy exposes no mutators at all. # mappingproxy exposes no mutators at all.
assert not any( assert not any(
hasattr(schedule.pattern, name) hasattr(pattern.pattern, name)
for name in ("clear", "pop", "popitem", "setdefault", "update") for name in ("clear", "pop", "popitem", "setdefault", "update")
) )
# The validated contents are still readable and encode correctly. # The validated contents are still readable and encode correctly.
assert schedule.pattern[0] == frozenset({0, 2}) assert pattern.pattern[0] == frozenset({0, 2})
assert schedule.encode() == "0,2|1" assert pattern.encode() == "0,2|1"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -270,13 +336,13 @@ def test_schedule_pattern_is_immutable_after_construction() -> None:
def test_cycle_week_index_follows_the_anchor_formula() -> None: def test_cycle_week_index_follows_the_anchor_formula() -> None:
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}}) pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0}, 1: {1}})
# The anchor day starts week 0; each whole 7 days steps the cycle. # The anchor day starts week 0; each whole 7 days steps the cycle.
offsets = {0: 0, 1: 0, 6: 0, 7: 1, 8: 1, 13: 1, 14: 0, 15: 0, 22: 1} offsets = {0: 0, 1: 0, 6: 0, 7: 1, 8: 1, 13: 1, 14: 0, 15: 0, 22: 1}
for offset, expected in offsets.items(): for offset, expected in offsets.items():
assert ( assert (
cycle_week_index( cycle_week_index(
schedule, ANCHOR + datetime.timedelta(days=offset) pattern, ANCHOR + datetime.timedelta(days=offset)
) )
== expected == expected
), f"offset {offset}" ), f"offset {offset}"
@@ -285,18 +351,18 @@ def test_cycle_week_index_follows_the_anchor_formula() -> None:
def test_cycle_week_index_before_the_anchor_walks_backwards() -> None: def test_cycle_week_index_before_the_anchor_walks_backwards() -> None:
"""Python floor division: day -1 is the LAST week of the cycle, """Python floor division: day -1 is the LAST week of the cycle,
wrapping backwards — never inverting.""" wrapping backwards — never inverting."""
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}}) pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0}, 1: {1}})
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=1)) == 1 assert cycle_week_index(pattern, ANCHOR - datetime.timedelta(days=1)) == 1
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=7)) == 1 assert cycle_week_index(pattern, ANCHOR - datetime.timedelta(days=7)) == 1
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=8)) == 0 assert cycle_week_index(pattern, ANCHOR - datetime.timedelta(days=8)) == 0
def test_cycle_week_index_three_week_pattern() -> None: def test_cycle_week_index_three_week_pattern() -> None:
schedule = PresenceSchedule( pattern = PresencePattern(
1, 3, ANCHOR, {0: {0}, 1: {1}, 2: {2}} 1, NAME, HOME, 3, ANCHOR, {0: {0}, 1: {1}, 2: {2}}
) )
offsets = [0, 7, 14, 21, 28] offsets = [0, 7, 14, 21, 28]
assert [cycle_week_index(schedule, ANCHOR + datetime.timedelta(days=o)) for o in offsets] == [ assert [cycle_week_index(pattern, ANCHOR + datetime.timedelta(days=o)) for o in offsets] == [
0, 1, 2, 0, 1 0, 1, 2, 0, 1
] ]
@@ -307,8 +373,8 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None:
or +1 (mod cycle length) and NEVER resets or inverts — including or +1 (mod cycle length) and NEVER resets or inverts — including
2020, a 53-ISO-week year, where ISO week parity flips and would 2020, a 53-ISO-week year, where ISO week parity flips and would
invert a two-week custody schedule on January 1st.""" invert a two-week custody schedule on January 1st."""
schedule = PresenceSchedule( pattern = PresencePattern(
1, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}} 1, NAME, HOME, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}}
) )
# Independently derived expectations (anchor 2019-01-06, index = # Independently derived expectations (anchor 2019-01-06, index =
# floor(days-since-anchor / 7) % 2, hand-verified) — hardcoded so # floor(days-since-anchor / 7) % 2, hand-verified) — hardcoded so
@@ -357,7 +423,7 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None:
datetime.date(2022, 1, 7): 0, datetime.date(2022, 1, 7): 0,
} }
for day, wanted in expected.items(): for day, wanted in expected.items():
got = cycle_week_index(schedule, day) got = cycle_week_index(pattern, day)
assert got == wanted, f"{day}: expected week {wanted}, got {got}" assert got == wanted, f"{day}: expected week {wanted}, got {got}"
# And 2020 really is the 53-ISO-week year this guards against. # And 2020 really is the 53-ISO-week year this guards against.
assert datetime.date(2020, 12, 31).isocalendar()[1] == 53 assert datetime.date(2020, 12, 31).isocalendar()[1] == 53
@@ -388,21 +454,22 @@ def test_module_uses_no_iso_week_functions() -> None:
def _three_child_engine() -> PresenceEngine: def _three_child_engine() -> PresenceEngine:
"""Two children on opposite weeks of one two-week rotation, one """Two children on opposite weeks of one two-week rotation (each a
with no schedule at all (the always-present sibling).""" single ``home`` pattern), one with no pattern at all (the
always-present sibling)."""
anchor = datetime.date(2026, 1, 5) # Monday, week 0 anchor = datetime.date(2026, 1, 5) # Monday, week 0
declan = PresenceSchedule( declan = PresencePattern(
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} 1, NAME, HOME, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
) )
jordyn = PresenceSchedule( jordyn = PresencePattern(
2, 2, anchor, {0: set(), 1: {0, 1, 2, 3, 4, 5, 6}} 2, NAME, HOME, 2, anchor, {0: set(), 1: {0, 1, 2, 3, 4, 5, 6}}
) )
return PresenceEngine({1: declan, 2: jordyn}) return PresenceEngine({1: [declan], 2: [jordyn]})
def test_engine_child_without_schedule_is_always_present() -> None: def test_engine_child_without_pattern_is_always_present() -> None:
"""Every day of the four-week span, all three children get the """Every day of the four-week span, all three children get the
expected verdict: Chloe (no schedule) always present; the two expected verdict: Chloe (no pattern) always present; the two
rotation children complementary per the anchor pattern.""" rotation children complementary per the anchor pattern."""
engine = _three_child_engine() engine = _three_child_engine()
anchor = datetime.date(2026, 1, 5) anchor = datetime.date(2026, 1, 5)
@@ -446,9 +513,12 @@ def test_engine_weekend_absence_respects_the_pattern() -> None:
child's present week.""" child's present week."""
engine = PresenceEngine( engine = PresenceEngine(
{ {
1: PresenceSchedule( 1: [
1, 2, datetime.date(2026, 1, 5), {0: {1, 3}, 1: {2, 4}} PresencePattern(
) 1, NAME, HOME, 2, datetime.date(2026, 1, 5),
{0: {1, 3}, 1: {2, 4}},
)
]
} }
) )
# 2026-01-06 (Tue) is week 0, weekday 1 -> present. # 2026-01-06 (Tue) is week 0, weekday 1 -> present.
@@ -460,18 +530,24 @@ def test_engine_weekend_absence_respects_the_pattern() -> None:
def test_engine_rejects_bad_construction_and_inputs() -> None: def test_engine_rejects_bad_construction_and_inputs() -> None:
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) pattern = PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}})
with pytest.raises(ValueError, match="must map child_id"): with pytest.raises(ValueError, match="must map child_id"):
PresenceEngine([schedule]) PresenceEngine([pattern])
with pytest.raises(ValueError, match="must be a PresenceSchedule"): # Values are LISTS now: a bare pattern (the retired one-schedule
PresenceEngine({1: "not-a-schedule"}) # shape) is refused rather than silently iterated.
with pytest.raises(ValueError, match="holds the schedule"): with pytest.raises(ValueError, match="must be a list of PresencePattern"):
PresenceEngine({2: schedule}) PresenceEngine({1: pattern})
with pytest.raises(ValueError, match="must be a list of PresencePattern"):
PresenceEngine({1: (pattern,)})
with pytest.raises(ValueError, match="entries must be PresencePattern"):
PresenceEngine({1: ["not-a-pattern"]})
with pytest.raises(ValueError, match="holds a pattern of child 1"):
PresenceEngine({2: [pattern]})
with pytest.raises(ValueError, match="child_id must be an integer"): with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceEngine({True: schedule}) PresenceEngine({True: [pattern]})
with pytest.raises(ValueError, match="child_id must be an integer"): with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceEngine({1.0: PresenceSchedule(1, 1, ANCHOR, {0: {0}})}) PresenceEngine({1.0: [PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}})]})
engine = PresenceEngine({1: schedule}) engine = PresenceEngine({1: [pattern]})
with pytest.raises(ValueError, match="child_id must be an integer"): with pytest.raises(ValueError, match="child_id must be an integer"):
engine.is_present(True, ANCHOR) engine.is_present(True, ANCHOR)
with pytest.raises(ValueError, match="plain calendar date"): with pytest.raises(ValueError, match="plain calendar date"):
@@ -481,11 +557,111 @@ def test_engine_rejects_bad_construction_and_inputs() -> None:
def test_engine_is_immutable() -> None: def test_engine_is_immutable() -> None:
engine = _three_child_engine() engine = _three_child_engine()
with pytest.raises(AttributeError, match="immutable snapshot"): with pytest.raises(AttributeError, match="immutable snapshot"):
engine._schedules = {} engine._patterns = {}
with pytest.raises(AttributeError, match="immutable snapshot"): with pytest.raises(AttributeError, match="immutable snapshot"):
engine.something_new = 1 engine.something_new = 1
# ---------------------------------------------------------------------------
# PresenceEngine: combining several patterns per child
# ---------------------------------------------------------------------------
def _pattern(kind: str, pattern: dict, *, cycle: int = 1) -> PresencePattern:
return PresencePattern(1, f"{kind} pattern", kind, cycle, ANCHOR, pattern)
def test_engine_empty_pattern_list_is_always_present() -> None:
"""An empty list is the same as no entry: present every day."""
engine = PresenceEngine({1: []})
for offset in range(14):
day = ANCHOR + datetime.timedelta(days=offset)
assert engine.is_present(1, day) is True, f"offset {offset}"
def test_engine_away_only_child_is_present_except_covered_days() -> None:
"""Away patterns alone never make an uncovered day absent: the
household default (present) holds outside their coverage."""
engine = PresenceEngine({1: [_pattern("away", {0: {3, 4}})]})
for offset in range(14):
day = ANCHOR + datetime.timedelta(days=offset)
expected = day.weekday() not in {3, 4}
assert engine.is_present(1, day) is expected, f"{day}"
def test_engine_several_home_patterns_union_their_days() -> None:
"""Any covering home pattern makes the day present; a day no home
pattern covers is absent once the child has a home pattern."""
engine = PresenceEngine(
{
1: [
_pattern("home", {0: {0, 1}}),
_pattern("home", {0: {4}, 1: set()}, cycle=2),
]
}
)
# Week 0 (Jan 5-11): Mon, Tue from the first, Fri from the second.
present = {
ANCHOR + datetime.timedelta(days=offset)
for offset in range(7)
if engine.is_present(1, ANCHOR + datetime.timedelta(days=offset))
}
assert present == {
datetime.date(2026, 1, 5),
datetime.date(2026, 1, 6),
datetime.date(2026, 1, 9),
}
# Week 1: the second pattern's empty week leaves only Mon/Tue.
assert engine.is_present(1, datetime.date(2026, 1, 16)) is False
assert engine.is_present(1, datetime.date(2026, 1, 12)) is True
def test_engine_away_pattern_beats_covering_home_pattern() -> None:
"""Rule order: a covering away pattern wins over a covering home
pattern, whichever is listed first."""
home = _pattern("home", {0: {0, 1, 2, 3, 4, 5, 6}})
away = _pattern("away", {0: {5, 6}})
for patterns in ([home, away], [away, home]):
engine = PresenceEngine({1: patterns})
assert engine.is_present(1, datetime.date(2026, 1, 9)) is True # Fri
assert engine.is_present(1, datetime.date(2026, 1, 10)) is False # Sat
assert engine.is_present(1, datetime.date(2026, 1, 11)) is False # Sun
def test_engine_home_pattern_makes_uncovered_days_absent_even_with_away() -> None:
"""Mixing kinds: a day neither pattern covers is absent because the
child has a home pattern (rule 4), not present."""
engine = PresenceEngine(
{1: [_pattern("home", {0: {0, 1}}), _pattern("away", {0: {5}})]}
)
assert engine.is_present(1, datetime.date(2026, 1, 5)) is True # Mon
assert engine.is_present(1, datetime.date(2026, 1, 7)) is False # Wed
assert engine.is_present(1, datetime.date(2026, 1, 10)) is False # Sat
def test_engine_override_beats_away_pattern() -> None:
engine = PresenceEngine(
{1: [_pattern("away", {0: {5, 6}})]},
overrides={
1: [PresenceOverride(1, "2026-01-10", "2026-01-10", True)]
},
)
assert engine.is_present(1, datetime.date(2026, 1, 10)) is True
assert engine.is_present(1, datetime.date(2026, 1, 11)) is False
def test_engine_pattern_lists_are_frozen() -> None:
"""Mutating the caller's pattern list after construction must not
change the engine's answers."""
patterns = [_pattern("home", {0: {0}})]
engine = PresenceEngine({1: patterns})
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
patterns.append(_pattern("home", {0: {1}}))
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
with pytest.raises(AttributeError):
engine._patterns[1].append(patterns[1])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Overrides take precedence over the pattern # Overrides take precedence over the pattern
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -495,9 +671,12 @@ def _engine_with_overrides(*overrides_for_declan) -> PresenceEngine:
anchor = datetime.date(2026, 1, 5) # Monday anchor = datetime.date(2026, 1, 5) # Monday
return PresenceEngine( return PresenceEngine(
{ {
1: PresenceSchedule( 1: [
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} PresencePattern(
) 1, NAME, HOME, 2, anchor,
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
)
]
}, },
overrides={1: list(overrides_for_declan)} if overrides_for_declan else {}, overrides={1: list(overrides_for_declan)} if overrides_for_declan else {},
) )
@@ -523,8 +702,8 @@ def test_present_override_on_normally_absent_day_wins() -> None:
assert engine.is_present(1, datetime.date(2026, 1, 14)) is False assert engine.is_present(1, datetime.date(2026, 1, 14)) is False
def test_override_applies_without_a_schedule() -> None: def test_override_applies_without_a_pattern() -> None:
"""An override on a schedule-free child still applies: holidays and """An override on a pattern-free child still applies: holidays and
swaps must work for the always-present sibling too.""" swaps must work for the always-present sibling too."""
engine = PresenceEngine( engine = PresenceEngine(
{}, {},
@@ -554,18 +733,18 @@ def test_override_range_beats_pattern_for_its_span() -> None:
def test_engine_override_snapshot_validation() -> None: def test_engine_override_snapshot_validation() -> None:
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) patterns = [PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}})]
entry = PresenceOverride(1, "2026-07-20", "2026-07-20", True) entry = PresenceOverride(1, "2026-07-20", "2026-07-20", True)
with pytest.raises(ValueError, match="must map child_id"): with pytest.raises(ValueError, match="must map child_id"):
PresenceEngine({1: schedule}, overrides=[entry]) PresenceEngine({1: patterns}, overrides=[entry])
with pytest.raises(ValueError, match="must be a list"): with pytest.raises(ValueError, match="must be a list"):
PresenceEngine({1: schedule}, overrides={1: entry}) PresenceEngine({1: patterns}, overrides={1: entry})
with pytest.raises(ValueError, match="must be"): with pytest.raises(ValueError, match="must be"):
PresenceEngine({1: schedule}, overrides={1: ["nope"]}) PresenceEngine({1: patterns}, overrides={1: ["nope"]})
with pytest.raises(ValueError, match="holds an override"): with pytest.raises(ValueError, match="holds an override"):
PresenceEngine({1: schedule}, overrides={2: [entry]}) PresenceEngine({1: patterns}, overrides={2: [entry]})
with pytest.raises(ValueError, match="child_id must be an integer"): with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceEngine({1: schedule}, overrides={True: [entry]}) PresenceEngine({1: patterns}, overrides={True: [entry]})
def test_engine_overlapping_snapshot_uses_latest_start() -> None: def test_engine_overlapping_snapshot_uses_latest_start() -> None:
@@ -591,11 +770,11 @@ def test_engine_override_lists_are_frozen() -> None:
frozen snapshot it must not.""" frozen snapshot it must not."""
entry = PresenceOverride(1, "2026-01-06", "2026-01-06", False) entry = PresenceOverride(1, "2026-01-06", "2026-01-06", False)
entries = [entry] entries = [entry]
schedule = PresenceSchedule( pattern = PresencePattern(
1, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} 1, NAME, HOME, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
) )
engine = PresenceEngine( engine = PresenceEngine(
{1: schedule}, overrides={1: entries} # caller-owned list {1: [pattern]}, overrides={1: entries} # caller-owned list
) )
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False)) entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False))
@@ -620,10 +799,12 @@ def _preview_engine() -> PresenceEngine:
would matter only for the override test.""" would matter only for the override test."""
return PresenceEngine( return PresenceEngine(
{ {
1: PresenceSchedule( 1: [
1, 2, datetime.date(2026, 1, 5), PresencePattern(
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, 1, NAME, HOME, 2, datetime.date(2026, 1, 5),
) {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
)
]
} }
) )
@@ -669,12 +850,12 @@ def test_preview_start_date_is_inclusive() -> None:
def test_preview_honours_overrides() -> None: def test_preview_honours_overrides() -> None:
schedule = PresenceSchedule( pattern = PresencePattern(
1, 2, datetime.date(2026, 1, 5), 1, NAME, HOME, 2, datetime.date(2026, 1, 5),
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
) )
engine = PresenceEngine( engine = PresenceEngine(
{1: schedule}, {1: [pattern]},
overrides={ overrides={
1: [ 1: [
PresenceOverride( PresenceOverride(
@@ -692,7 +873,7 @@ def test_preview_honours_overrides() -> None:
] ]
def test_preview_schedule_free_child_returns_consecutive_days() -> None: def test_preview_pattern_free_child_returns_consecutive_days() -> None:
engine = PresenceEngine({}) engine = PresenceEngine({})
preview = engine.next_present_dates(3, "2026-03-01", 3) preview = engine.next_present_dates(3, "2026-03-01", 3)
assert preview == [ assert preview == [
@@ -719,9 +900,11 @@ def test_preview_always_absent_child_raises_not_loops() -> None:
hang the admin preview.""" hang the admin preview."""
engine = PresenceEngine( engine = PresenceEngine(
{ {
1: PresenceSchedule( 1: [
1, 1, datetime.date(2026, 1, 5), {0: set()} PresencePattern(
) 1, NAME, HOME, 1, datetime.date(2026, 1, 5), {0: set()}
)
]
} }
) )
with pytest.raises(ValueError, match="not present on any"): with pytest.raises(ValueError, match="not present on any"):
@@ -767,10 +950,10 @@ def test_custody_rotation_walks_full_years_without_inversion(
days=(7 - datetime.date(year, 1, 1).weekday()) % 7 days=(7 - datetime.date(year, 1, 1).weekday()) % 7
) )
assert anchor.weekday() == 0 assert anchor.weekday() == 0
rotation = PresenceSchedule( rotation = PresencePattern(
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} 1, NAME, HOME, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
) )
engine = PresenceEngine({1: rotation}) engine = PresenceEngine({1: [rotation]})
# Walk EVERY day of the year. The expectation is derived from the # Walk EVERY day of the year. The expectation is derived from the
# anchor's 7-day BLOCKS, not from the engine: each block of seven # anchor's 7-day BLOCKS, not from the engine: each block of seven
+165
View File
@@ -0,0 +1,165 @@
"""Tests for the schema-9 multi-pattern presence engine.
A child carries zero or more :class:`PresencePattern` rules; the
engine resolves them override -> away -> home -> default. The
expectations below are written out literally, week by week, rather
than recomputed with the engine's own arithmetic, so a wrong cycle
position cannot agree with itself.
"""
from __future__ import annotations
import datetime
from custom_components.nestquest.core.presence import (
PresenceEngine,
PresenceOverride,
PresencePattern,
)
CHILD = 1
#: The owner's rules, both anchored on Monday 2026-09-07 (cycle week 0).
AWAY_THU_FRI = PresencePattern(
CHILD, "Thursdays and Fridays away", "away", 1, "2026-09-07", {0: {3, 4}}
)
AWAY_ALTERNATE_WEEKENDS = PresencePattern(
CHILD, "Weekends away", "away", 2, "2026-09-07", {0: {5, 6}, 1: set()}
)
def _owner_engine(overrides=None) -> PresenceEngine:
return PresenceEngine(
{CHILD: [AWAY_THU_FRI, AWAY_ALTERNATE_WEEKENDS]}, overrides
)
def _week(engine: PresenceEngine, monday: str) -> str:
"""Render one Monday..Sunday week as ``P`` (present) / ``A`` (away)."""
start = datetime.date.fromisoformat(monday)
assert start.weekday() == 0, monday
return "".join(
"P" if engine.is_present(CHILD, start + datetime.timedelta(days=d))
else "A"
for d in range(7)
)
def test_owner_scenario_day_by_day_across_several_weeks() -> None:
"""Away Thu+Fri every week, plus away Sat+Sun every other week."""
engine = _owner_engine()
# Mon..Sun
expected = {
"2026-08-31": "PPPAAPP", # cycle week 1, before the anchor
"2026-09-07": "PPPAAAA", # anchor week: weekend away
"2026-09-14": "PPPAAPP",
"2026-09-21": "PPPAAAA",
"2026-09-28": "PPPAAPP",
"2026-10-05": "PPPAAAA",
"2026-10-12": "PPPAAPP",
"2026-10-19": "PPPAAAA",
}
assert {monday: _week(engine, monday) for monday in expected} == expected
def test_owner_scenario_across_the_year_boundary() -> None:
"""2026 has 53 ISO weeks; the alternation must not skip a beat."""
engine = _owner_engine()
expected = {
"2026-12-21": "PPPAAPP", # Dec 26-27 home
"2026-12-28": "PPPAAAA", # Jan 1 (Fri) and Jan 2-3 away
"2027-01-04": "PPPAAPP", # Jan 9-10 home
"2027-01-11": "PPPAAAA", # Jan 16-17 away
}
assert {monday: _week(engine, monday) for monday in expected} == expected
def test_owner_scenario_through_a_leap_day() -> None:
"""February 29th 2028 is an ordinary Tuesday in the cycle."""
engine = _owner_engine()
expected = {
"2028-02-21": "PPPAAAA", # Feb 26-27 away
"2028-02-28": "PPPAAPP", # Tue Feb 29 home; Mar 4-5 home
"2028-03-06": "PPPAAAA", # Mar 11-12 away
}
assert {monday: _week(engine, monday) for monday in expected} == expected
assert engine.is_present(CHILD, datetime.date(2028, 2, 29)) is True
def test_cycle_position_ignores_iso_week_parity() -> None:
"""D-004: consecutive Saturdays in odd ISO weeks 53 and 1 alternate.
2027-01-02 sits in ISO week 53 of 2026 and 2027-01-09 in ISO week 1
of 2027 — both odd, so a week-parity rule would give them the SAME
answer. Anchor-date arithmetic puts them in consecutive cycle
weeks, so the alternate-weekend pattern covers exactly one.
"""
first = datetime.date(2027, 1, 2)
second = datetime.date(2027, 1, 9)
assert first.isocalendar()[1] % 2 == second.isocalendar()[1] % 2 == 1
assert AWAY_ALTERNATE_WEEKENDS.covers(first) is True
assert AWAY_ALTERNATE_WEEKENDS.covers(second) is False
engine = _owner_engine()
assert engine.is_present(CHILD, first) is False
assert engine.is_present(CHILD, second) is True
def test_child_with_no_patterns_is_present_every_day() -> None:
engine = _owner_engine()
other = 2
start = datetime.date(2026, 12, 1)
for offset in range(120):
assert engine.is_present(other, start + datetime.timedelta(days=offset))
empty = PresenceEngine({})
assert all(
empty.is_present(CHILD, start + datetime.timedelta(days=offset))
for offset in range(120)
)
def test_overrides_beat_patterns_both_ways() -> None:
engine = _owner_engine(
{
CHILD: [
# A Thursday the away pattern covers: present override.
PresenceOverride(CHILD, "2026-09-10", "2026-09-10", True),
# A Monday no pattern covers: absent override.
PresenceOverride(CHILD, "2026-09-14", "2026-09-14", False),
]
}
)
assert engine.is_present(CHILD, datetime.date(2026, 9, 10)) is True
assert engine.is_present(CHILD, datetime.date(2026, 9, 14)) is False
# The day after each override the patterns answer again.
assert engine.is_present(CHILD, datetime.date(2026, 9, 11)) is False
assert engine.is_present(CHILD, datetime.date(2026, 9, 15)) is True
def test_away_beats_home_where_both_cover_a_date() -> None:
home_every_day = PresencePattern(
CHILD, "Home schedule", "home", 1, "2026-09-07",
{0: {0, 1, 2, 3, 4, 5, 6}},
)
engine = PresenceEngine({CHILD: [home_every_day, AWAY_THU_FRI]})
assert _week(engine, "2026-09-07") == "PPPAAPP"
# Order of the patterns does not matter.
reversed_engine = PresenceEngine({CHILD: [AWAY_THU_FRI, home_every_day]})
assert _week(reversed_engine, "2026-09-07") == "PPPAAPP"
def test_home_patterns_mark_uncovered_days_away() -> None:
"""A home pattern lists the days the child IS here (schedule semantics).
With at least one home pattern, a date no pattern covers is away —
the retired single-schedule behaviour migration 9 preserves. Two
home patterns union their covered days.
"""
weekdays = PresencePattern(
CHILD, "School weeks", "home", 1, "2026-09-07", {0: {0, 1, 2}}
)
alternate_weekends = PresencePattern(
CHILD, "Alternate weekends", "home", 2, "2026-09-07",
{0: {5, 6}, 1: set()},
)
engine = PresenceEngine({CHILD: [weekdays, alternate_weekends]})
assert _week(engine, "2026-09-07") == "PPPAAPP"
assert _week(engine, "2026-09-14") == "PPPAAAA"
+130 -46
View File
@@ -13,7 +13,7 @@ from custom_components.nestquest.core.schema import (
SCHEMA_V1_COMPLETION_EVENTS_DDL, SCHEMA_V1_COMPLETION_EVENTS_DDL,
SCHEMA_V7_META_STATE_DDL, SCHEMA_V7_META_STATE_DDL,
SCHEMA_V1_PRESENCE_OVERRIDES_DDL, SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
SCHEMA_V1_PRESENCE_SCHEDULES_DDL, SCHEMA_V9_PRESENCE_PATTERNS_DDL,
SCHEMA_V1_SCHEDULE_RULES_DDL, SCHEMA_V1_SCHEDULE_RULES_DDL,
SCHEMA_V1_STATEMENTS, SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITIONS_DDL, SCHEMA_V1_QUEST_DEFINITIONS_DDL,
@@ -112,14 +112,16 @@ def _insert_definition(database, rule_id=1, assignees=(1,), **overrides):
return result.lastrowid return result.lastrowid
def _insert_schedule(database, child_id, **overrides): def _insert_pattern(database, child_id, **overrides):
"""Insert one valid presence_schedule, returning the schedule id. """Insert one valid presence_pattern, returning the pattern id.
Pass ``_OMIT`` as a value to leave that column out of the INSERT so Pass ``_OMIT`` as a value to leave that column out of the INSERT so
its DDL DEFAULT applies. its DDL DEFAULT applies.
""" """
values: dict[str, object] = { values: dict[str, object] = {
"child_id": child_id, "child_id": child_id,
"name": "Home schedule",
"kind": "home",
"cycle_length_weeks": 2, "cycle_length_weeks": 2,
"anchor_date": "2026-09-13", "anchor_date": "2026-09-13",
"pattern": "0,2,4|1,3", "pattern": "0,2,4|1,3",
@@ -130,7 +132,7 @@ def _insert_schedule(database, child_id, **overrides):
placeholders = ", ".join("?" for _ in values) placeholders = ", ".join("?" for _ in values)
result = _run( result = _run(
database.execute( database.execute(
f"INSERT INTO presence_schedules ({columns}) VALUES ({placeholders})", f"INSERT INTO presence_patterns ({columns}) VALUES ({placeholders})",
tuple(values.values()), tuple(values.values()),
) )
) )
@@ -177,7 +179,7 @@ def test_schema_v1_statements_compose_the_nine_tables() -> None:
*SCHEMA_V1_QUEST_DEFINITIONS_DDL, *SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, *SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL, *SCHEMA_V9_PRESENCE_PATTERNS_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL,
@@ -1317,7 +1319,7 @@ def test_applying_extended_ddl_twice_keeps_new_table_rows(tmp_path) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# presence_schedules and presence_overrides constraint enforcement # presence_patterns and presence_overrides constraint enforcement
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1331,17 +1333,26 @@ def _child(database, name="Ada") -> int:
return 1 return 1
def test_presence_schedules_table_exists_after_applying_ddl(tmp_path) -> None: def test_presence_patterns_table_exists_after_applying_ddl(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules.db") database = _open_db(tmp_path / "presence-patterns.db")
try: try:
_apply(database) _apply(database)
row = _run( row = _run(
database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'presence_patterns'"
)
)
assert row is not None and row[0] == "presence_patterns"
# Schema 9 replaced the one-per-child presence_schedules table;
# the current-schema DDL must not create it.
gone = _run(
database.fetch_one( database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' " "SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'presence_schedules'" "AND name = 'presence_schedules'"
) )
) )
assert row is not None and row[0] == "presence_schedules" assert gone is None
finally: finally:
_run(database.close()) _run(database.close())
@@ -1361,15 +1372,17 @@ def test_presence_overrides_table_exists_after_applying_ddl(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_presence_schedules_columns_types_and_constraints(tmp_path) -> None: def test_presence_patterns_columns_types_and_constraints(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-columns.db") database = _open_db(tmp_path / "presence-patterns-columns.db")
try: try:
_apply(database) _apply(database)
columns = _run(database.fetch_all("PRAGMA table_info(presence_schedules)")) columns = _run(database.fetch_all("PRAGMA table_info(presence_patterns)"))
# cid, name, type, notnull, dflt_value, pk # cid, name, type, notnull, dflt_value, pk
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
("id", "INTEGER", 0, None, 1), ("id", "INTEGER", 0, None, 1),
("child_id", "INTEGER", 1, None, 0), ("child_id", "INTEGER", 1, None, 0),
("name", "TEXT", 1, None, 0),
("kind", "TEXT", 1, None, 0),
("cycle_length_weeks", "INTEGER", 1, None, 0), ("cycle_length_weeks", "INTEGER", 1, None, 0),
("anchor_date", "TEXT", 1, None, 0), ("anchor_date", "TEXT", 1, None, 0),
("pattern", "TEXT", 1, None, 0), ("pattern", "TEXT", 1, None, 0),
@@ -1395,11 +1408,11 @@ def test_presence_overrides_columns_types_and_constraints(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_presence_schedules_foreign_key_declared(tmp_path) -> None: def test_presence_patterns_foreign_key_declared(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-fks.db") database = _open_db(tmp_path / "presence-patterns-fks.db")
try: try:
_apply(database) _apply(database)
fks = _run(database.fetch_all("PRAGMA foreign_key_list(presence_schedules)")) fks = _run(database.fetch_all("PRAGMA foreign_key_list(presence_patterns)"))
declared = {(row[2], row[3], row[4]) for row in fks} declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == {("children", "child_id", "id")} assert declared == {("children", "child_id", "id")}
finally: finally:
@@ -1417,48 +1430,119 @@ def test_presence_overrides_foreign_key_declared(tmp_path) -> None:
_run(database.close()) _run(database.close())
def test_presence_schedules_valid_insert_round_trips(tmp_path) -> None: def test_presence_patterns_valid_insert_round_trips(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-valid.db") database = _open_db(tmp_path / "presence-patterns-valid.db")
try: try:
_apply(database) _apply(database)
_child(database) _child(database)
schedule_id = _insert_schedule(database, child_id=1) pattern_id = _insert_pattern(database, child_id=1)
row = _run( row = _run(
database.fetch_one( database.fetch_one(
"SELECT child_id, cycle_length_weeks, anchor_date, pattern " "SELECT child_id, name, kind, cycle_length_weeks, anchor_date, "
"FROM presence_schedules WHERE id = ?", "pattern FROM presence_patterns WHERE id = ?",
(schedule_id,), (pattern_id,),
) )
) )
assert row == (1, 2, "2026-09-13", "0,2,4|1,3") assert row == (1, "Home schedule", "home", 2, "2026-09-13", "0,2,4|1,3")
finally: finally:
_run(database.close()) _run(database.close())
def test_presence_schedules_second_schedule_for_same_child_fails(tmp_path) -> None: def test_presence_patterns_allow_several_patterns_per_child(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-unique.db") database = _open_db(tmp_path / "presence-patterns-many.db")
try: try:
_apply(database) _apply(database)
_child(database) _child(database)
_insert_schedule(database, child_id=1) first = _insert_pattern(database, child_id=1)
with pytest.raises(sqlite3.IntegrityError, match="UNIQUE"): second = _insert_pattern(
_insert_schedule(database, child_id=1) database, child_id=1, name="Weekend away", kind="away",
cycle_length_weeks=1, pattern="5,6",
)
third = _insert_pattern(database, child_id=1) # identical row is fine
rows = _run(
database.fetch_all(
"SELECT id, name, kind FROM presence_patterns "
"WHERE child_id = 1 ORDER BY id"
)
)
assert rows == [
(first, "Home schedule", "home"),
(second, "Weekend away", "away"),
(third, "Home schedule", "home"),
]
finally: finally:
_run(database.close()) _run(database.close())
def test_presence_schedules_unknown_child_fails(tmp_path) -> None: def test_presence_patterns_declare_no_unique_index(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-bad-child.db") database = _open_db(tmp_path / "presence-patterns-no-unique.db")
try:
_apply(database)
indexes = _run(database.fetch_all("PRAGMA index_list(presence_patterns)"))
assert [row for row in indexes if row[2]] == []
finally:
_run(database.close())
@pytest.mark.parametrize("bad_kind", ["", "HOME", "Away", "present", "absent", 1])
def test_presence_patterns_kind_check_enforced(tmp_path, bad_kind) -> None:
database = _open_db(tmp_path / "presence-patterns-kind.db")
try:
_apply(database)
_child(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_pattern(database, child_id=1, kind=bad_kind)
finally:
_run(database.close())
@pytest.mark.parametrize("kind", ["home", "away"])
def test_presence_patterns_both_kinds_accepted(tmp_path, kind) -> None:
database = _open_db(tmp_path / f"presence-patterns-kind-{kind}.db")
try:
_apply(database)
_child(database)
pattern_id = _insert_pattern(database, child_id=1, kind=kind)
row = _run(
database.fetch_one(
"SELECT kind FROM presence_patterns WHERE id = ?", (pattern_id,)
)
)
assert row == (kind,)
finally:
_run(database.close())
@pytest.mark.parametrize("column", ["name", "kind"])
def test_presence_patterns_name_and_kind_not_null(tmp_path, column) -> None:
database = _open_db(tmp_path / f"presence-patterns-null-{column}.db")
try:
_apply(database)
_child(database)
with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"):
_insert_pattern(database, child_id=1, **{column: None})
finally:
_run(database.close())
def test_presence_schedules_absent_from_current_schema_ddl() -> None:
assert not any(
"presence_schedules" in sql for sql in SCHEMA_V1_STATEMENTS
)
def test_presence_patterns_unknown_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-bad-child.db")
try: try:
_apply(database) _apply(database)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_insert_schedule(database, child_id=999) _insert_pattern(database, child_id=999)
finally: finally:
_run(database.close()) _run(database.close())
@pytest.mark.parametrize("bad_cycle", [0, 5, "abc", 1.5]) @pytest.mark.parametrize("bad_cycle", [0, 5, "abc", 1.5])
def test_presence_schedules_cycle_length_out_of_range_or_type_fails( def test_presence_patterns_cycle_length_out_of_range_or_type_fails(
tmp_path, bad_cycle tmp_path, bad_cycle
) -> None: ) -> None:
database = _open_db(tmp_path / f"cycle-{type(bad_cycle).__name__}.db") database = _open_db(tmp_path / f"cycle-{type(bad_cycle).__name__}.db")
@@ -1466,7 +1550,7 @@ def test_presence_schedules_cycle_length_out_of_range_or_type_fails(
_apply(database) _apply(database)
_child(database) _child(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"): with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_schedule(database, child_id=1, cycle_length_weeks=bad_cycle) _insert_pattern(database, child_id=1, cycle_length_weeks=bad_cycle)
finally: finally:
_run(database.close()) _run(database.close())
@@ -1487,18 +1571,18 @@ def test_presence_schedules_cycle_length_out_of_range_or_type_fails(
(4, "0,1|0,1|0,1|0,1"), (4, "0,1|0,1|0,1|0,1"),
], ],
) )
def test_presence_schedules_valid_patterns_succeed(tmp_path, cycle, pattern) -> None: def test_presence_patterns_valid_patterns_succeed(tmp_path, cycle, pattern) -> None:
database = _open_db(tmp_path / "presence-schedules-patterns-valid.db") database = _open_db(tmp_path / "presence-patterns-patterns-valid.db")
try: try:
_apply(database) _apply(database)
_child(database) _child(database)
schedule_id = _insert_schedule( pattern_id = _insert_pattern(
database, child_id=1, cycle_length_weeks=cycle, pattern=pattern database, child_id=1, cycle_length_weeks=cycle, pattern=pattern
) )
row = _run( row = _run(
database.fetch_one( database.fetch_one(
"SELECT pattern FROM presence_schedules WHERE id = ?", "SELECT pattern FROM presence_patterns WHERE id = ?",
(schedule_id,), (pattern_id,),
) )
) )
assert row == (pattern,) assert row == (pattern,)
@@ -1529,32 +1613,32 @@ def test_presence_schedules_valid_patterns_succeed(tmp_path, cycle, pattern) ->
(1, "0,1,2,3,4,5,6,0"), # more than 7 elements (1, "0,1,2,3,4,5,6,0"), # more than 7 elements
], ],
) )
def test_presence_schedules_invalid_patterns_fail(tmp_path, cycle, pattern) -> None: def test_presence_patterns_invalid_patterns_fail(tmp_path, cycle, pattern) -> None:
database = _open_db(tmp_path / "presence-schedules-patterns-invalid.db") database = _open_db(tmp_path / "presence-patterns-patterns-invalid.db")
try: try:
_apply(database) _apply(database)
_child(database) _child(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"): with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_schedule( _insert_pattern(
database, child_id=1, cycle_length_weeks=cycle, pattern=pattern database, child_id=1, cycle_length_weeks=cycle, pattern=pattern
) )
finally: finally:
_run(database.close()) _run(database.close())
def test_presence_schedules_cycle_length_bounds_pass(tmp_path) -> None: def test_presence_patterns_cycle_length_bounds_pass(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-cycle-bounds.db") database = _open_db(tmp_path / "presence-patterns-cycle-bounds.db")
try: try:
_apply(database) _apply(database)
_child(database) _child(database)
_insert_schedule(database, child_id=1, cycle_length_weeks=1, pattern="0") _insert_pattern(database, child_id=1, cycle_length_weeks=1, pattern="0")
_run( _run(
database.execute( database.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)", "INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Bo", "2026-09-13T00:00:00+00:00"), ("Bo", "2026-09-13T00:00:00+00:00"),
) )
) )
_insert_schedule(database, child_id=2, cycle_length_weeks=4, pattern="|||") _insert_pattern(database, child_id=2, cycle_length_weeks=4, pattern="|||")
finally: finally:
_run(database.close()) _run(database.close())
@@ -1732,11 +1816,11 @@ def test_applying_presence_ddl_twice_keeps_rows(tmp_path) -> None:
try: try:
_apply(database) _apply(database)
_child(database) _child(database)
_insert_schedule(database, child_id=1) _insert_pattern(database, child_id=1)
_insert_override(database, child_id=1) _insert_override(database, child_id=1)
_apply(database) _apply(database)
counts = [] counts = []
for table in ("presence_schedules", "presence_overrides"): for table in ("presence_patterns", "presence_overrides"):
row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}")) row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}"))
counts.append(row[0]) counts.append(row[0])
assert counts == [1, 1] assert counts == [1, 1]