Core, API and Admin PWA: multiple presence patterns per child (schema 9) (#231)

This commit was merged in pull request #231.
This commit is contained in:
2026-09-24 20:09:56 -04:00
parent 5650a1f5a2
commit 88b4733a2d
36 changed files with 4230 additions and 1388 deletions
+58 -38
View File
@@ -1,25 +1,35 @@
/**
* Typed client for the admin presence routes: a child's repeating schedule
* (/api/v1/admin/children/{id}/presence-schedule) and the household's
* date-range overrides (/api/v1/admin/presence-overrides).
* Typed client for the admin presence routes: a child's repeating patterns
* (/api/v1/admin/children/{id}/presence-patterns, /presence-patterns/{id})
* and the household's date-range overrides (/api/v1/admin/presence-overrides).
*/
import { apiFetch } from "./client";
import { CHILDREN_PATH, readJson } from "./definitions";
/**
* Cycle week index ("0".."n-1", JSON object keys) -> present weekdays
* (0 = Monday .. 6 = Sunday). An empty list is away that whole week.
*/
export type PresencePattern = Record<string, number[]>;
/** A pattern claims its covered days as the child being home or away. */
export type PatternKind = "home" | "away";
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;
/** "YYYY-MM-DD"; pins cycle week 0. */
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;
}
@@ -40,30 +50,48 @@ export interface PresenceOverrideFilter {
}
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 {
return `${CHILDREN_PATH}/${childId}/presence-schedule`;
export function childPatternsPath(childId: number): string {
return `${CHILDREN_PATH}/${childId}/presence-patterns`;
}
/** The child's schedule, or null when it has none (present every day). */
export async function fetchPresenceSchedule(childId: number): Promise<PresenceSchedule | null> {
const body = await readJson<{ schedule: PresenceSchedule | null }>(
await apiFetch(presenceSchedulePath(childId)),
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
return apiFetch(path, {
method,
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 savePresenceSchedule(
export async function createPresencePattern(
childId: number,
body: PresenceScheduleBody,
): Promise<PresenceSchedule> {
return readJson<PresenceSchedule>(
await apiFetch(presenceSchedulePath(childId), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
body: PresencePatternBody,
): Promise<PresencePattern> {
return readJson<PresencePattern>(await sendJson(childPatternsPath(childId), "POST", body));
}
/** Change only the supplied fields; resolves to the pattern as stored. */
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`;
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
* by the API before anything is saved; completed instances never count.
*/
export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise<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;
}
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> {
@@ -362,6 +362,39 @@ describe("DefinitionsTab", () => {
expect(screen.getByText("4 definitions · 3 children")).toBeTruthy();
});
it("the weekday picker lists Sunday first and Sunday still stores 6", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Feed the cat" },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
target: { value: "weekly" },
});
const days = within(s).getByRole("group", { name: "Days" });
expect(
within(days)
.getAllByRole("button")
.map((b) => b.getAttribute("aria-label")),
).toEqual(["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]);
fireEvent.click(within(days).getByRole("button", { name: "Sunday" }));
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
const body = api.writes()[0].body as { rule: DefinitionRule };
expect(body.rule.weekday_set).toEqual([2, 6]);
expect(meta(await screen.findByTestId("definition-99"))).toContain("on Sun, Wed");
});
it("tapping a row opens the sheet in editing state and saving PATCHes", async () => {
await renderReady();
const row = screen.getByTestId("definition-11");
+4 -3
View File
@@ -27,6 +27,7 @@ import {
} from "../api/definitions";
import {
MONTH_SHORT,
WEEKDAY_DISPLAY_ORDER,
WEEKDAY_SHORT,
WINDOW_LABELS,
WINDOW_ORDER,
@@ -680,9 +681,9 @@ function EditSheet({
</div>
{draft.repeats === "weekly" || draft.repeats === "custom" ? (
<div className="defs-weekdays" role="group" aria-label="Days">
{WEEKDAY_SHORT.map((label, day) => (
{WEEKDAY_DISPLAY_ORDER.map((day) => (
<button
key={label}
key={day}
type="button"
aria-pressed={draft.weekdays.includes(day)}
aria-label={WEEKDAY_LONG[day]}
@@ -691,7 +692,7 @@ function EditSheet({
}
onClick={() => update({ weekdays: toggle(draft.weekdays, day) })}
>
{label.charAt(0)}
{WEEKDAY_SHORT[day].charAt(0)}
</button>
))}
</div>
+4 -1
View File
@@ -21,6 +21,9 @@ export const WINDOW_LABELS: Record<WindowName, string> = {
/** Index 0 = Monday .. 6 = Sunday, the core's weekday numbering. */
export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
/** Display order for a week: Sunday first. The stored indices stay Monday=0. */
export const WEEKDAY_DISPLAY_ORDER = [6, 0, 1, 2, 3, 4, 5];
export const MONTH_SHORT = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
@@ -51,7 +54,7 @@ export function assigneeSummary(
function weekdayList(days: number[] | null): string {
return [...(days ?? [])]
.sort((a, b) => a - b)
.sort((a, b) => WEEKDAY_DISPLAY_ORDER.indexOf(a) - WEEKDAY_DISPLAY_ORDER.indexOf(b))
.map((day) => WEEKDAY_SHORT[day])
.join(", ");
}
+9
View File
@@ -95,6 +95,15 @@
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 {
min-height: var(--nq-a-tap-min);
padding: 0 8px;
+12 -9
View File
@@ -4,7 +4,7 @@ import App from "../App";
import ScheduleTab from "./ScheduleTab";
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
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";
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 DECLAN: PresenceSchedule = {
const DECLAN: PresencePattern = {
id: 1,
child_id: 1,
name: "Home weeks",
kind: "home",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
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" });
}
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const scheduleMatch = /\/children\/(\d+)\/presence-schedule$/.exec(path);
if (scheduleMatch) {
return jsonResponse({ schedule: Number(scheduleMatch[1]) === 1 ? DECLAN : null });
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (patternsMatch) {
return jsonResponse({ patterns: Number(patternsMatch[1]) === 1 ? [DECLAN] : [] });
}
if (path === OVERRIDES_PATH) return jsonResponse({ overrides });
return jsonResponse({ detail: "unexpected" }, 500);
@@ -293,7 +296,7 @@ describe("OverrideEditor", () => {
});
await waitFor(() => expect(saveButton().disabled).toBe(false));
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());
const row = await screen.findByTestId("override-50");
@@ -321,10 +324,10 @@ describe("OverrideEditor", () => {
},
]);
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(calls().filter((c) => c.path.endsWith("/presence-schedule")).length).toBeGreaterThan(
schedulesBefore,
expect(calls().filter((c) => c.path.endsWith("/presence-patterns")).length).toBeGreaterThan(
patternsBefore,
);
// The grid reflects the new override.
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: "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 ApiRequestError && error.detail) return error.detail;
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 {
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
}
@@ -118,20 +136,7 @@ export default function OverrideEditor({
if (!saving) onCancel();
return;
}
if (event.key !== "Tab" || !screenRef.current) return;
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();
}
trapTab(event, screenRef.current);
}
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 {
display: grid;
place-items: center;
aspect-ratio: 1;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.schedule-day--home {
@@ -184,60 +183,11 @@
border-radius: 3px;
}
/* Pattern editing controls */
.schedule-controls {
display: flex;
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;
.schedule-legend-note {
margin: 8px 0 0;
font-size: 10.5px;
font-weight: 600;
text-transform: none;
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;
color: var(--nq-a-ink-tertiary);
}
.schedule-save-status {
@@ -251,7 +201,7 @@
color: var(--nq-a-danger-strong);
}
/* Presence pattern + overrides cards */
/* Presence patterns + overrides cards */
.schedule-section-head {
display: flex;
align-items: center;
@@ -318,13 +268,6 @@
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 {
flex: none;
display: grid;
@@ -339,6 +282,10 @@
cursor: pointer;
}
.schedule-icon-action--edit {
margin-right: -4px;
}
.schedule-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
+470 -89
View File
@@ -1,9 +1,10 @@
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 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 { cycleWeekIndex, monthDates, weekdayOf } from "./cycle";
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 };
@@ -16,20 +17,39 @@ const CHILDREN = [
child(9, "Former", 4, false),
];
// Declan: anchor Monday 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun.
// Keys arrive as JSON strings, exactly as the API serializes them.
const DECLAN: PresenceSchedule = {
// Declan: the migrated single schedule — one `home` pattern anchored Monday
// 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun only. Keys arrive
// as JSON strings, exactly as the API serializes them.
const DECLAN_HOME: PresencePattern = {
id: 1,
child_id: 1,
name: "Home weeks",
kind: "home",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
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,
cycle_length_weeks: 4,
name: "Thursday+Friday",
kind: "away",
cycle_length_weeks: 1,
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[] = [
@@ -39,6 +59,7 @@ const OVERRIDES: PresenceOverride[] = [
];
const TODAY = "2026-09-23";
const PATTERNS_PATH = "/api/v1/admin/presence-patterns";
function jsonResponse(body: unknown, status = 200): Response {
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 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 {
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 });
const match = /\/children\/(\d+)\/presence-schedule$/.exec(path);
if (match) {
const id = Number(match[1]);
return jsonResponse({ schedule: id === 1 ? DECLAN : id === 2 ? MAEVE : null });
const collection = /\/children\/(\d+)\/presence-patterns$/.exec(path);
if (collection) {
const childId = Number(collection[1]);
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 });
return jsonResponse({ detail: "unexpected" }, 500);
@@ -69,16 +142,32 @@ function day(date: string): HTMLElement {
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() {
render(<ScheduleTab today={TODAY} />);
await screen.findByTestId("pattern-1");
}
async function selectMaeve() {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
await screen.findByTestId("pattern-3");
}
describe("ScheduleTab", () => {
beforeEach(() => {
sessionStorage.clear();
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));
vi.stubGlobal("fetch", fetchMock);
});
@@ -88,27 +177,31 @@ describe("ScheduleTab", () => {
vi.unstubAllGlobals();
});
it("loads active children, their schedules and the overrides", async () => {
it("loads active children, their patterns and the overrides", async () => {
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/1/presence-schedule");
expect(urls).toContain("/api/v1/admin/children/2/presence-schedule");
expect(urls).toContain("/api/v1/admin/children/3/presence-schedule");
expect(urls).not.toContain("/api/v1/admin/children/9/presence-schedule");
expect(urls).toContain("/api/v1/admin/children/1/presence-patterns");
expect(urls).toContain("/api/v1/admin/children/2/presence-patterns");
expect(urls).toContain("/api/v1/admin/children/3/presence-patterns");
expect(urls).not.toContain("/api/v1/admin/children/9/presence-patterns");
expect(urls).toContain("/api/v1/admin/presence-overrides");
const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]);
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();
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();
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
const states: Record<string, string> = {
@@ -131,10 +224,22 @@ describe("ScheduleTab", () => {
expect(today.className).toContain("schedule-day--today");
expect(today.dataset.state).toBe("home");
expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home");
// September 2026 starts on a Tuesday: one leading pad after the initials.
// 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.
const grid = screen.getByRole("group", { name: "Month grid" });
expect(grid.children[7].tagName).toBe("SPAN");
expect(grid.children[8].getAttribute("data-date")).toBe("2026-09-01");
expect(Array.from(grid.children, (c) => c.textContent).slice(0, 7)).toEqual([
"S",
"M",
"T",
"W",
"T",
"F",
"S",
]);
expect(grid.children[7].getAttribute("data-date")).toBeNull();
expect(grid.children[8].getAttribute("data-date")).toBeNull();
expect(grid.children[9].getAttribute("data-date")).toBe("2026-09-01");
const legend = screen.getByRole("list", { name: "Legend" });
expect(within(legend).getAllByRole("listitem").map((li) => li.textContent)).toEqual([
@@ -144,6 +249,29 @@ describe("ScheduleTab", () => {
"Override · home",
"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 () => {
@@ -158,14 +286,36 @@ describe("ScheduleTab", () => {
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
});
it("presence pattern card shows a rule per child", async () => {
await renderReady();
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
expect(within(screen.getByTestId("pattern-2")).getByText("Weeks 1 & 3 of cycle")).toBeTruthy();
expect(within(screen.getByTestId("pattern-3")).getByText("Every day")).toBeTruthy();
it("patterns list shows each pattern's name, kind, cycle and weekday set", async () => {
await selectMaeve();
const list = screen.getByRole("list", { name: "Presence patterns" });
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
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();
const list = screen.getByRole("list", { name: "Overrides" });
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
@@ -178,86 +328,317 @@ describe("ScheduleTab", () => {
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
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();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
expect(screen.getByText("2-week cycle · anchor Tuesday, Sep 1")).toBeTruthy();
expect(day("2026-09-15").dataset.state).toBe("home");
expect(screen.getByText("No overrides for Rory.")).toBeTruthy();
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
expect(await screen.findByRole("heading", { name: "New pattern" })).toBeTruthy();
// Nothing named yet.
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,
);
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();
expect(day("2026-09-01").dataset.state).toBe("away");
fireEvent.click(day("2026-09-01")); // cycle week 1 (pre-anchor), Tuesday
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: "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.
expect(day("2026-09-01").dataset.state).toBe("home");
expect(day("2026-09-15").dataset.state).toBe("home");
expect(day("2026-09-08").dataset.state).toBe("home");
it("Save requires a name; covered days alone are not enough", async () => {
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.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" }));
expect(await screen.findByText("Pattern saved.")).toBeTruthy();
it("Add saves a pattern that covers no weekday and lists it", async () => {
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(put).toBeTruthy();
const [url, init] = put!;
expect(new URL(String(url), "http://x").pathname).toBe("/api/v1/admin/children/1/presence-schedule");
expect(init.headers.Authorization).toBe("Bearer at-123");
expect(JSON.parse(init.body)).toEqual({
expect(writes()).toEqual([
{
method: "POST",
path: "/api/v1/admin/children/3/presence-patterns",
body: {
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,
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");
expect(day("2026-09-29").dataset.state).toBe("home");
expect(day("2026-09-30").dataset.state).toBe("away");
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Never home" } });
expect(saveButton().disabled).toBe(false);
fireEvent.click(saveButton());
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,
);
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 () => {
await renderReady();
fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-14" } });
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "3" } });
expect(screen.getByText("3-week cycle · anchor Monday, Sep 14")).toBeTruthy();
// Anchor moved a week: Sep 7-13 is now the last week of the cycle (full, added).
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
await screen.findByText("Pattern saved.");
const [, init] = fetchMock.mock.calls.find(([, i]) => i?.method === "PUT")!;
expect(JSON.parse(init.body)).toEqual({
cycle_length_weeks: 3,
anchor_date: "2026-09-14",
pattern: {
"0": [0, 1, 2, 3, 4, 5, 6],
"1": [5, 6],
"2": [0, 1, 2, 3, 4, 5, 6],
},
});
it("Delete asks first, DELETEs /presence-patterns/{id} and refreshes the list", async () => {
await selectMaeve();
fireEvent.click(screen.getByRole("button", { name: "Delete Thursday+Friday" }));
const confirm = within(screen.getByTestId("pattern-2")).getByRole("group", { name: "Confirm delete" });
fireEvent.click(within(confirm).getByRole("button", { name: "Keep" }));
expect(writes()).toEqual([]);
fireEvent.click(screen.getByRole("button", { name: "Delete Thursday+Friday" }));
fireEvent.click(within(screen.getByTestId("pattern-2")).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(screen.queryByTestId("pattern-2")).toBeNull());
expect(writes()).toEqual([{ method: "DELETE", path: `${PATTERNS_PATH}/2`, body: undefined }]);
expect(screen.getByTestId("pattern-3")).toBeTruthy();
expect(day("2026-09-17").dataset.state).toBe("home"); // Thursday, no longer away
expect(day("2026-09-12").dataset.state).toBe("away"); // weekends pattern still applies
});
it("a 422 shows the API detail and keeps the draft", async () => {
putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422);
it("deleting the last pattern leaves the child with none: home every day", async () => {
await renderReady();
fireEvent.click(day("2026-09-01"));
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
expect(day("2026-09-15").dataset.state).toBe("away");
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");
expect(alert.textContent).toBe("anchor_date must be a Monday");
expect(day("2026-09-01").dataset.state).toBe("home");
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
false,
);
// The saved rule is unchanged.
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
expect(alert.textContent).toBe("Your account is not in the nestquest-admins group.");
expect(screen.getByTestId("pattern-1")).toBeTruthy();
});
it("a 422 on delete shows the API detail", async () => {
writeFailure = jsonResponse({ detail: "pattern_id must be an integer" }, 422);
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 () => {
+196 -167
View File
@@ -1,52 +1,50 @@
/**
* 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
* from ISO week numbers or week parity (D-004).
* The grid shows the COMBINED presence of every pattern (./cycle.ts
* `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 { ApiForbiddenError } from "../api/client";
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
import {
deletePresenceOverride,
deletePresencePattern,
fetchPresenceOverrides,
fetchPresenceSchedule,
savePresenceSchedule,
fetchPresencePatterns,
type PresenceOverride,
type PresenceSchedule,
type PresenceScheduleBody,
type PresencePattern,
} from "../api/presence";
import {
cycleWeekIndex,
cycleLabel,
dayState,
fullPattern,
localTodayIso,
monthDates,
monthStart,
patternIncludes,
presenceRule,
resizePattern,
togglePatternDay,
patternDaysLabel,
weekdayOf,
type DayState,
} from "./cycle";
import { anchorLabel, dateRange, monthLabel, shortDate } from "./format";
import { dateRange, monthLabel, shortDate } from "./format";
import {
CalendarCheckGlyph,
CalendarXGlyph,
ChevronLeftGlyph,
ChevronRightGlyph,
PencilGlyph,
Trash2Glyph,
} from "./glyphs";
import OverrideEditor from "./OverrideEditor";
import PatternEditor from "./PatternEditor";
import "./ScheduleTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
export const DEFAULT_CYCLE_LENGTH_WEEKS = 2;
const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
const WEEKDAY_INITIALS = ["M", "T", "W", "T", "F", "S", "S"];
/** Sunday-first header; `weekdayOf` stays Monday=0. */
const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"];
export const STATE_LABELS: Record<DayState, string> = {
home: "Home",
@@ -57,7 +55,7 @@ export const STATE_LABELS: Record<DayState, string> = {
interface Loaded {
children: AdminChild[];
schedules: Record<number, PresenceSchedule | null>;
patterns: Record<number, PresencePattern[]>;
overrides: PresenceOverride[];
}
@@ -72,23 +70,22 @@ type DeleteState =
| { kind: "deleting"; id: number }
| { kind: "error"; id: number; message: string };
type SaveState =
| { kind: "idle" }
| { kind: "saving" }
| { kind: "saved" }
| { kind: "error"; message: string };
type EditorState =
| { kind: "closed" }
| { kind: "override" }
| { kind: "pattern"; pattern: PresencePattern | null };
async function loadSchedule(): Promise<Loaded> {
const children = (await fetchChildren())
.filter((child) => child.is_active)
.sort((a, b) => a.sort_order - b.sort_order);
const [schedules, overrides] = await Promise.all([
Promise.all(children.map((child) => fetchPresenceSchedule(child.id))),
const [patterns, overrides] = await Promise.all([
Promise.all(children.map((child) => fetchPresencePatterns(child.id))),
fetchPresenceOverrides(),
]);
return {
children,
schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])),
patterns: Object.fromEntries(children.map((child, i) => [child.id, patterns[i]])),
overrides,
};
}
@@ -99,10 +96,7 @@ function loadErrorMessage(error: unknown): string {
: "The schedule could not be loaded. Check your connection and try again.";
}
function saveErrorMessage(
error: unknown,
fallback = "The pattern could not be saved. Check your connection and try again.",
): string {
function saveErrorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail;
return fallback;
@@ -120,17 +114,15 @@ function ScreenHeader({ sub }: { sub: string }) {
interface MonthGridProps {
month: string;
today: string;
schedule: PresenceScheduleBody;
/** Null when the child has no saved schedule and no edits: home every day. */
effective: PresenceScheduleBody | null;
/** Every pattern of the child; none means home every day. */
patterns: PresencePattern[];
overrides: PresenceOverride[];
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 leading = weekdayOf(month);
const leading = (weekdayOf(month) + 1) % 7;
return (
<>
<div className="schedule-month-head">
@@ -166,23 +158,21 @@ function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onTo
<span key={`pad-${i}`} aria-hidden="true" />
))}
{dates.map((date) => {
const state = dayState(effective, overrides, date);
const state = dayState(patterns, overrides, date);
const isToday = date === today;
let className = `schedule-day schedule-day--${state}`;
if (isToday) className += " schedule-day--today";
return (
<button
<span
key={date}
type="button"
role="img"
className={className}
data-date={date}
data-state={state}
aria-pressed={patternIncludes(schedule, date)}
aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`}
onClick={() => onToggle(date)}
>
{Number(date.slice(8))}
</button>
</span>
);
})}
</div>
@@ -198,10 +188,83 @@ function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onTo
Today
</li>
</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 {
override: PresenceOverride;
childName: string;
@@ -270,21 +333,24 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
const [attempt, setAttempt] = useState(0);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [month, setMonth] = useState(() => monthStart(today));
const [drafts, setDrafts] = useState<Record<number, PresenceScheduleBody>>({});
const [save, setSave] = useState<SaveState>({ kind: "idle" });
const [editorOpen, setEditorOpen] = useState(false);
const [editor, setEditor] = useState<EditorState>({ kind: "closed" });
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
const [patternDeletion, setPatternDeletion] = useState<DeleteState>({ kind: "idle" });
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;
// hand focus back to it then.
// An editor replaces this screen, so the Add controls are remounted when
// it closes; hand focus back to the one that belongs to that editor.
useEffect(() => {
if (editorWasOpen.current && !editorOpen) addRef.current?.focus();
editorWasOpen.current = editorOpen;
}, [editorOpen]);
if (editor.kind === "closed") {
if (openedEditor.current === "override") addRef.current?.focus();
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 = () =>
loadSchedule()
.then((data) => setState({ kind: "ready", data }))
@@ -308,16 +374,31 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
};
}, [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 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 (
<OverrideEditor
childOptions={children}
initialChildId={children.find((c) => c.id === selectedId)?.id ?? children[0].id}
initialChildId={current.id}
today={today}
onCancel={() => setEditorOpen(false)}
onCancel={() => setEditor({ kind: "closed" })}
onSaved={(override) => {
setEditorOpen(false);
setEditor({ kind: "closed" });
setSelectedId(override.child_id);
void refresh();
}}
@@ -347,28 +428,13 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
} else {
const { data } = state;
const child = data.children.find((c) => c.id === selectedId) ?? data.children[0];
const saved = data.schedules[child.id] ?? null;
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 patterns = data.patterns[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) => {
setDeletion({ kind: "deleting", id });
deletePresenceOverride(id)
@@ -385,32 +451,21 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
}),
);
};
const submit = () => {
if (!draft) return;
const childId = child.id;
setSave({ kind: "saving" });
const { cycle_length_weeks, anchor_date, pattern } = draft;
savePresenceSchedule(childId, { cycle_length_weeks, anchor_date, pattern })
.then((stored) => {
setState((current) =>
current.kind === "ready"
? {
kind: "ready",
data: {
...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) }));
const confirmPatternDelete = (id: number) => {
setPatternDeletion({ kind: "deleting", id });
deletePresencePattern(id)
.then(() => refresh())
.then(() => setPatternDeletion({ kind: "idle" }))
.catch((error: unknown) =>
setPatternDeletion({
kind: "error",
id,
message: saveErrorMessage(
error,
"The pattern could not be deleted. Check your connection and try again.",
),
}),
);
};
body = (
@@ -424,7 +479,7 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
aria-pressed={c.id === child.id}
onClick={() => {
setSelectedId(c.id);
setSave({ kind: "idle" });
setPatternDeletion({ kind: "idle" });
}}
>
{c.display_name}
@@ -436,74 +491,48 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
<MonthGrid
month={month}
today={today}
schedule={schedule}
effective={effective}
patterns={patterns}
overrides={childOverrides}
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>
<h3 className="schedule-section-label">Presence pattern</h3>
<ul className="schedule-card schedule-list" aria-label="Presence pattern">
{data.children.map((c) => (
<li key={c.id} className="schedule-row" data-testid={`pattern-${c.id}`}>
<div className="schedule-row-text">
<div className="schedule-row-title">{c.display_name}</div>
</div>
<span className="schedule-rule">{presenceRule(data.schedules[c.id] ?? null)}</span>
</li>
))}
</ul>
<div className="schedule-section-head">
<h3 className="schedule-section-label">Presence patterns</h3>
<button
ref={addPatternRef}
type="button"
className="schedule-text-action"
aria-label="Add pattern"
onClick={() => setEditor({ kind: "pattern", pattern: null })}
>
Add
</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">
<h3 className="schedule-section-label">Overrides</h3>
@@ -511,7 +540,7 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
ref={addRef}
type="button"
className="schedule-text-action"
onClick={() => setEditorOpen(true)}
onClick={() => setEditor({ kind: "override" })}
>
Add
</button>
+104 -28
View File
@@ -1,23 +1,44 @@
import { describe, expect, it } from "vitest";
import {
cycleLabel,
cycleWeekIndex,
dayState,
emptyPattern,
monthDates,
monthStart,
presenceRule,
patternDaysLabel,
patternIncludes,
resizePattern,
togglePatternDay,
weekdayOf,
} 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.
const SCHEDULE: PresenceScheduleBody = {
type KindedCycle = PatternCycle & { kind: PatternKind };
// 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,
anchor_date: "2026-09-07",
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", () => {
it("follows the anchor across a month, wrapping every 2 weeks", () => {
const expected: Record<string, number> = {};
@@ -58,6 +79,24 @@ describe("calendar helpers", () => {
expect(weekdayOf("2026-09-06")).toBe(6);
});
it("weekdayOf keeps the stored Monday=0 .. Sunday=6 numbering (display order is separate)", () => {
// A full known week plus dates across month and year ends.
expect(
[
"2026-09-07",
"2026-09-08",
"2026-09-09",
"2026-09-10",
"2026-09-11",
"2026-09-12",
"2026-09-13",
].map(weekdayOf),
).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(weekdayOf("2026-11-01")).toBe(6); // Sunday
expect(weekdayOf("2027-01-01")).toBe(4); // Friday
expect(weekdayOf("2024-02-29")).toBe(3); // Thursday
});
it("monthStart shifts across year ends", () => {
expect(monthStart("2026-12-15", 1)).toBe("2027-01-01");
expect(monthStart("2026-01-31", -1)).toBe("2025-12-01");
@@ -71,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 },
];
it("applies overrides first, then the anchor-based pattern", () => {
expect(dayState(SCHEDULE, overrides, "2026-09-08")).toBe("home");
expect(dayState(SCHEDULE, overrides, "2026-09-11")).toBe("override-away");
expect(dayState(SCHEDULE, overrides, "2026-09-15")).toBe("away");
expect(dayState(SCHEDULE, overrides, "2026-09-16")).toBe("override-home");
expect(dayState(SCHEDULE, overrides, "2026-09-19")).toBe("home");
expect(dayState(SCHEDULE, overrides, "2026-09-01")).toBe("away");
expect(dayState(SCHEDULE, overrides, "2026-09-05")).toBe("home");
it("a home-only child: overrides first, then absent on days no home pattern covers", () => {
const patterns = [SCHEDULE];
expect(dayState(patterns, overrides, "2026-09-08")).toBe("home");
expect(dayState(patterns, overrides, "2026-09-11")).toBe("override-away");
expect(dayState(patterns, overrides, "2026-09-15")).toBe("away");
expect(dayState(patterns, overrides, "2026-09-16")).toBe("override-home");
expect(dayState(patterns, overrides, "2026-09-19")).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", () => {
expect(dayState(null, [], "2026-09-15")).toBe("home");
it("combines the owner's two away patterns; every other day is 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", () => {
expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
0: [0, 1, 2, 3, 4, 5, 6],
@@ -95,21 +173,19 @@ describe("pattern editing and rule text", () => {
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, 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", () => {
expect(presenceRule(null)).toBe("Every day");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [0, 1, 2, 3, 4, 5, 6] } })).toBe(
"Every day",
);
expect(presenceRule(SCHEDULE)).toBe("Weeks 1 & 2 of cycle");
expect(
presenceRule({ cycle_length_weeks: 4, anchor_date: "2026-09-07", pattern: { 0: [0], 1: [], 2: [3], 3: [] } }),
).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");
it("describes the cycle and the weekday set in plain language", () => {
expect(cycleLabel(1)).toBe("Every week");
expect(cycleLabel(2)).toBe("Every 2 weeks");
expect(patternDaysLabel(THU_FRI)).toBe("Thu, Fri");
expect(patternDaysLabel(ALTERNATE_WEEKENDS)).toBe("Week 1: Sat, Sun · Week 2: No days");
expect(patternDaysLabel(SCHEDULE)).toBe("Week 1: Every day · Week 2: Sat, Sun");
// Listed Monday-first by weekday number, whatever order they were stored in.
expect(patternDaysLabel({ ...THU_FRI, pattern: { "0": [6, 0] } })).toBe("Mon, Sun");
});
});
+58 -37
View File
@@ -10,12 +10,23 @@
* Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
* 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;
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. */
export function dayNumber(iso: string): number {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
@@ -67,44 +78,50 @@ export function monthDates(firstIso: string): string[] {
return dates;
}
/** Present every day for every week of the cycle (the no-schedule case). */
export function fullPattern(cycleLengthWeeks: number): PresencePattern {
const pattern: PresencePattern = {};
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [...ALL_WEEKDAYS];
/** Every week of the cycle covers no day yet (a new pattern). */
export function emptyPattern(cycleLengthWeeks: number): PatternWeeks {
const pattern: PatternWeeks = {};
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [];
return pattern;
}
export type DayState = "home" | "away" | "override-home" | "override-away";
export function patternIncludes(
schedule: PresenceScheduleBody,
date: string,
): boolean {
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
return (schedule.pattern[week] ?? []).includes(weekdayOf(date));
/** Does the pattern cover `date` (its cycle week lists the date's weekday)? */
export function patternIncludes(cycle: PatternCycle, date: string): boolean {
const week = cycleWeekIndex(cycle.anchor_date, date, cycle.cycle_length_weeks);
return (cycle.pattern[week] ?? []).includes(weekdayOf(date));
}
/**
* A date's state for one child: an override covering the date wins;
* otherwise the schedule's pattern decides (no schedule = home every day).
* A date's combined state for one child, mirroring core/presence.py
* `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(
schedule: PresenceScheduleBody | null,
patterns: (PatternCycle & { kind: PatternKind })[],
overrides: PresenceOverride[],
date: string,
): DayState {
const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
if (override) return override.is_present ? "override-home" : "override-away";
if (!schedule) return "home";
return patternIncludes(schedule, date) ? "home" : "away";
if (patterns.some((p) => p.kind === "away" && patternIncludes(p, date))) return "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. */
export function togglePatternDay(
pattern: PresencePattern,
pattern: PatternWeeks,
week: number,
weekday: number,
): PresencePattern {
): PatternWeeks {
const days = pattern[week] ?? [];
const next = days.includes(weekday)
? days.filter((d) => d !== weekday)
@@ -112,31 +129,35 @@ export function togglePatternDay(
return { ...pattern, [week]: next };
}
/** Fit the pattern to a new cycle length; added weeks start present every day. */
export function resizePattern(pattern: PresencePattern, cycleLengthWeeks: number): PresencePattern {
const next: PresencePattern = {};
/** Fit the pattern to a new cycle length; added weeks start covering no day. */
export function resizePattern(pattern: PatternWeeks, cycleLengthWeeks: number): PatternWeeks {
const next: PatternWeeks = {};
for (let week = 0; week < cycleLengthWeeks; week += 1) {
next[week] = pattern[week] ? [...pattern[week]] : [...ALL_WEEKDAYS];
next[week] = pattern[week] ? [...pattern[week]] : [];
}
return next;
}
function joinWeeks(weeks: number[]): string {
if (weeks.length === 1) return String(weeks[0]);
return `${weeks.slice(0, -1).join(", ")} & ${weeks[weeks.length - 1]}`;
function daysLabel(days: number[]): string {
if (days.length === 7) return "Every day";
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 {
if (!schedule) return "Every day";
const weeks: number[] = [];
let full = true;
for (let week = 0; week < schedule.cycle_length_weeks; week += 1) {
const days = schedule.pattern[week] ?? [];
if (days.length > 0) weeks.push(week + 1);
if (days.length < 7) full = false;
/**
* Plain-language weekday set: `Thu, Fri` for a 1-week cycle, otherwise one
* entry per cycle week, e.g. `Week 1: Sat, Sun · Week 2: No days`.
*/
export function patternDaysLabel(cycle: PatternCycle): string {
if (cycle.cycle_length_weeks === 1) return daysLabel(cycle.pattern[0] ?? []);
const weeks: string[] = [];
for (let week = 0; week < cycle.cycle_length_weeks; week += 1) {
weeks.push(`Week ${week + 1}: ${daysLabel(cycle.pattern[week] ?? [])}`);
}
if (full) return "Every day";
if (weeks.length === 0) return "Never home";
return `${weeks.length === 1 ? "Week" : "Weeks"} ${joinWeeks(weeks)} of cycle`;
return weeks.join(" · ");
}
/** `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 }) {
return (
<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")
#: 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.
core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence")
#: 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
#: services share.
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
#: read stored schedules and overrides back through.
#: read stored patterns and overrides back through.
core_dao_presence = importlib.import_module(
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
layer the HA services share, so the two planes cannot drift:
- ``PUT /children/{child_id}/presence-schedule`` sets (UPSERTS) the
child's repeating schedule: the body's ``cycle_length_weeks``,
``anchor_date`` and ``pattern`` go straight into ONE
:class:`~nestquest_core.presence.PresenceSchedule` (the model
validates cycle length, the strict anchor date and the pattern's
week coverage at construction), the core upserts by child (setting
again REPLACES the one schedule a child can have) and regenerates
the child's future instances; the response is the STORED schedule
decoded back through the model.
- ``GET /children/{child_id}/presence-patterns`` lists the child's
repeating presence patterns (zero or more, schema 9) through ONE
``list_presence_patterns`` call, wrapped as ``{"patterns": [...]}``
ordered by id — an empty list when the child has none (present
every day), never a 404; an unknown child is 404.
- ``POST /children/{child_id}/presence-patterns`` adds one pattern:
the body's ``name``, ``kind`` (``home``/``away``),
``cycle_length_weeks``, ``anchor_date`` and ``pattern`` go straight
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
body becomes ONE :class:`~nestquest_core.presence.PresenceOverride`
(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).
Nothing is written; an unknown child is 404, a malformed or inverted
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
ONE ``list_presence_overrides`` call, wrapped as
``{"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
weekdays, a malformed date, a forbidden field), a bad window name or
due time, a rejected window or assignee list (empty, duplicate,
wrong-typed); a rejected presence schedule or override (bad cycle
length, a pattern not covering the cycle's weeks, a malformed or
wrong-typed); a rejected presence pattern or override (a blank name,
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
existing one) — is mapped to 422: the body was well-formed JSON but
semantically invalid, the same failure class Pydantic reports. The
core rejects these BEFORE any write, so a 422 never leaves a
half-applied change behind.
- A non-existent CHILD on a presence route (setting a schedule or
creating an override for an unknown id) is mapped to 404 through the
SAME :func:`_raise_child_error` the children routes use — the
message reads ``child N does not exist``. A non-existent OVERRIDE
on the delete route is mapped to 404 through
- A non-existent CHILD on a presence route (listing or creating a
pattern, or creating an override, for an unknown id) is mapped to 404
through the SAME :func:`_raise_child_error` the children routes use —
the message reads ``child N does not exist``. A non-existent
OVERRIDE on the delete route is mapped to 404 through
:func:`_raise_presence_override_error`, keyed on 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-
``instance_id`` error (prefixed ``instance_id:``, the same
convention the panel complete route keys on) to 404; every other
@@ -327,7 +337,6 @@ from api.nestquest_core import (
core_dao_rules,
core_history,
core_materialize,
core_presence,
core_presence_management,
core_quest_definitions,
core_recurrence,
@@ -605,37 +614,70 @@ class AdminQuestDefinitionActiveRequest(BaseModel):
is_active: bool
class AdminPresenceScheduleRequest(BaseModel):
"""The body of ``PUT /api/v1/admin/children/{child_id}/presence-schedule``.
class AdminPresencePatternCreateRequest(BaseModel):
"""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
keys, coerced to ints by the model) to that week's present weekdays
(Monday=0); every week of the cycle must be covered, which the
PRESENCE MODEL enforces at construction, not this shape. A week
with an empty list means absent every day of that week.
keys, coerced to ints by the model) to the weekdays the pattern
covers that week (Monday=0); every week of the cycle must be
covered, which the PRESENCE MODEL enforces at construction, not
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
pattern: dict[int, list[int]]
pattern: dict[int, list[StrictInt]]
class AdminPresenceScheduleResponse(BaseModel):
"""A child's repeating presence schedule as the admin plane serializes it.
class AdminPresencePatternEditRequest(BaseModel):
"""The body of ``PATCH /api/v1/admin/presence-patterns/{pattern_id}``.
The stored schedule decoded back through
:meth:`nestquest_core.presence.PresenceSchedule.decode`: each
pattern week is the sorted list of present weekdays, and the child
has exactly ONE schedule (setting again replaces it).
Every field optional; only SUPPLIED fields are forwarded, and an
explicit ``null`` is forwarded too — and rejected by the model.
The pattern's child cannot be changed.
"""
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
name: str
kind: str
cycle_length_weeks: int
anchor_date: str
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):
"""The body of ``POST /api/v1/admin/presence-overrides``.
@@ -669,16 +711,6 @@ class AdminPresenceOverrideResponse(BaseModel):
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):
"""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
404. Every other ``ValueError`` — a non-integer id the path
parser's ``int`` already makes unreachable — becomes 422. The
schedule and override-CREATE routes do NOT use this mapper: their
only 404 is an unknown CHILD, mapped by :func:`_raise_child_error`.
pattern list/create and override-CREATE routes do NOT use this
mapper: their only 404 is an unknown CHILD, mapped by
:func:`_raise_child_error`.
"""
if str(error).startswith("override_id:"):
raise HTTPException(
@@ -999,6 +1032,26 @@ def _raise_presence_override_error(error: ValueError) -> NoReturn:
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.
_QUEST_INSTANCE_NOT_FOUND_DETAIL = "Quest instance not found"
@@ -1169,16 +1222,20 @@ def _quest_definition_response(
)
def _presence_schedule_response(
schedule: core_presence.PresenceSchedule,
) -> AdminPresenceScheduleResponse:
"""Serialize one decoded core PresenceSchedule into the payload."""
return AdminPresenceScheduleResponse(
child_id=schedule.child_id,
cycle_length_weeks=schedule.cycle_length_weeks,
anchor_date=schedule.anchor_date.isoformat(),
def _presence_pattern_response(
record: core_presence_management.PresencePatternRecord,
) -> AdminPresencePatternResponse:
"""Serialize one stored core pattern, weekday sets decoded."""
pattern = core_presence_management.decode_pattern_record(record)
return AdminPresencePatternResponse(
id=record.id,
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={
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)
@router.put(
"/children/{child_id}/presence-schedule",
summary="Set a child's repeating presence schedule",
response_model=AdminPresenceScheduleResponse,
@router.get(
"/children/{child_id}/presence-patterns",
summary="List a child's repeating presence patterns",
response_model=AdminPresencePatternListResponse,
)
async def admin_set_presence_schedule(
child_id: int, body: AdminPresenceScheduleRequest, request: Request
) -> AdminPresenceScheduleResponse:
"""Set (upsert) a child's repeating presence schedule.
async def admin_list_presence_patterns(
child_id: int, request: Request
) -> AdminPresencePatternListResponse:
"""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
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: the body model
carries the shape, and the cycle-length, anchor-date and
pattern-coverage policies all live in the
:class:`~nestquest_core.presence.PresenceSchedule` constructor the
core builds the schedule with — setting again REPLACES the child's
one schedule, and the core regenerates the child's future instances
so they track the new presence. Errors are mapped by
:func:`_raise_child_error`: an unknown child is 404, a rejected
schedule 422.
carries the shape, and the name, kind, cycle-length, anchor-date
and pattern-coverage policies all live in the
:class:`~nestquest_core.presence.PresencePattern` constructor the
core builds the pattern with; the core regenerates the child's
future instances so they track the new presence. Errors are
mapped by :func:`_raise_child_error`: an unknown child is 404, a
rejected pattern 422.
"""
state: DatabaseState = request.app.state.db
try:
schedule = await core_presence_management.set_presence_schedule(
record = await core_presence_management.create_presence_pattern(
state.database,
child_id,
body.name,
body.kind,
body.cycle_length_weeks,
body.anchor_date,
body.pattern,
)
except ValueError as 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(
@@ -1694,39 +1847,6 @@ async def admin_presence_override_consequence(
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(
"/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
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:
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting
by child updates the existing row rather than raising.
- A child with NO schedule row is present every day — the absence of a
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child`
returning None, never by an empty pattern row.
- A child has ZERO OR MORE presence patterns (schema 9 — no UNIQUE on
``child_id``); each is created, updated and deleted by its own id.
- A child with NO pattern row is present every day — the absence of a
row is meaningful state, expressed by
: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
the presence engine's job, Feature 05); the DAO only stores and
lists them. A single-day override stores the same date as both
@@ -32,11 +33,13 @@ from .db import NestQuestDatabase
@dataclass(frozen=True)
class PresenceScheduleRecord:
"""One row of ``presence_schedules``."""
class PresencePatternRecord:
"""One row of ``presence_patterns``."""
id: int
child_id: int
name: str
kind: str
cycle_length_weeks: int
anchor_date: str
pattern: str
@@ -54,21 +57,23 @@ class PresenceOverrideRecord:
note: str | None
_SCHEDULE_COLUMNS = (
"id, child_id, cycle_length_weeks, anchor_date, pattern"
_PATTERN_COLUMNS = (
"id, child_id, name, kind, cycle_length_weeks, anchor_date, pattern"
)
_OVERRIDE_COLUMNS = (
"id, child_id, start_date, end_date, is_present, note"
)
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord:
return PresenceScheduleRecord(
def _pattern_from_row(row: tuple) -> PresencePatternRecord:
return PresencePatternRecord(
id=row[0],
child_id=row[1],
cycle_length_weeks=row[2],
anchor_date=row[3],
pattern=row[4],
name=row[2],
kind=row[3],
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:
"""Typed async access to the ``presence_schedules`` table."""
class PresencePatternsDao:
"""Typed async access to the ``presence_patterns`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert_by_child(
async def create(
self,
child_id: int,
name: str,
kind: str,
cycle_length_weeks: int,
anchor_date: str,
pattern: str,
) -> PresenceScheduleRecord:
"""Set the child's presence schedule, replacing any existing one.
) -> PresencePatternRecord:
"""Insert one pattern for the child and return it as stored.
Upsert semantics are the done-condition: a second schedule for
the same child updates the existing row rather than raising.
Runs under the connection lock so a concurrent upsert/delete
for the same child serializes instead of colliding on the
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.
The child must exist (FK) — validated atomically under the
connection lock so a deleted child cannot pass a stale check.
``kind``, the cycle length and the pattern shape (segment count
= ``cycle_length_weeks``) are enforced by the schema CHECKs.
"""
_validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database):
@@ -115,53 +118,98 @@ class PresenceSchedulesDao:
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
await self._database.execute(
"INSERT INTO presence_schedules (child_id, "
result = await self._database.execute(
"INSERT INTO presence_patterns (child_id, name, kind, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT (child_id) DO UPDATE SET "
"cycle_length_weeks = excluded.cycle_length_weeks, "
"anchor_date = excluded.anchor_date, "
"pattern = excluded.pattern",
(child_id, cycle_length_weeks, anchor_date, pattern),
"VALUES (?, ?, ?, ?, ?, ?)",
(
child_id,
name,
kind,
cycle_length_weeks,
anchor_date,
pattern,
),
)
schedule = await self.get_by_child(child_id)
assert schedule is not None
return schedule
record = await self.get(result.lastrowid)
assert record is not None
return record
async def get_by_child(
self, child_id: int
) -> PresenceScheduleRecord | None:
"""Return the child's schedule, or None when the child has none.
async def list_by_child(self, child_id: int) -> list[PresencePatternRecord]:
"""Return the child's patterns ordered by id; empty when none.
None is meaningful and UNAMBIGUOUS here: the child exists and
has no schedule row, i.e. the child is present every day
(Feature 05 guardrail). An unknown child id raises ValueError
instead of returning None, because "nonexistent child" must
never be silently read as "present every day".
An empty list is meaningful and UNAMBIGUOUS here: the child
exists and has no pattern row, i.e. the child is present every
day (Feature 05 guardrail). An unknown child id raises
ValueError instead of returning an empty list, because
"nonexistent child" must never be silently read as "present
every day".
"""
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
row = await self._database.fetch_one(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
"WHERE child_id = ?",
rows = await self._database.fetch_all(
f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns "
"WHERE child_id = ? ORDER BY 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:
"""Remove the child's schedule; True if a row was removed.
async def get(self, pattern_id: int) -> PresencePatternRecord | None:
"""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
no restriction). Deleting a schedule the child never had
returns False.
async def update(
self,
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(
"DELETE FROM presence_schedules WHERE child_id = ?",
(child_id,),
"DELETE FROM presence_patterns WHERE id = ?",
(pattern_id,),
)
return result.rowcount > 0
@@ -345,22 +393,23 @@ async def read_snapshot_unlocked(
range_start: str,
range_end: str,
) -> tuple[
dict[int, PresenceScheduleRecord],
dict[int, list[PresencePatternRecord]],
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
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
materialization walk's single input snapshot does exactly that). The
date range comes already validated by the caller.
Returns ``(schedules, overrides)``:
Returns ``(patterns, overrides)``:
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
for every child that HAS a schedule row. A child absent from the
mapping has no schedule, i.e. is present every day (Feature 05).
- ``patterns`` maps ``child_id`` to its :class:`PresencePatternRecord`
list (ordered by id) for every child that HAS a pattern row. A
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
``[range_start, range_end]`` (inclusive boundaries), ordered by start
date; a child with no matching override is absent from the mapping.
@@ -372,9 +421,9 @@ async def read_snapshot_unlocked(
if not children:
return {}, {}
placeholders = ",".join("?" for _ in children)
schedule_rows = await database.fetch_all(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
f"WHERE child_id IN ({placeholders})",
pattern_rows = await database.fetch_all(
f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns "
f"WHERE child_id IN ({placeholders}) ORDER BY id",
tuple(children),
)
override_rows = await database.fetch_all(
@@ -383,12 +432,12 @@ async def read_snapshot_unlocked(
"AND start_date <= ? ORDER BY start_date",
tuple(children) + (range_start, range_end),
)
schedules = {
record.child_id: record
for record in map(_schedule_from_row, schedule_rows)
}
patterns: dict[int, list[PresencePatternRecord]] = {}
for row in pattern_rows:
record = _pattern_from_row(row)
patterns.setdefault(record.child_id, []).append(record)
overrides: dict[int, list[PresenceOverrideRecord]] = {}
for row in override_rows:
record = _override_from_row(row)
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.
Returns ``(snapshots, schedules, overrides)``:
Returns ``(snapshots, patterns, overrides)``:
- ``snapshots``: every active definition with its rule, assignees and
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
:mod:`.dao_presence`'s record shapes.
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
(definition, assignment, window, or presence) cannot interleave
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
schedules, overrides = await dao_presence.read_snapshot_unlocked(
patterns, overrides = await dao_presence.read_snapshot_unlocked(
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:
- 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
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
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
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
presence-state write so a child's future instances track its presence:
``set_presence_pattern`` (including clearing/setting an empty pattern),
deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
creating, updating and deleting a presence pattern
(:mod:`.presence_management`'s ``create_presence_pattern`` /
``update_presence_pattern`` / ``delete_presence_pattern``),
``create_presence_override``, and ``delete_presence_override``. A
schedule delete changes presence state too (the child becomes
always-present), so it must regenerate just like the others. This hook
pattern delete changes presence state too, so it must regenerate just
like the others. This hook
is documented, not yet wired: no presence business layer exists yet, and
the presence DAO (``dao_presence.py``) must stay free of business rules
— never call :func:`regenerate_for_child` from inside the DAO layer.
@@ -79,7 +79,7 @@ from .dao_rules import (
schedule_rule_storage_from_record,
)
from .db import NestQuestDatabase
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
from .presence import PresenceEngine, PresenceOverride, PresencePattern
from .recurrence import ScheduleRule, occurs_on
@@ -129,7 +129,7 @@ def _decode_rule(snapshot) -> ScheduleRule:
def _build_engine(
schedules_records,
patterns_records,
overrides_records,
*,
extra_overrides: tuple[PresenceOverride, ...] = (),
@@ -140,11 +140,18 @@ def _build_engine(
overrides on top of the stored ones — the consequence preview's
proposed override (:func:`count_removed_by_override`).
"""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
child_id, record.anchor_date, record.pattern
)
patterns: dict[int, list[PresencePattern]] = {}
for child_id, records in patterns_records.items():
patterns[child_id] = [
PresencePattern.decode(
record.child_id,
record.name,
record.kind,
record.anchor_date,
record.pattern,
)
for record in records
]
overrides: dict[int, list[PresenceOverride]] = {}
for child_id, records in overrides_records.items():
overrides[child_id] = [
@@ -159,7 +166,7 @@ def _build_engine(
]
for override in extra_overrides:
overrides.setdefault(override.child_id, []).append(override)
return PresenceEngine(schedules, overrides)
return PresenceEngine(patterns, overrides)
class GeneratedTuple(NamedTuple):
@@ -279,10 +286,10 @@ async def materialize(
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)
)
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)
generated_at = _now_stamp()
@@ -366,12 +373,11 @@ async def regenerate_for_definition(
# child-presence counterpart to ``regenerate_for_definition``. The
# presence business layer that lands in Feature 09 MUST call
# ``regenerate_for_child`` after EVERY presence-state write, so future
# instances track a child's changed presence: set_presence_pattern
# (including clearing/setting an empty pattern), deleting/clearing the
# presence schedule (PresenceSchedulesDao.delete),
# create_presence_override, and delete_presence_override. A schedule
# delete changes presence state too (the child becomes always-present),
# so it must regenerate just like the others. It is deliberately NOT
# instances track a child's changed presence: create_presence_pattern,
# update_presence_pattern, delete_presence_pattern,
# create_presence_override, and delete_presence_override. A pattern
# delete changes presence state too, so it must regenerate just like
# the others. It is deliberately NOT
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
# business rules), and no presence business layer exists yet to host the
# 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.
The Feature 09 presence services call this after every
presence-state write — set_presence_pattern (including clearing or
setting an empty pattern), deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
presence-state write — create_presence_pattern,
update_presence_pattern, delete_presence_pattern,
create_presence_override, and delete_presence_override — so a
child's future instances track its new presence: the child's open
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()
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)
)
current = _generation_preview(
snapshots,
_build_engine(schedules_records, overrides_records),
_build_engine(patterns_records, overrides_records),
start,
end,
child_filter={child_id},
@@ -495,7 +500,7 @@ async def count_removed_by_override(
_generation_preview(
snapshots,
_build_engine(
schedules_records,
patterns_records,
overrides_records,
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``
key/value table for integration-owned bookkeeping (Feature 11's
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
together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together.
@@ -85,6 +89,7 @@ from .schema import (
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
SCHEMA_V7_META_STATE_DDL,
SCHEMA_V9_PRESENCE_PATTERNS_DDL,
)
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
#: version ``n-1`` to version ``n``.
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
#: version ``n-1`` to version ``n``. Migration 1 is the v1 DDL as it
#: 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
#: 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
# pre-existing definition keeps today's skip-when-absent behaviour.
_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``,
overrides, previews) builds directly on this model.
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
week is this child at this house?" for an N-week repeating custody
pattern. Two of the household's three children follow a two-week
rotation; a child with NO schedule row is present every day (that
default lives in the engine, not here).
A :class:`PresencePattern` answers "on which weekdays of which cycle
week does this rule apply to this child?" for an N-week repeating
custody rule, and its ``kind`` says what the rule claims on those
days: ``home`` (the child is at this house) or ``away``. A child
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
pipe-separated CSV the database stores (see schema.py's
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
empty segment means absent every day of that week. Encoding sorts
each segment's weekdays and uses the exact segment order, so
``decode(encode(schedule))`` round-trips losslessly and two schedules
with equal patterns produce equal strings.
``presence_patterns.pattern``): segment ``i`` (week index ``i``) is a
comma-separated list of the weekdays the pattern covers, 0 = Monday ..
6 = Sunday, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
week 1. An empty segment means the pattern covers no day of that
week. Encoding sorts each segment's weekdays and uses the exact
segment order, so ``decode(encode(pattern))`` round-trips losslessly
and two patterns with equal weekday sets produce equal strings.
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
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.
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
the database CHECK (schema.py) and keeps anchor arithmetic and the
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).
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"
@@ -126,6 +135,25 @@ def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
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:
"""Reject non-int and out-of-range cycle lengths (bools included)."""
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)
class PresenceSchedule:
"""One child's N-week repeating presence pattern.
class PresencePattern:
"""One of a child's N-week repeating presence rules.
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
to the set of present weekdays (ints, Monday=0). A week with an
empty set means the child is absent every day of that week — which
is distinct from having no schedule at all (the engine treats a
missing schedule as present every day).
to the set of weekdays the rule covers (ints, Monday=0); ``kind``
is what the rule claims on a covered date (``home`` or ``away``).
A week with an empty set covers no day of that week. How several
patterns combine is the engine's job (:class:`PresenceEngine`).
"""
child_id: int
name: str
kind: str
cycle_length_weeks: int
anchor_date: datetime.date
pattern: MappingProxyType # week index -> frozenset of weekdays
@@ -166,11 +196,15 @@ class PresenceSchedule:
def __init__(
self,
child_id: int,
name: str,
kind: str,
cycle_length_weeks: int,
anchor_date: object,
pattern: dict[int, object],
) -> None:
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)
object.__setattr__(self, "cycle_length_weeks", cycle)
object.__setattr__(
@@ -178,7 +212,7 @@ class PresenceSchedule:
)
if not isinstance(pattern, dict):
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}"
)
normalised: dict[int, frozenset[int]] = {}
@@ -204,9 +238,9 @@ class PresenceSchedule:
"""Serialize to the database's pipe-separated CSV shape.
Segments appear in week order; each segment is the week's
present weekdays as a sorted comma-separated list (an empty set
encodes as an empty segment). Two schedules with equal
patterns encode identically.
covered weekdays as a sorted comma-separated list (an empty set
encodes as an empty segment). Two patterns with equal weekday
sets encode identically.
"""
segments = [
",".join(str(day) for day in sorted(self.pattern[week]))
@@ -218,10 +252,12 @@ class PresenceSchedule:
def decode(
cls,
child_id: int,
name: str,
kind: str,
anchor_date: object,
encoded: str,
) -> "PresenceSchedule":
"""Build a schedule from its encoded pattern.
) -> "PresencePattern":
"""Build a pattern from its encoded weekday sets.
The cycle length is derived from the segment count, mirroring
the database's segment-count CHECK. Decoding is strict: wrong
@@ -248,7 +284,15 @@ class PresenceSchedule:
)
weekdays.add(int(part))
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)
@@ -301,7 +345,7 @@ class PresenceOverride:
def cycle_week_index(
schedule: PresenceSchedule, target_date: object
pattern: PresencePattern, target_date: object
) -> int:
"""Return the cycle week index ``target_date`` falls in.
@@ -315,14 +359,14 @@ def cycle_week_index(
valid week index.
"""
target = _parse_date(target_date, "target_date")
days = (target - schedule.anchor_date).days
return (days // 7) % schedule.cycle_length_weeks
days = (target - pattern.anchor_date).days
return (days // 7) % pattern.cycle_length_weeks
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
#: years of daily checks. A child absent every day (an all-absent
#: pattern with a blocking override) would otherwise loop forever; the
#: cap bounds the scan and surfaces the situation as an error instead.
#: years of daily checks. A child absent every day (an away pattern
#: covering every weekday) would otherwise loop forever; the cap bounds
#: the scan and surfaces the situation as an error instead.
MAX_PREVIEW_SCAN_DAYS = 366 * 2
@@ -330,40 +374,45 @@ class PresenceEngine:
"""Answers "is this child at this house on this date?".
Pure and side-effect-free: built from an immutable snapshot of the
children's schedules (``child_id -> PresenceSchedule``), it never
touches the database — callers build a snapshot from the DAO layer
and pass it in. A child with NO schedule entry is present every
day (the third household child's case); a child WITH a schedule is
present exactly when the date's weekday is in that schedule's
pattern for the date's cycle week index (anchor-date arithmetic —
:func:`cycle_week_index`, never ISO week parity).
children's patterns (``child_id -> list of PresencePattern``), it
never touches the database — callers build a snapshot from the DAO
layer and pass it in. A child with NO pattern is present every day
(the third household child's case). Evaluation order, first match
wins — see :meth:`is_present`. Pattern coverage is anchor-date
arithmetic (:func:`cycle_week_index`, never ISO week parity).
"""
def __init__(
self,
schedules: dict[int, PresenceSchedule],
patterns: dict[int, list[PresencePattern]],
overrides: dict[int, list[PresenceOverride]] | None = None,
) -> None:
if not isinstance(schedules, dict):
if not isinstance(patterns, dict):
raise ValueError(
f"schedules must map child_id to PresenceSchedule, got "
f"{schedules!r}"
"patterns must map child_id to a list of PresencePattern, "
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
# 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.
_validate_child_id(child_id)
if not isinstance(schedule, PresenceSchedule):
if not isinstance(entries, list):
raise ValueError(
f"schedules[{child_id!r}] must be a PresenceSchedule, "
f"got {schedule!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}"
f"patterns[{child_id!r}] must be a list of "
f"PresencePattern, got {entries!r}"
)
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:
overrides = {}
if not isinstance(overrides, dict):
@@ -391,11 +440,17 @@ class PresenceEngine:
)
# Read-only snapshot: reassignment is refused and no mutator
# exists, so a built engine answers consistently for its
# lifetime. The overrides lists are copied to immutable
# tuples — a shallow mapping copy alone would leave caller-
# owned lists reachable through _overrides, and mutating one
# later would change is_present results.
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
# lifetime. The patterns and overrides lists are copied to
# immutable tuples — a shallow mapping copy alone would leave
# caller-owned lists reachable, and mutating one later would
# change is_present results.
object.__setattr__(
self,
"_patterns",
MappingProxyType(
{child_id: tuple(entries) for child_id, entries in patterns.items()}
),
)
object.__setattr__(
self,
"_overrides",
@@ -407,12 +462,22 @@ class PresenceEngine:
def is_present(self, child_id: object, date: object) -> bool:
"""Return whether ``child_id`` is present on ``date``.
Overrides take precedence over the repeating pattern: when an
override covers the date, its flag IS the answer — including
for a child with no schedule at all. The DAO layer guarantees
a child's overrides never overlap; if a snapshot still carries
two covering the same date, the one with the LATEST start date
wins (deterministic, and matches "the most specific swap wins").
Ordered, first match wins:
1. An override covering the date — its flag IS the answer,
including for a child with no pattern at all. The DAO layer
guarantees a child's overrides never overlap; if a snapshot
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):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
@@ -424,12 +489,16 @@ class PresenceEngine:
if covering:
winner = max(covering, key=lambda entry: entry.start_date)
return winner.is_present
schedule = self._schedules.get(child_id)
if schedule is None:
# No schedule at all: present every day (household default).
patterns = self._patterns.get(child_id) or ()
if any(
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
week = cycle_week_index(schedule, target)
return target.weekday() in schedule.pattern[week]
return not homes
def next_present_dates(
self,
@@ -442,7 +511,7 @@ class PresenceEngine:
Walks forward from ``start_date`` (INCLUSIVE — a parent
previewing "starting Monday" expects Monday itself when the
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
a custody pattern is right before saving it.
@@ -2,11 +2,11 @@
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 pure presence model: callers get the model validation, the upsert /
create / delete operations and the regeneration side effect here, and
the pure presence model: callers get the model validation, the create /
update / delete operations and the regeneration side effect here, and
never touch the DAO or SQL directly. This is the ONE implementation of
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
handler) and gets the same post-write
: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
host clock there.
Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence
schedule — setting again REPLACES the existing row rather than
duplicating it. Overrides are deletable by design (children are not —
the children layer owns that guardrail). Deleting a nonexistent
override raises ValueError prefixed ``override_id:`` so a caller can
distinguish "unknown id" from a rejected argument the same way the
Pattern policy (schema 9): a child has ZERO OR MORE presence patterns,
each created, edited and deleted by its own id. Patterns and overrides
are deletable by design (children are not — the children layer owns
that guardrail). An unknown pattern or override id raises ValueError
prefixed ``pattern_id:`` / ``override_id:`` so a caller can distinguish
"unknown id" from a rejected argument the same way the
quest-definitions layer prefixes ``definition_id:``.
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.
"""
from __future__ import annotations
@@ -35,11 +35,13 @@ from .dao_children import ChildrenDao
from .dao_presence import (
PresenceOverrideRecord,
PresenceOverridesDao,
PresenceSchedulesDao,
PresencePatternRecord,
PresencePatternsDao,
)
from .dao_rules import _UNSET
from .db import NestQuestDatabase
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:
@@ -49,49 +51,159 @@ def _validate_override_id(value: object) -> int:
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,
child_id: int,
name: str,
kind: str,
cycle_length_weeks: int,
anchor_date: object,
pattern: dict[int, object],
*,
today: datetime.date | None = None,
horizon_days: int | None = None,
) -> PresenceSchedule:
"""Set (upsert) a child's repeating presence schedule.
) -> PresencePatternRecord:
"""Add one repeating presence pattern to a child.
The :class:`~.presence.PresenceSchedule` model validates at
construction — cycle length 1..4, a strict calendar anchor date,
and a pattern covering week indices 0..n-1 with weekday sets — so
no invalid schedule reaches the DAO. The encoded pattern is
UPSERTed by child (a second set REPLACES the child's existing
schedule; the schema's UNIQUE(child_id) makes duplication
unrepresentable), then :func:`~.materialize.regenerate_for_child`
The :class:`~.presence.PresencePattern` model validates at
construction — a non-blank name, ``kind`` ``home``/``away``, cycle
length 1..4, a strict calendar anchor date, and a pattern covering
week indices 0..n-1 with weekday sets — so no invalid pattern
reaches the DAO. The encoded pattern is inserted beside the
child's other patterns, then :func:`~.materialize.regenerate_for_child`
re-materializes the child's future instances against the new
presence. ``today``/``horizon_days`` thread through to that
regeneration; raising ValueError names what to fix (an unknown
child reads ``child N does not exist``).
Returns the STORED schedule decoded back through the model — the
round-trip proves the encoded pattern the database now holds is
exactly the one the caller asked for.
Returns the STORED record WITH its ``id`` — the handle
:func:`update_presence_pattern` and :func:`delete_presence_pattern`
take.
"""
schedule = PresenceSchedule(
child_id, cycle_length_weeks, anchor_date, pattern
model = PresencePattern(
child_id, name, kind, cycle_length_weeks, anchor_date, pattern
)
record = await PresenceSchedulesDao(database).upsert_by_child(
schedule.child_id,
schedule.cycle_length_weeks,
schedule.anchor_date.isoformat(),
schedule.encode(),
record = await PresencePatternsDao(database).create(
model.child_id,
model.name,
model.kind,
model.cycle_length_weeks,
model.anchor_date.isoformat(),
model.encode(),
)
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(
record.child_id, record.anchor_date, record.pattern
return record
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(
@@ -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
) -> PresenceSchedule | None:
"""Return the child's presence schedule decoded, or None when unset.
) -> list[PresencePatternRecord]:
"""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
(``child N does not exist``) rather than reading as present.
"""
record = await PresenceSchedulesDao(database).get_by_child(child_id)
if record is None:
return None
return PresenceSchedule.decode(
record.child_id, record.anchor_date, record.pattern
)
return await PresencePatternsDao(database).list_by_child(child_id)
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
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
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``)
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; a wholly empty pattern with ``cycle_length_weeks = 1`` means
absent every day of every week, which is distinct from having no
schedule row at all (a child with no schedule is present every day, per
the feature guardrails). Chosen over JSON 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
week 1. An empty segment means the pattern covers no day of that week.
A child carries zero or more patterns, each ``kind`` ``home`` or
``away``; a child with no pattern row at all is present every day, per
the feature guardrails (how patterns combine is the presence engine's
job). The encoding and its CHECKs are carried over verbatim from the
retired ``presence_schedules`` table (schema 9). Chosen over JSON 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
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
(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
one-row-per-``week_index`` normalization was considered and rejected:
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
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] = [
f"""
CREATE TABLE IF NOT EXISTS presence_schedules (
#: ``presence_patterns`` (schema version 9): zero or more repeating
#: presence rules per child, replacing the retired one-per-child
#: ``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,
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),
anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL CHECK (
@@ -407,7 +417,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V9_PRESENCE_PATTERNS_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
+32 -21
View File
@@ -39,14 +39,14 @@ from typing import Any
from .completion import derive_state
from .dao_children import ChildrenDao
from .dao_instances import CompletionEventsDao, QuestInstancesDao
from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao
from .dao_presence import PresenceOverridesDao, PresencePatternsDao
from .dao_rules import QuestDefinitionsDao
from .db import NestQuestDatabase
from .presence import (
MAX_PREVIEW_SCAN_DAYS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
PresencePattern,
)
from .settings import NestQuestSettings
@@ -98,20 +98,26 @@ class NestQuestSnapshot:
def _cycle_day(
schedules: dict[int, PresenceSchedule], children, today: datetime.date
patterns: dict[int, list[PresencePattern]],
children,
today: datetime.date,
) -> 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 schedule's own anchor, never ISO week numbers or parity, so a
53-week year cannot silently invert the cycle.
The cycle is that child's FIRST pattern (lowest id) — one number
for the household's cycle-day sensor even when a child carries
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:
schedule = schedules.get(child.id)
if schedule is None:
entries = patterns.get(child.id)
if not entries:
continue
days = (today - schedule.anchor_date).days
cycle_length = schedule.cycle_length_weeks * 7
pattern = entries[0]
days = (today - pattern.anchor_date).days
cycle_length = pattern.cycle_length_weeks * 7
return (days % cycle_length) + 1
return 0
@@ -191,16 +197,21 @@ async def build_snapshot(
children = await ChildrenDao(database).list_active()
schedules: dict[int, PresenceSchedule] = {}
patterns: dict[int, list[PresencePattern]] = {}
overrides: dict[int, list[PresenceOverride]] = {}
for child in children:
schedule = await PresenceSchedulesDao(database).get_by_child(
child.id
)
if schedule is not None:
schedules[child.id] = PresenceSchedule.decode(
child.id, schedule.anchor_date, schedule.pattern
)
records = await PresencePatternsDao(database).list_by_child(child.id)
if records:
patterns[child.id] = [
PresencePattern.decode(
record.child_id,
record.name,
record.kind,
record.anchor_date,
record.pattern,
)
for record in records
]
window_end = today + datetime.timedelta(
days=MAX_PREVIEW_SCAN_DAYS
)
@@ -222,7 +233,7 @@ async def build_snapshot(
)
for record in ranged
]
engine = PresenceEngine(schedules, overrides)
engine = PresenceEngine(patterns, overrides)
instances_dao = QuestInstancesDao(database)
events_dao = CompletionEventsDao(database)
@@ -302,5 +313,5 @@ async def build_snapshot(
return NestQuestSnapshot(
today_iso=today_iso,
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
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):
- Authentication runs through the router's ONE ``require_admin``
dependency: an admin JWT passes, a JWT without the group is 403, an
absent credential is 401 — and nothing is written.
- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the
first set persists the schedule, setting again REPLACES it — one
schedule row per child, never a duplicate.
dependency on EVERY presence route (the four pattern routes and the
override routes): an admin JWT passes, a JWT without the group or the
panel service token is 403, an absent credential is 401 — and
nothing is written.
- ``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
``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}``
removes it again.
- The child's RESOLVED presence changes accordingly: the pure
:class:`~nestquest_core.presence.PresenceEngine` built from the
stored rows answers differently before/after each write — a schedule
restricts the child's present weekdays, an override beats the pattern
for its range, and deleting the override restores the pattern's
answer.
stored rows answers differently before/after each write — a home
pattern restricts the child's present weekdays, an away pattern beats
a home one, an override beats every pattern for its range, and
deleting the override (or the pattern) restores the earlier answer.
- The regeneration side effect lives in the core layer the route
calls: setting an all-absent schedule through the route deletes the
child's future open quest instances and they are NOT rebuilt while
the child is absent every day — the same proof the HA service test
makes, through the API instead.
calls: adding an all-absent home pattern through the route deletes
the child's future open quest instances and they are NOT rebuilt
while the child is absent every day; deleting that pattern rebuilds
them — the same proof the HA service test makes, through the API.
Error mapping (documented in api/routes_admin.py): a core ValueError
for a non-existent child (schedule or override create) or override
(delete) is 404; every other core ValueError — a rejected schedule
(cycle length, pattern coverage, anchor date) or override (malformed or
inverted dates, an overlapping range) — is 422, the same failure class
for a non-existent child (pattern or override create) is 404 "Child not
found", a non-existent pattern (edit/delete) 404 "Presence pattern not
found", a non-existent override (delete) 404; every other core
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.
"""
from __future__ import annotations
@@ -64,24 +71,54 @@ _dao_instances = importlib.import_module("nestquest_core.dao_instances")
_materialize = importlib.import_module("nestquest_core.materialize")
_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"
#: 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"
#: 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]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _schedule_body(
cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None
def _patterns_url(child_id: int) -> str:
"""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]:
"""A valid schedule body anchored to the test Monday."""
"""A valid pattern body anchored to the test Monday (Mon/Wed/Fri home)."""
return {
"name": name,
"kind": kind,
"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,
}
@@ -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:
"""Create one child through the admin children route; return the id."""
created = await client.post(
@@ -114,26 +163,44 @@ async def _create_child(client: object, name: str = "Ada") -> int:
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(
database: object,
child_id: int,
schedule_record: object | None,
pattern_records: list[object],
override_records: list[object],
) -> object:
"""Build the pure PresenceEngine from stored rows (the snapshot shape).
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
routes produced.
"""
schedules: dict[int, object] = {}
if schedule_record is not None:
schedules[child_id] = _presence.PresenceSchedule.decode(
schedule_record.child_id,
schedule_record.anchor_date,
schedule_record.pattern,
)
patterns = {
child_id: [
_presence.PresencePattern.decode(
record.child_id,
record.name,
record.kind,
record.anchor_date,
record.pattern,
)
for record in pattern_records
]
}
overrides = {
child_id: [
_presence.PresenceOverride(
@@ -146,21 +213,21 @@ def _engine(
for record in override_records
]
}
return _presence.PresenceEngine(schedules, overrides)
return _presence.PresenceEngine(patterns, overrides)
async def _child_state(
database: object, child_id: int
) -> tuple[object | None, list[object], object]:
"""The child's stored schedule record, overrides and PresenceEngine."""
schedule_record = await _dao_presence.PresenceSchedulesDao(
) -> tuple[list[object], list[object], object]:
"""The child's stored pattern records, overrides and PresenceEngine."""
pattern_records = await _dao_presence.PresencePatternsDao(
database
).get_by_child(child_id)
).list_by_child(child_id)
override_records = await _dao_presence.PresenceOverridesDao(
database
).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01")
engine = _engine(database, child_id, schedule_record, override_records)
return schedule_record, override_records, engine
engine = _engine(database, child_id, pattern_records, override_records)
return pattern_records, override_records, engine
@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_headers = {"Authorization": f"Bearer {non_admin}"}
forbidden_schedule = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
forbidden_pattern = await client.post(
_patterns_url(child_id),
json=_pattern_body(),
headers=non_admin_headers,
)
assert forbidden_schedule.status_code == 403
assert forbidden_schedule.json()["detail"] == (
"Admin access requires membership in the nestquest-admins group"
)
assert forbidden_pattern.status_code == 403
assert forbidden_pattern.json()["detail"] == FORBIDDEN_DETAIL
forbidden_override = await client.post(
"/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
unauthenticated_schedule = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
unauthenticated_pattern = await client.post(
_patterns_url(child_id), json=_pattern_body()
)
assert unauthenticated_schedule.status_code == 401
assert unauthenticated_pattern.status_code == 401
unauthenticated_override = await client.post(
"/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
# No route ran its handler: the child still has no schedule and no
# overrides (and is present every day, the no-schedule default).
_schedule, _overrides, engine = await _child_state(
# No route ran its handler: the child still has no pattern and no
# overrides (and is present every day, the no-pattern default).
patterns, overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert _schedule is None
assert _overrides == []
assert patterns == []
assert overrides == []
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,
) -> 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
child_id = await _create_child(client)
first = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
first = await client.post(
_patterns_url(child_id),
json=_pattern_body(),
headers=_admin_headers(),
)
assert first.status_code == 200
assert first.json() == {
assert first.status_code == 201
first_payload = first.json()
assert first_payload == {
"id": first_payload["id"],
"child_id": child_id,
"name": "Mum's house",
"kind": "home",
"cycle_length_weeks": 1,
"anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [0, 2, 4]},
}
# Database proof: exactly ONE schedule row (the schema's
# UNIQUE(child_id) makes a second row unrepresentable), stored as
# encoded CSV.
schedule, _overrides, _engine = await _child_state(
# Database proof: ONE pattern row, stored as encoded CSV.
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert schedule is not None
row_id = schedule.id
assert schedule.cycle_length_weeks == 1
assert schedule.anchor_date == ANCHOR_MONDAY
assert schedule.pattern == "0,2,4"
assert [record.id for record in patterns] == [first_payload["id"]]
assert patterns[0].name == "Mum's house"
assert patterns[0].kind == "home"
assert patterns[0].cycle_length_weeks == 1
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
# values, unchanged id — never a duplicate or a stale first set.
second = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(
cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]}
# Creating again ADDS a second pattern beside the first — the first
# row is untouched (schema 9 has no one-per-child constraint).
second = await client.post(
_patterns_url(child_id),
json=_pattern_body(
cycle_length_weeks=2,
pattern={"0": [], "1": [3, 1]},
name=" Holiday rota ",
kind="away",
),
headers=_admin_headers(),
)
assert second.status_code == 200
assert second.json() == {
assert second.status_code == 201
second_payload = second.json()
assert second_payload == {
"id": second_payload["id"],
"child_id": child_id,
"name": "Holiday rota",
"kind": "away",
"cycle_length_weeks": 2,
"anchor_date": ANCHOR_MONDAY,
"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
)
assert schedule is not None
assert schedule.id == row_id
assert schedule.cycle_length_weeks == 2
assert schedule.pattern == "|1,3"
assert [record.id for record in patterns] == [
first_payload["id"],
second_payload["id"],
]
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,
) -> None:
"""The resolved presence (PresenceEngine) follows the stored schedule."""
"""The resolved presence (PresenceEngine) follows the stored patterns."""
client = admin_presence.client
child_id = await _create_child(client)
# Before any schedule the child is present every day.
_schedule, _overrides, engine = await _child_state(
# Before any pattern the child is present every day.
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is True
# Mon/Wed/Fri only, week 0 of a one-week cycle.
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
headers=_admin_headers(),
)
assert response.status_code == 200
_schedule, _overrides, engine = await _child_state(
# Home Mon/Wed/Fri only, week 0 of a one-week cycle.
await _create_pattern(client, child_id)
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
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,
) -> 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
calls: the child's open instances at or after today are deleted
across ALL its definitions and NOT rebuilt while the child is
absent every day — the same proof the HA service test makes,
exercised here through the API route instead.
absent every day; deleting the pattern rebuilds them.
"""
client = admin_presence.client
database = admin_presence.database
@@ -360,16 +536,257 @@ async def test_schedule_upsert_regenerates_future_instances(
instances = _dao_instances.QuestInstancesDao(database)
assert await instances.list_by_date_range(child_id, start, end)
# The route's write must regenerate: absent every day removes every
# open future instance.
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(pattern={"0": []}),
# The route's write must regenerate: a home pattern covering no day
# makes the child absent every day and removes every open instance.
pattern = await _create_pattern(
client, child_id, _pattern_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(),
)
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) == []
# 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 -----------------------------------------------
@@ -381,12 +798,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
client = admin_presence.client
database = admin_presence.database
child_id = await _create_child(client)
schedule_response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
headers=_admin_headers(),
)
assert schedule_response.status_code == 200
await _create_pattern(client, child_id)
# Create: the override comes back with its id and the posted shape.
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
# 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 engine.is_present(child_id, TUESDAY) is True
# 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"])
is None
)
_schedule, overrides, engine = await _child_state(database, child_id)
_patterns, overrides, engine = await _child_state(database, child_id)
assert overrides == []
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 ----------------------------------------------
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,
) -> None:
"""A presence write naming a child that does not exist is 404."""
client = admin_presence.client
schedule = await client.put(
"/api/v1/admin/children/999/presence-schedule",
json=_schedule_body(),
pattern = await client.post(
_patterns_url(999),
json=_pattern_body(),
headers=_admin_headers(),
)
assert schedule.status_code == 404
assert schedule.json()["detail"] == "Child not found"
assert pattern.status_code == 404
assert pattern.json()["detail"] == "Child not found"
override = await client.post(
"/api/v1/admin/presence-overrides",
@@ -462,42 +874,70 @@ async def test_schedule_and_override_for_unknown_child_are_404(
@pytest.mark.parametrize(
"body",
("body", "field"),
[
_schedule_body(cycle_length_weeks=0), # below the 1..4 range
_schedule_body(cycle_length_weeks=5), # above the documented cap
(_pattern_body(cycle_length_weeks=0), "cycle_length_weeks"),
(_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:
_schedule_body(cycle_length_weeks=2, pattern={"0": [0]}),
{
"cycle_length_weeks": 1,
"anchor_date": "2026-13-40",
"pattern": {"0": [0]},
},
_schedule_body(pattern={"0": [7]}), # weekday out of 0..6
_schedule_body(pattern={"nope": [0]}), # week index not an integer
_schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list
(_pattern_body(cycle_length_weeks=2, pattern={"0": [0]}), "pattern"),
(_pattern_body(pattern={}), "pattern"),
(_pattern_body(anchor_date="2026-13-40"), "anchor_date"),
(_pattern_body(anchor_date="05/01/2026"), "anchor_date"),
(_pattern_body(pattern={"0": [7]}), "pattern"), # weekday out of 0..6
# Strict JSON integer weekdays: no bool, string or float coercion.
(_pattern_body(pattern={"0": [True]}), "pattern"),
(_pattern_body(pattern={"0": [False]}), "pattern"),
(_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(
admin_presence: SimpleNamespace, body: dict[str, object]
async def test_rejected_pattern_is_422(
admin_presence: SimpleNamespace, body: dict[str, object], field: str
) -> None:
"""A rejected schedule is 422 and stores nothing."""
"""A rejected pattern is 422 naming the field, and stores nothing."""
client = admin_presence.client
child_id = await _create_child(client)
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
response = await client.post(
_patterns_url(child_id),
json=body,
headers=_admin_headers(),
)
assert response.status_code == 422
assert response.status_code == 422, response.text
assert _detail_names(response, field), response.json()
assert (
await _dao_presence.PresenceSchedulesDao(
await _dao_presence.PresencePatternsDao(
admin_presence.database
).get_by_child(child_id)
is None
).list_by_child(child_id)
== []
)
@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(
"body",
[
@@ -548,7 +988,7 @@ async def test_overlapping_override_is_422_and_first_survives(
headers=_admin_headers(),
)
assert response.status_code == 422
_schedule, overrides, _engine = await _child_state(
_patterns, overrides, _engine = await _child_state(
admin_presence.database, child_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"]
async def _set_custody_schedule(client: object, child_id: int) -> None:
"""Anchor the two-week custody cycle on today (week 0 starts today)."""
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
async def _set_custody_pattern(client: object, child_id: int) -> None:
"""Add the two-week custody home pattern anchored on today (week 0)."""
response = await client.post(
f"/api/v1/admin/children/{child_id}/presence-patterns",
json={
"name": "Custody",
"kind": "home",
"cycle_length_weeks": 2,
"anchor_date": _day(0),
"pattern": CUSTODY_PATTERN,
},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.status_code == 201
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"],
skip_on_away=False,
)
await _set_custody_schedule(client, child_id)
await _set_custody_pattern(client, child_id)
await _materialize_horizon(database)
yield SimpleNamespace(
client=client,
@@ -290,7 +292,7 @@ async def test_skip_on_away_false_definition_contributes_nothing(
client, child_id, "Feed the fish", ["morning"],
skip_on_away=False,
)
await _set_custody_schedule(client, child_id)
await _set_custody_pattern(client, child_id)
await _materialize_horizon(database)
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).
``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
httpx, through the shared stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) — no network access.
- The schedule read answers ``{"schedule": {...}}`` in the PUT
response's shape for a seeded schedule, ``{"schedule": null}`` for a
child with none (present every day — NOT a 404), and 404 for an
unknown child.
- The patterns read answers ``{"patterns": [...]}`` in the POST
response's shape for seeded patterns, ordered by id (a child may have
several), ``{"patterns": []}`` for a child with none (present every
day — NOT a 404), and 404 for an unknown child.
- The overrides read answers ``{"overrides": [...]}`` in the POST
response's shape, ordered by start date, child id, id; ``child_id``
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:
return f"/api/v1/admin/children/{child_id}/presence-schedule"
def _patterns_url(child_id: int) -> str:
return f"/api/v1/admin/children/{child_id}/presence-patterns"
def _admin_headers() -> dict[str, str]:
@@ -99,20 +99,23 @@ async def reads(temp_db_path: str) -> SimpleNamespace:
@pytest.fixture
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
ada = await _create_child(client, "Ada")
ben = await _create_child(client, "Ben")
put = await client.put(
_schedule_url(ada),
created = await client.post(
_patterns_url(ada),
json={
"name": "Custody",
"kind": "home",
"cycle_length_weeks": 2,
"anchor_date": "2026-01-05",
"pattern": {"0": [4, 0, 2], "1": []},
},
headers=_admin_headers(),
)
assert put.status_code == 200
assert created.status_code == 201, created.text
ada_custody = created.json()
ada_march = await _create_override(
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_march=ben_march,
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,
) -> 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(
_schedule_url(seeded.ada), headers=_admin_headers()
_patterns_url(seeded.ada), headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {
"schedule": {
"child_id": seeded.ada,
"cycle_length_weeks": 2,
"anchor_date": "2026-01-05",
"pattern": {"0": [0, 2, 4], "1": []},
}
"patterns": [
{
"id": seeded.ada_custody["id"],
"child_id": seeded.ada,
"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,
) -> None:
"""A child with no schedule (present every day) is schedule: null."""
response = await seeded.client.get(
_schedule_url(seeded.ben), headers=_admin_headers()
"""A child's patterns (zero or more) are listed ordered by id."""
client = seeded.client
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.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,
) -> None:
"""An unknown child is 404 with the children routes' detail."""
response = await reads.client.get(
_schedule_url(999), headers=_admin_headers()
_patterns_url(999), headers=_admin_headers()
)
assert response.status_code == 404
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 -----------------------------------------------
@@ -302,12 +367,12 @@ async def test_overrides_read_unknown_child_is_404(
# --- 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:
"""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":
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(
+5 -3
View File
@@ -141,7 +141,7 @@ async def _seed_household(
[bo.id],
[("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).
await _materialize.materialize(
database,
@@ -183,12 +183,14 @@ async def _seed_household(
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
# anchored yesterday, empty week 0 covers today => absent
# today; an override returns her on today+2.
await _dao_presence.PresenceSchedulesDao(database).upsert_by_child(
await _dao_presence.PresencePatternsDao(database).create(
ada.id,
name="Custody",
kind="home",
cycle_length_weeks=2,
anchor_date=yesterday_iso,
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_presence import (
PresenceOverridesDao,
PresenceSchedulesDao,
PresencePatternsDao,
)
from custom_components.nestquest.core.db import NestQuestDatabase
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:
"""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
``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``.
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
scheduled active child is Ada, whose 2-week cycle anchored one day
before today puts ``cycle_day`` at 2.
patterned active child is Ada, whose FIRST (lowest id) pattern is a
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
# 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
# on today+2, so next_present resolves to that date and cycle_day is
# ((today - (today-1)) % 14) + 1 == 2.
await PresenceSchedulesDao(database).upsert_by_child(
patterns = PresencePatternsDao(database)
await patterns.create(
ada.id,
name="Home schedule",
kind="home",
cycle_length_weeks=2,
anchor_date=(today - datetime.timedelta(days=1)).isoformat(),
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)
await PresenceOverridesDao(database).create(
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 (
PresenceOverridesDao,
PresenceSchedulesDao,
PresencePatternsDao,
)
from custom_components.nestquest.core.dao_rules import (
ScheduleRulesDao,
@@ -72,7 +72,7 @@ ALL_TABLES = (
"schedule_rules",
"quest_definitions",
"quest_definition_assignees",
"presence_schedules",
"presence_patterns",
"presence_overrides",
"quest_instances",
"completion_events",
@@ -88,7 +88,7 @@ class World:
self.admins = AdminUsersDao(database)
self.rules = ScheduleRulesDao(database)
self.definitions = QuestDefinitionsDao(database)
self.schedules = PresenceSchedulesDao(database)
self.patterns = PresencePatternsDao(database)
self.overrides = PresenceOverridesDao(database)
self.instances = QuestInstancesDao(database)
self.events = CompletionEventsDao(database)
@@ -105,8 +105,8 @@ class World:
_stamp(),
assignee_child_ids=[self.child.id],
)
self.schedule = await self.schedules.upsert_by_child(
self.child.id, 2, D1, "0,2,4|1,3"
self.pattern = await self.patterns.create(
self.child.id, "Home schedule", "home", 2, D1, "0,2,4|1,3"
)
self.override = await self.overrides.create(
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
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):
# 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"
# update path: upsert replaces in place (same row id)
replaced = await w.schedules.upsert_by_child(
w.child.id, 2, D2, "1,3|0,2,4"
# a second pattern for the same child is allowed (schema 9)
away = await w.patterns.create(
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 (await w.schedules.get_by_child(w.child.id)).pattern == (
"1,3|0,2,4"
)
assert (await w.patterns.get(w.pattern.id)).pattern == "1,3|0,2,4"
# constraint: malformed pattern CHECK
with pytest.raises(sqlite3.IntegrityError):
await w.schedules.upsert_by_child(w.child.id, 2, D1, "0,2")
# delete: row gone, child present every day
assert await w.schedules.delete(w.child.id) is True
assert await w.schedules.get_by_child(w.child.id) is None
await w.patterns.create(w.child.id, "Bad", "home", 2, D1, "0,2")
# constraint: kind CHECK
with pytest.raises(sqlite3.IntegrityError):
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
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:
+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
import asyncio
@@ -10,8 +10,8 @@ from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_presence import (
PresenceOverrideRecord,
PresenceOverridesDao,
PresenceScheduleRecord,
PresenceSchedulesDao,
PresencePatternRecord,
PresencePatternsDao,
)
from custom_components.nestquest.core.db import NestQuestDatabase
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)
await database.open(path)
await apply_migrations(database)
schedules = PresenceSchedulesDao(database)
patterns = PresencePatternsDao(database)
overrides = PresenceOverridesDao(database)
children = ChildrenDao(database)
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 _run_test(body):
async def _main():
database, schedules, overrides, children, child = await _prepare(
database, patterns, overrides, children, child = await _prepare(
tmp_path / name
)
try:
return await body(
database, schedules, overrides, children, child
database, patterns, overrides, children, child
)
finally:
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:
"""Second upsert for the same child UPDATES, not raises."""
async def _body(database, schedules, overrides, children, child):
first = await schedules.upsert_by_child(
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"
def test_create_returns_typed_record_fields(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Mum's rota", "home", 4, "2026-09-07", "0|1|2|3"
)
assert isinstance(record, PresencePatternRecord)
assert record.id == 1
assert record.child_id == child.id
assert record.name == "Mum's rota"
assert record.kind == "home"
assert record.cycle_length_weeks == 4
assert record.anchor_date == "2026-09-07"
assert record.pattern == "0|1|2|3"
assert await patterns.get(record.id) == 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:
async def _body(database, schedules, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await schedules.upsert_by_child(
999, 2, "2026-09-07", "0,2|1,3"
def test_create_many_patterns_for_one_child(tmp_path) -> None:
"""Schema 9 drops the one-per-child UNIQUE: a second create for the
same child adds a row instead of raising or replacing."""
async def _body(database, patterns, overrides, children, child):
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
_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:
async def _body(database, schedules, overrides, children, child):
def test_create_malformed_anchor_date_raises(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
for bad in ("not-a-date", "2026-9-7", "2026-13-01", ""):
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
_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:
async def _body(database, schedules, overrides, children, child):
def test_create_malformed_pattern_rejected_by_schema(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2"
await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2"
)
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:
async def _body(database, schedules, overrides, children, child):
@pytest.mark.parametrize("bad_kind", ["present", "Home", "AWAY", ""])
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)
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
await schedules.upsert_by_child(other.id, 2, "2026-09-07", "1,3|0,2")
mine = await schedules.get_by_child(child.id)
theirs = await schedules.get_by_child(other.id)
assert mine.id != theirs.id
assert mine.pattern == "0,2|1,3"
assert theirs.pattern == "1,3|0,2"
await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
await patterns.create(
other.id, "Home", "home", 2, "2026-09-07", "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
_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:
"""Racing upserts for one child both succeed; exactly one row.
def test_list_by_child_orders_by_id(tmp_path) -> None:
"""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
job AFTER it has acquired the connection lock and begun its
transaction, so task B is created while A provably owns the
transaction and must queue on the lock. Both upserts land, and
the table holds exactly one row (the ON CONFLICT upsert, not two
plain INSERTs, is what avoids a UNIQUE violation).
transaction and must queue on the lock. With no UNIQUE on
``child_id`` (schema 9) both INSERTs land and the child lists two
patterns in id order.
"""
async def _main():
from unittest.mock import MagicMock
@@ -180,13 +227,6 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
a_started = asyncio.Event()
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"]:
name = getattr(fn, "__name__", "")
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
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "upsert-race.db")
await database.open(tmp_path / "create-race.db")
try:
await apply_migrations(database)
children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database)
patterns = PresencePatternsDao(database)
child = await children.create("Ada", NOW)
armed["active"] = True
task_a = asyncio.ensure_future(
schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
patterns.create(
child.id, "A", "home", 2, "2026-09-07", "0,2|1,3"
)
)
# 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()
armed["active"] = False
task_b = asyncio.ensure_future(
schedules.upsert_by_child(
child.id, 2, "2026-09-14", "1,3|0,2"
patterns.create(
child.id, "B", "away", 1, "2026-09-14", "5,6"
)
)
await asyncio.sleep(0)
# Release A; B's upsert must wait for A's commit and then
# land as the ON CONFLICT update path.
# Release A; B must wait for A's commit and then insert its
# own row.
gate_open.set()
record_a, record_b = await asyncio.gather(task_a, task_b)
assert record_a.id == record_b.id
fetched = await schedules.get_by_child(child.id)
assert fetched is not None
assert record_a.id != record_b.id
assert (record_a.name, record_b.name) == ("A", "B")
listed = await patterns.list_by_child(child.id)
rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_schedules"
"SELECT COUNT(*) FROM presence_patterns"
)
assert rows == (1,)
return fetched
assert rows == (2,)
return record_a, record_b, listed
finally:
await database.close()
fetched = _run(_main())
assert fetched.pattern in {"0,2|1,3", "1,3|0,2"}
record_a, record_b, listed = _run(_main())
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:
async def _body(database, schedules, overrides, children, child):
return await schedules.get_by_child(child.id)
def test_list_by_child_without_patterns_returns_empty(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
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
def test_get_by_child_unknown_child_raises(tmp_path) -> None:
"""None must mean ONLY 'no schedule row': an unknown child id is a
caller bug, and silently reading a nonexistent child as 'present
every day' would be wrong — so get raises instead.
"""
async def _body(database, schedules, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await schedules.get_by_child(999)
def test_update_rewrites_every_field_but_child(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3"
)
updated = await patterns.update(
record.id, "Weekends away", "away", 1, "2026-09-12", "5,6"
)
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
_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:
async def _body(database, schedules, overrides, children, child):
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
assert await schedules.delete(child.id) is True
assert await schedules.get_by_child(child.id) is None
assert await schedules.delete(child.id) is False
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
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
_with_db(tmp_path, "delete.db")(_body)
def test_delete_child_without_schedule_returns_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
return await schedules.delete(child.id)
def test_delete_missing_pattern_returns_false(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
return await patterns.delete(999)
assert _with_db(tmp_path, "delete-none.db")(_body) is False
def test_delete_then_upsert_creates_fresh_row(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
first = await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
def test_delete_leaves_the_childs_other_patterns(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"
)
await schedules.delete(child.id)
second = await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
second = await patterns.create(
child.id, "Away", "away", 1, "2026-09-07", "5"
)
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
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
child.id, "2026-12-21", "2027-01-04", False,
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await overrides.create(
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:
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", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await overrides.create(
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(
tmp_path,
) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
await overrides.create(
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
late = await overrides.create(
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
other = await children.create("Bo", NOW)
await overrides.create(child.id, "2026-09-10", "2026-09-11", True)
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await overrides.list_by_child_and_range(
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
return await overrides.list_by_child_and_range(
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
return await overrides.delete(999)
assert _with_db(tmp_path, "override-delete-missing.db")(_body) is False
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(
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)
def test_schedule_and_override_independent_for_same_child(tmp_path) -> None:
"""Deleting the schedule does not touch overrides and vice versa."""
async def _body(database, schedules, overrides, children, child):
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
def test_pattern_and_override_independent_for_same_child(tmp_path) -> None:
"""Deleting a pattern does not touch overrides and vice versa."""
async def _body(database, patterns, overrides, children, child):
pattern = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
override = await overrides.create(
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(
child.id, "2026-09-01", "2026-09-30"
)
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
_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:
"""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).
RECURSIVE scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/
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(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
r"(presence_schedules|presence_overrides)\b",
r"(presence_patterns|presence_overrides)\b",
re.IGNORECASE,
)
offenders: list[str] = []
@@ -574,7 +737,7 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
if sql_pattern.search(py.read_text()):
offenders.append(relative)
assert offenders == [], (
f"SQL touching presence_schedules/presence_overrides leaked "
f"SQL touching presence_patterns/presence_overrides leaked "
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:
"""This test module must exercise the DAO, not raw SQL, for these
tables. Exemptions are EXACT lines, not whole-function spans: the
two single-statement COUNT(*) row probes (the only honest way to
prove 'exactly one row exists' without a listing method) and the
single-statement COUNT(*) row probes (the only honest way to prove
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
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)
and isinstance(sub.value, str)
and sub.value.strip()
== "SELECT COUNT(*) FROM presence_schedules"
== "SELECT COUNT(*) FROM presence_patterns"
and sub.lineno is not None
):
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(
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",
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
single = await overrides.create(
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
existing = await overrides.create(
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:
"""Different children's overrides may cover the same dates: the
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)
await overrides.create(child.id, "2026-07-20", "2026-07-24", False)
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:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
first = await overrides.create(
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,
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 (
QuestDefinitionsDao,
schedule_rule_to_storage,
@@ -28,6 +28,10 @@ from custom_components.nestquest.core.quest_definitions import (
create_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 (
RuleType,
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.
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()
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:
async def _body(database):
children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database)
patterns = PresencePatternsDao(database)
a = await children.create("Ada", NOW) # present weeks 0, 2
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_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=27)).isoformat()
await schedules.upsert_by_child(
a.id, 2, start_iso, "0,1,2,3,4,5,6|"
await patterns.create(
a.id, "Home schedule", "home", 2, start_iso, "0,1,2,3,4,5,6|"
)
await schedules.upsert_by_child(
b.id, 2, start_iso, "|0,1,2,3,4,5,6"
await patterns.create(
b.id, "Home schedule", "home", 2, start_iso, "|0,1,2,3,4,5,6"
)
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)
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:
async def _body(database):
children = ChildrenDao(database)
@@ -638,7 +772,7 @@ def test_materialize_honours_overrides(tmp_path) -> None:
start = _future_monday()
start_iso = start.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.
await overrides.create(
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 (
PresenceOverridesDao,
)
from custom_components.nestquest.core.presence import PresenceSchedule
from custom_components.nestquest.core.presence import PresencePattern
children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database)
patterns = PresencePatternsDao(database)
overrides = PresenceOverridesDao(database)
a = await children.create("Ada", NOW) # present weeks 0, 2
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)
start_iso = anchor.isoformat()
end_iso = (anchor + datetime.timedelta(days=27)).isoformat()
# 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))
a_schedule = PresenceSchedule(a.id, 2, anchor, {0: all_week, 1: frozenset()})
b_schedule = PresenceSchedule(b.id, 2, anchor, {0: frozenset(), 1: all_week})
await schedules.upsert_by_child(a.id, 2, start_iso, a_schedule.encode())
await schedules.upsert_by_child(b.id, 2, start_iso, b_schedule.encode())
a_home = PresencePattern(
a.id, "Home schedule", "home", 2, anchor,
{0: all_week, 1: frozenset()},
)
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["daily"] = (await create_quest_definition(
+336 -9
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import datetime
import hashlib
import logging
import sqlite3
@@ -12,6 +13,9 @@ import pytest
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.migrations import (
MIGRATED_PRESENCE_PATTERN_NAME,
MIGRATION_1_V1_DDL,
MIGRATION_9_PRESENCE_PATTERNS,
MIGRATIONS,
VERSION_TABLE,
VERSION_TABLE_DDL,
@@ -69,7 +73,7 @@ def _counts(database) -> dict[str, int]:
"quest_definitions",
"quest_definition_assignees",
"quest_definition_windows",
"presence_schedules",
"presence_patterns",
"presence_overrides",
"quest_instances",
"completion_events",
@@ -107,7 +111,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None:
"quest_definitions",
"quest_definition_assignees",
"quest_definition_windows",
"presence_schedules",
"presence_patterns",
"presence_overrides",
"quest_instances",
"completion_events",
@@ -154,10 +158,26 @@ def test_migration_list_shape() -> None:
model, migration 4 adds the windows table, migration 5 rebuilds
quest_instances onto the widened (definition, child, date, window)
key, migration 6 adds actor_child_id to completion_events,
migration 7 adds the meta_state table and migration 8 adds
skip_on_away to quest_definitions.
migration 7 adds the meta_state table, migration 8 adds
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[2])
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
@@ -165,7 +185,8 @@ def test_migration_list_shape() -> None:
assert callable(MIGRATIONS[5])
assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL
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
# 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 = (
"quest_definitions ",
"quest_definition_assignees ",
@@ -482,7 +504,7 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
"quest_instances ",
"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):
continue
_run(database.execute(sql))
@@ -694,7 +716,7 @@ def test_v7_definitions_gain_skip_on_away_defaulting_true(tmp_path) -> None:
before = _version(database)
assert before == 7
final = _migrate(database)
final = _migrate(database, MIGRATIONS[:8])
assert final == 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())
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
+319 -136
View File
@@ -7,133 +7,173 @@ import pytest
from custom_components.nestquest.core.presence import (
MAX_CYCLE_LENGTH_WEEKS,
PATTERN_KINDS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
PresencePattern,
cycle_week_index,
)
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}}
# ---------------------------------------------------------------------------
# PresenceSchedule construction
# PresencePattern construction
# ---------------------------------------------------------------------------
def test_schedule_holds_validated_fields() -> None:
schedule = PresenceSchedule(
3, 2, "2026-01-05", {0: {0, 2, 4}, 1: {1, 3}}
def test_pattern_holds_validated_fields() -> None:
pattern = PresencePattern(
3, "Dad's weekends", "away", 2, "2026-01-05",
{0: {0, 2, 4}, 1: {1, 3}},
)
assert schedule.child_id == 3
assert schedule.cycle_length_weeks == 2
assert schedule.anchor_date == ANCHOR
assert schedule.pattern == {0: frozenset({0, 2, 4}), 1: frozenset({1, 3})}
assert pattern.child_id == 3
assert pattern.name == "Dad's weekends"
assert pattern.kind == "away"
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:
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}})
assert schedule.anchor_date is ANCHOR
def test_pattern_accepts_date_objects() -> None:
pattern = PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}})
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"):
PresenceSchedule(1, 0, ANCHOR, {})
PresencePattern(1, NAME, HOME, 0, ANCHOR, {})
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."""
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
@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"):
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\\]"):
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"):
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"):
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."""
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])
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"):
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])
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"):
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}."""
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"):
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"):
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])
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"):
PresenceSchedule(bad, 1, ANCHOR, {0: {0}})
PresencePattern(bad, NAME, HOME, 1, ANCHOR, {0: {0}})
@pytest.mark.parametrize(
"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"):
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 —
distinct from having no schedule (the engine's default)."""
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()})
assert schedule.pattern[1] == frozenset()
distinct from having no pattern (the engine's default)."""
pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}, 1: set()})
assert pattern.pattern[1] == frozenset()
def test_schedule_empty_week_encodes_as_empty_segment() -> None:
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()})
assert schedule.encode() == "0,2|"
def test_pattern_empty_week_encodes_as_empty_segment() -> None:
pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}, 1: set()})
assert pattern.encode() == "0,2|"
def test_schedule_equal_patterns_are_equal_and_encode_identically() -> None:
one = PresenceSchedule(1, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}})
two = PresenceSchedule(2, 2, ANCHOR, {0: {0, 2, 4}, 1: {1, 3}})
def test_pattern_equal_patterns_are_equal_and_encode_identically() -> None:
one = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}})
two = PresencePattern(2, NAME, HOME, 2, ANCHOR, {0: {0, 2, 4}, 1: {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|",
],
)
def test_schedule_decode_encode_round_trips_losslessly(encoded) -> None:
schedule = PresenceSchedule.decode(1, ANCHOR, encoded)
assert schedule.encode() == encoded
again = PresenceSchedule.decode(1, ANCHOR, schedule.encode())
assert again == schedule
def test_pattern_decode_encode_round_trips_losslessly(encoded) -> None:
pattern = PresencePattern.decode(1, NAME, HOME, ANCHOR, encoded)
assert pattern.encode() == encoded
again = PresencePattern.decode(1, NAME, HOME, ANCHOR, pattern.encode())
assert again == pattern
def test_schedule_decode_sets_cycle_length_from_segments() -> None:
one_week = PresenceSchedule.decode(1, ANCHOR, "0,2,4")
def test_pattern_decode_sets_cycle_length_from_segments() -> None:
one_week = PresencePattern.decode(1, NAME, HOME, ANCHOR, "0,2,4")
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
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"):
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"):
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"):
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)
def test_schedule_rejects_datetime_anchor() -> None:
def test_pattern_rejects_datetime_anchor() -> None:
"""datetime.datetime subclasses date: a bare isinstance check would
store a time component and every date comparison would TypeError."""
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:
@@ -245,23 +311,23 @@ def test_override_rejects_datetime_bounds() -> None:
)
def test_schedule_pattern_is_immutable_after_construction() -> None:
"""The frozen schedule must not leak a mutable dict: injecting
def test_pattern_pattern_is_immutable_after_construction() -> None:
"""The frozen pattern must not leak a mutable dict: injecting
unvalidated weekdays or dropping required weeks after construction
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):
schedule.pattern[0] = {9}
pattern.pattern[0] = {9}
with pytest.raises(TypeError):
del schedule.pattern[1]
del pattern.pattern[1]
# mappingproxy exposes no mutators at all.
assert not any(
hasattr(schedule.pattern, name)
hasattr(pattern.pattern, name)
for name in ("clear", "pop", "popitem", "setdefault", "update")
)
# The validated contents are still readable and encode correctly.
assert schedule.pattern[0] == frozenset({0, 2})
assert schedule.encode() == "0,2|1"
assert pattern.pattern[0] == frozenset({0, 2})
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:
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.
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():
assert (
cycle_week_index(
schedule, ANCHOR + datetime.timedelta(days=offset)
pattern, ANCHOR + datetime.timedelta(days=offset)
)
== expected
), 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:
"""Python floor division: day -1 is the LAST week of the cycle,
wrapping backwards — never inverting."""
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}})
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=1)) == 1
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=7)) == 1
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=8)) == 0
pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0}, 1: {1}})
assert cycle_week_index(pattern, ANCHOR - datetime.timedelta(days=1)) == 1
assert cycle_week_index(pattern, ANCHOR - datetime.timedelta(days=7)) == 1
assert cycle_week_index(pattern, ANCHOR - datetime.timedelta(days=8)) == 0
def test_cycle_week_index_three_week_pattern() -> None:
schedule = PresenceSchedule(
1, 3, ANCHOR, {0: {0}, 1: {1}, 2: {2}}
pattern = PresencePattern(
1, NAME, HOME, 3, ANCHOR, {0: {0}, 1: {1}, 2: {2}}
)
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
]
@@ -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
2020, a 53-ISO-week year, where ISO week parity flips and would
invert a two-week custody schedule on January 1st."""
schedule = PresenceSchedule(
1, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}}
pattern = PresencePattern(
1, NAME, HOME, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}}
)
# Independently derived expectations (anchor 2019-01-06, index =
# 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,
}
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}"
# And 2020 really is the 53-ISO-week year this guards against.
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:
"""Two children on opposite weeks of one two-week rotation, one
with no schedule at all (the always-present sibling)."""
"""Two children on opposite weeks of one two-week rotation (each a
single ``home`` pattern), one with no pattern at all (the
always-present sibling)."""
anchor = datetime.date(2026, 1, 5) # Monday, week 0
declan = PresenceSchedule(
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
declan = PresencePattern(
1, NAME, HOME, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
)
jordyn = PresenceSchedule(
2, 2, anchor, {0: set(), 1: {0, 1, 2, 3, 4, 5, 6}}
jordyn = PresencePattern(
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
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."""
engine = _three_child_engine()
anchor = datetime.date(2026, 1, 5)
@@ -446,9 +513,12 @@ def test_engine_weekend_absence_respects_the_pattern() -> None:
child's present week."""
engine = PresenceEngine(
{
1: PresenceSchedule(
1, 2, datetime.date(2026, 1, 5), {0: {1, 3}, 1: {2, 4}}
)
1: [
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.
@@ -460,18 +530,24 @@ def test_engine_weekend_absence_respects_the_pattern() -> 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"):
PresenceEngine([schedule])
with pytest.raises(ValueError, match="must be a PresenceSchedule"):
PresenceEngine({1: "not-a-schedule"})
with pytest.raises(ValueError, match="holds the schedule"):
PresenceEngine({2: schedule})
PresenceEngine([pattern])
# Values are LISTS now: a bare pattern (the retired one-schedule
# shape) is refused rather than silently iterated.
with pytest.raises(ValueError, match="must be a list of PresencePattern"):
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"):
PresenceEngine({True: schedule})
PresenceEngine({True: [pattern]})
with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceEngine({1.0: PresenceSchedule(1, 1, ANCHOR, {0: {0}})})
engine = PresenceEngine({1: schedule})
PresenceEngine({1.0: [PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}})]})
engine = PresenceEngine({1: [pattern]})
with pytest.raises(ValueError, match="child_id must be an integer"):
engine.is_present(True, ANCHOR)
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:
engine = _three_child_engine()
with pytest.raises(AttributeError, match="immutable snapshot"):
engine._schedules = {}
engine._patterns = {}
with pytest.raises(AttributeError, match="immutable snapshot"):
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
# ---------------------------------------------------------------------------
@@ -495,9 +671,12 @@ def _engine_with_overrides(*overrides_for_declan) -> PresenceEngine:
anchor = datetime.date(2026, 1, 5) # Monday
return PresenceEngine(
{
1: PresenceSchedule(
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
)
1: [
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 {},
)
@@ -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
def test_override_applies_without_a_schedule() -> None:
"""An override on a schedule-free child still applies: holidays and
def test_override_applies_without_a_pattern() -> None:
"""An override on a pattern-free child still applies: holidays and
swaps must work for the always-present sibling too."""
engine = PresenceEngine(
{},
@@ -554,18 +733,18 @@ def test_override_range_beats_pattern_for_its_span() -> 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)
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"):
PresenceEngine({1: schedule}, overrides={1: entry})
PresenceEngine({1: patterns}, overrides={1: entry})
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"):
PresenceEngine({1: schedule}, overrides={2: [entry]})
PresenceEngine({1: patterns}, overrides={2: [entry]})
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:
@@ -591,11 +770,11 @@ def test_engine_override_lists_are_frozen() -> None:
frozen snapshot it must not."""
entry = PresenceOverride(1, "2026-01-06", "2026-01-06", False)
entries = [entry]
schedule = PresenceSchedule(
1, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
pattern = PresencePattern(
1, NAME, HOME, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
)
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
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."""
return PresenceEngine(
{
1: PresenceSchedule(
1, 2, datetime.date(2026, 1, 5),
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
)
1: [
PresencePattern(
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:
schedule = PresenceSchedule(
1, 2, datetime.date(2026, 1, 5),
pattern = PresencePattern(
1, NAME, HOME, 2, datetime.date(2026, 1, 5),
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
)
engine = PresenceEngine(
{1: schedule},
{1: [pattern]},
overrides={
1: [
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({})
preview = engine.next_present_dates(3, "2026-03-01", 3)
assert preview == [
@@ -719,9 +900,11 @@ def test_preview_always_absent_child_raises_not_loops() -> None:
hang the admin preview."""
engine = PresenceEngine(
{
1: PresenceSchedule(
1, 1, datetime.date(2026, 1, 5), {0: set()}
)
1: [
PresencePattern(
1, NAME, HOME, 1, datetime.date(2026, 1, 5), {0: set()}
)
]
}
)
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
)
assert anchor.weekday() == 0
rotation = PresenceSchedule(
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
rotation = PresencePattern(
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
# 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_V7_META_STATE_DDL,
SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
SCHEMA_V9_PRESENCE_PATTERNS_DDL,
SCHEMA_V1_SCHEDULE_RULES_DDL,
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITIONS_DDL,
@@ -112,14 +112,16 @@ def _insert_definition(database, rule_id=1, assignees=(1,), **overrides):
return result.lastrowid
def _insert_schedule(database, child_id, **overrides):
"""Insert one valid presence_schedule, returning the schedule id.
def _insert_pattern(database, child_id, **overrides):
"""Insert one valid presence_pattern, returning the pattern id.
Pass ``_OMIT`` as a value to leave that column out of the INSERT so
its DDL DEFAULT applies.
"""
values: dict[str, object] = {
"child_id": child_id,
"name": "Home schedule",
"kind": "home",
"cycle_length_weeks": 2,
"anchor_date": "2026-09-13",
"pattern": "0,2,4|1,3",
@@ -130,7 +132,7 @@ def _insert_schedule(database, child_id, **overrides):
placeholders = ", ".join("?" for _ in values)
result = _run(
database.execute(
f"INSERT INTO presence_schedules ({columns}) VALUES ({placeholders})",
f"INSERT INTO presence_patterns ({columns}) VALUES ({placeholders})",
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_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V9_PRESENCE_PATTERNS_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_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
def test_presence_schedules_table_exists_after_applying_ddl(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules.db")
def test_presence_patterns_table_exists_after_applying_ddl(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns.db")
try:
_apply(database)
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(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'presence_schedules'"
)
)
assert row is not None and row[0] == "presence_schedules"
assert gone is None
finally:
_run(database.close())
@@ -1361,15 +1372,17 @@ def test_presence_overrides_table_exists_after_applying_ddl(tmp_path) -> None:
_run(database.close())
def test_presence_schedules_columns_types_and_constraints(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-columns.db")
def test_presence_patterns_columns_types_and_constraints(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-columns.db")
try:
_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
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
("id", "INTEGER", 0, None, 1),
("child_id", "INTEGER", 1, None, 0),
("name", "TEXT", 1, None, 0),
("kind", "TEXT", 1, None, 0),
("cycle_length_weeks", "INTEGER", 1, None, 0),
("anchor_date", "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())
def test_presence_schedules_foreign_key_declared(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-fks.db")
def test_presence_patterns_foreign_key_declared(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-fks.db")
try:
_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}
assert declared == {("children", "child_id", "id")}
finally:
@@ -1417,48 +1430,119 @@ def test_presence_overrides_foreign_key_declared(tmp_path) -> None:
_run(database.close())
def test_presence_schedules_valid_insert_round_trips(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-valid.db")
def test_presence_patterns_valid_insert_round_trips(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-valid.db")
try:
_apply(database)
_child(database)
schedule_id = _insert_schedule(database, child_id=1)
pattern_id = _insert_pattern(database, child_id=1)
row = _run(
database.fetch_one(
"SELECT child_id, cycle_length_weeks, anchor_date, pattern "
"FROM presence_schedules WHERE id = ?",
(schedule_id,),
"SELECT child_id, name, kind, cycle_length_weeks, anchor_date, "
"pattern FROM presence_patterns WHERE 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:
_run(database.close())
def test_presence_schedules_second_schedule_for_same_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-unique.db")
def test_presence_patterns_allow_several_patterns_per_child(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-many.db")
try:
_apply(database)
_child(database)
_insert_schedule(database, child_id=1)
with pytest.raises(sqlite3.IntegrityError, match="UNIQUE"):
_insert_schedule(database, child_id=1)
first = _insert_pattern(database, child_id=1)
second = _insert_pattern(
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:
_run(database.close())
def test_presence_schedules_unknown_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-bad-child.db")
def test_presence_patterns_declare_no_unique_index(tmp_path) -> None:
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:
_apply(database)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_insert_schedule(database, child_id=999)
_insert_pattern(database, child_id=999)
finally:
_run(database.close())
@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
) -> None:
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)
_child(database)
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:
_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"),
],
)
def test_presence_schedules_valid_patterns_succeed(tmp_path, cycle, pattern) -> None:
database = _open_db(tmp_path / "presence-schedules-patterns-valid.db")
def test_presence_patterns_valid_patterns_succeed(tmp_path, cycle, pattern) -> None:
database = _open_db(tmp_path / "presence-patterns-patterns-valid.db")
try:
_apply(database)
_child(database)
schedule_id = _insert_schedule(
pattern_id = _insert_pattern(
database, child_id=1, cycle_length_weeks=cycle, pattern=pattern
)
row = _run(
database.fetch_one(
"SELECT pattern FROM presence_schedules WHERE id = ?",
(schedule_id,),
"SELECT pattern FROM presence_patterns WHERE id = ?",
(pattern_id,),
)
)
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
],
)
def test_presence_schedules_invalid_patterns_fail(tmp_path, cycle, pattern) -> None:
database = _open_db(tmp_path / "presence-schedules-patterns-invalid.db")
def test_presence_patterns_invalid_patterns_fail(tmp_path, cycle, pattern) -> None:
database = _open_db(tmp_path / "presence-patterns-patterns-invalid.db")
try:
_apply(database)
_child(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_schedule(
_insert_pattern(
database, child_id=1, cycle_length_weeks=cycle, pattern=pattern
)
finally:
_run(database.close())
def test_presence_schedules_cycle_length_bounds_pass(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-cycle-bounds.db")
def test_presence_patterns_cycle_length_bounds_pass(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-cycle-bounds.db")
try:
_apply(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(
database.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("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:
_run(database.close())
@@ -1732,11 +1816,11 @@ def test_applying_presence_ddl_twice_keeps_rows(tmp_path) -> None:
try:
_apply(database)
_child(database)
_insert_schedule(database, child_id=1)
_insert_pattern(database, child_id=1)
_insert_override(database, child_id=1)
_apply(database)
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}"))
counts.append(row[0])
assert counts == [1, 1]