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:
+58
-38
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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,),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"]]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user