Files
minabot/src/App.test.tsx
2026-09-05 07:52:42 +02:00

747 lines
39 KiB
TypeScript

import { ReminderSettings } from "./components/ReminderSettings";
import { Modal } from "./components/design-system/Modal";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
spyOn,
test,
} from "bun:test";
import { Window } from "happy-dom";
import { act, StrictMode, useState } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import type { Root } from "react-dom/client";
import { App } from "./App";
import { CalendarHeatmap } from "./components/design-system/CalendarHeatmap";
import { fixture } from "./habits/test-fixture";
// Keep the design system independent of authentication and the local database.
const dom = new Window({ url: "http://localhost:3000/" });
const originalGlobals = new Map<string, PropertyDescriptor | undefined>();
let createRoot: typeof import("react-dom/client").createRoot;
let root: Root;
let container: HTMLDivElement;
let fetchMock: ReturnType<typeof spyOn<typeof globalThis, "fetch">>;
let apiFixture: ReturnType<typeof fixture> | undefined;
beforeAll(async () => {
for (const key of [
"window",
"document",
"navigator",
"HTMLElement",
"HTMLInputElement",
"Element",
"Node",
"Event",
"MouseEvent",
"IS_REACT_ACT_ENVIRONMENT",
]) {
originalGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value:
key === "window"
? dom
: key === "IS_REACT_ACT_ENVIRONMENT"
? true
: (dom as unknown as Record<string, unknown>)[key],
});
}
({ createRoot } = await import("react-dom/client"));
});
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
fetchMock = spyOn(globalThis, "fetch").mockResolvedValue(
new Response(null, { status: 401 }),
);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
fetchMock.mockRestore();
apiFixture?.close();
apiFixture = undefined;
});
afterAll(() => {
dom.happyDOM.abort();
for (const [key, descriptor] of originalGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
function LocationProbe() {
const location = useLocation();
const navigate = useNavigate();
return <>
<output data-testid="pathname">{location.pathname}</output>
<output data-testid="hash">{location.hash}</output>
<button onClick={() => navigate(-1)}>History back</button>
<button onClick={() => navigate(1)}>History forward</button>
</>;
}
async function render(path = "/design-system") {
await act(async () =>
root.render(
<MemoryRouter initialEntries={[path]}>
<App />
<LocationProbe />
</MemoryRouter>,
),
);
}
function connectAccount() {
apiFixture = fixture();
const f = apiFixture;
fetchMock.mockImplementation((async (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Cookie", `minabot_session=${"a".repeat(43)}`);
headers.set("Origin", f.origin);
return f.app.request(new Request(new URL(String(input), f.origin), { ...init, headers }));
}) as typeof fetch);
return f;
}
const buttonNamed = (name: string) => [...container.querySelectorAll<HTMLButtonElement>("button")]
.find(button => button.textContent === name || button.getAttribute("aria-label") === name)!;
describe("homepage", () => {
test("development Strict Mode does not restore background focus while a modal is open", async () => {
await act(async () => root.render(<StrictMode><Modal id="strict-modal" title="Editor" onClose={() => {}} initialFocus="input"><input aria-label="Draft" /></Modal></StrictMode>));
await new Promise(resolve => dom.requestAnimationFrame(() => dom.requestAnimationFrame(resolve)));
expect(document.activeElement).toBe(container.querySelector("input"));
expect(container.querySelector<HTMLDialogElement>("dialog")?.open).toBe(true);
expect(document.documentElement.style.overflow).toBe("hidden");
});
test("settings open as a modal and Escape restores the opener and scrolling", async () => {
connectAccount();
await render("/");
const opener = buttonNamed("Settings");
opener.focus();
await act(async () => opener.click());
const dialog = container.querySelector<HTMLDialogElement>("dialog#account-settings")!;
expect(dialog.open).toBe(true);
expect(dialog.contains(document.activeElement)).toBe(true);
expect(document.documentElement.style.overflow).toBe("hidden");
await act(async () => dialog.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
await new Promise(resolve => dom.requestAnimationFrame(resolve));
expect(container.querySelector("dialog")).toBeNull();
expect(document.activeElement).toBe(opener);
expect(document.documentElement.style.overflow).toBe("");
});
test("modal dismissal waits for saves and nested dialogs retain the scroll lock", async () => {
function Harness() {
const [nested, setNested] = useState(true);
const [busy, setBusy] = useState(true);
return <Modal id="outer" title="Outer" onClose={() => {}}>
{nested && <Modal id="inner" title="Inner" busy={busy} onClose={() => setNested(false)}>
<button onClick={() => setBusy(false)}>Finish save</button>
</Modal>}
</Modal>;
}
await act(async () => root.render(<Harness />));
const inner = container.querySelector<HTMLDialogElement>("#inner")!;
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
expect(inner.open).toBe(true);
expect(inner.querySelector<HTMLButtonElement>('[aria-label="Close dialog"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Finish save").click());
await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event));
expect(container.querySelector("#inner")).toBeNull();
expect(container.querySelector<HTMLDialogElement>("#outer")?.open).toBe(true);
expect(document.documentElement.style.overflow).toBe("hidden");
await act(async () => root.render(null));
expect(document.documentElement.style.overflow).toBe("");
});
test("archived habits expose history and restore to the dashboard", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const h = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await f.json(`/habits/${h.id}/days/2026-09-03/progress`, "PUT", { done: true });
f.setTime("2026-09-04T12:00:00Z");
await f.request(`/habits/${h.id}`, "DELETE");
await render("/");
await act(async () => buttonNamed("Archived habits").click());
expect(buttonNamed("Restore Reading")).toBeDefined();
await act(async () => buttonNamed("View history").click());
expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull();
await act(async () => buttonNamed("Restore Reading").click());
expect(container.textContent).toContain("No archived habits.");
expect((await f.json("/today")).habits[0].name).toBe("Reading");
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
});
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
f.setTime("2026-09-04T12:00:00Z");
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
await render("/");
expect(container.querySelector(".ds-calendar")).toBeNull();
expect(buttonNamed("View history for Reading").getAttribute("aria-expanded")).toBe("false");
await act(async () => buttonNamed("View history for Reading").click());
expect(buttonNamed("Hide history for Reading").getAttribute("aria-expanded")).toBe("true");
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
await act(async () => buttonNamed("Edit selected check-in").click());
const past = [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!;
expect(past).toBeDefined();
await act(async () => past.click());
expect((await f.json(`/habits/${habit.id}/days/2026-09-03`)).complete).toBe(true);
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).value).toBe(0);
expect(container.textContent).toContain("Saved check-in for 2026-09-03");
});
test("signed-out visitors can try progress without calling private habit APIs", async () => {
await render("/");
expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm.");
expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]);
await act(async () => buttonNamed("Increase glasses of water").click());
expect(container.querySelector<HTMLInputElement>('[aria-label="Total glasses of water"]')?.value).toBe("4");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("OAuth errors are retained at the homepage and unknown routes go home", async () => {
await render("/?auth_error=denied");
expect(container.querySelector('[role="alert"]')?.textContent).toContain("cancelled");
// A new router is needed when changing initialEntries after mount.
await act(async () => root.unmount());
root = createRoot(container);
await render("/missing");
expect(container.querySelector('[data-testid="pathname"]')?.textContent).toBe("/");
expect(container.querySelector("#welcome-title")).not.toBeNull();
});
test("does not flash the signed-out page while the account is loading", async () => {
let resolve!: (response: Response) => void;
fetchMock.mockImplementation(Object.assign(() => new Promise<Response>(done => { resolve = done; }), { preconnect: fetch.preconnect }));
await render("/");
expect(container.textContent).toContain("Getting things ready");
expect(container.querySelector("#welcome-title")).toBeNull();
await act(async () => resolve(new Response(null, { status: 401 })));
expect(container.querySelector("#welcome-title")).not.toBeNull();
});
test("account failures offer retry without pretending the user is signed out", async () => {
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
await render("/");
expect(container.textContent).toContain("Account unavailable");
expect(container.querySelector("#welcome-title")).toBeNull();
await act(async () => buttonNamed("Try again").click());
expect(container.querySelector("#welcome-title")).not.toBeNull();
});
test("signed-in users create a habit from the empty state and sign out", async () => {
const f = connectAccount();
await render("/");
expect(container.textContent).toContain("Welcome back, alice.");
expect(container.textContent).toContain("Start with one habit.");
await act(async () => buttonNamed("Add a habit +").click());
const input = container.querySelector<HTMLInputElement>("form input")!;
expect(document.activeElement).toBe(input);
await act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Read a little");
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect((await f.json("/habits")).habits[0].name).toBe("Read a little");
expect(container.querySelector("form")).toBeNull();
expect(container.textContent).toContain("Read a little created.");
expect(container.querySelector(".ds-habit-chart")).not.toBeNull();
await act(async () => buttonNamed("Sign out").click());
expect(container.querySelector("#welcome-title")).not.toBeNull();
expect((await f.request("/me")).status).toBe(401);
});
test("count, manual, and task progress persist and update today's completion", async () => {
const f = connectAccount();
const water = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
await f.json(`/habits/${water.id}/days/2026-09-04/progress`, "PUT", { count: 7 });
await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "Stretch" }] }, 201);
await f.json("/habits", "POST", { name: "Sunday walk", method: "manual", schedule: { type: "weekdays", days: [0] } }, 201);
await render("/");
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("0 / 3");
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)).toBeNull();
await act(async () => buttonNamed("View history for Water").click());
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
expect(container.textContent).not.toContain("Sunday walk");
await act(async () => buttonNamed("All habits · 4").click());
const checkbox = (name: string) => [...container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].find(input => (input.getAttribute("aria-label") || input.closest("label")?.textContent) === name)!;
expect(checkbox("Sunday walk done").disabled).toBe(true);
await act(async () => checkbox("Reading done").click());
await act(async () => checkbox("Stretch").click());
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3");
expect((await f.json("/today")).completed).toBe(3);
await act(async () => buttonNamed("Decrease Water").click());
expect((await f.json("/today")).completed).toBe(2);
});
test("inline habit edits persist configuration and delete archives without losing earlier history", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const habit = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "glasses" }, 201);
await f.json(`/habits/${habit.id}/days/2026-09-03/progress`, "PUT", { count: 5 });
f.setTime("2026-09-04T12:00:00Z");
await render("/");
await act(async () => buttonNamed("Edit habit Water").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit habit Water"]')!;
expect(document.activeElement).toBe(form.querySelector("input"));
await act(async () => {
const name = form.querySelector<HTMLInputElement>("input")!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(name, "Daily water");
name.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
const target = form.querySelector<HTMLInputElement>('input[type="number"]')!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(target, "10");
target.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
const saved = await f.json(`/habits/${habit.id}`);
expect(saved.name).toBe("Daily water");
expect(saved.target).toBe(10);
expect(container.querySelector("form")).toBeNull();
expect(container.querySelector("h3")?.textContent).toContain("Daily water");
await act(async () => buttonNamed("Archive habit Daily water").click());
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Cancel").click());
expect((await f.json("/habits")).habits).toHaveLength(1);
await act(async () => buttonNamed("Archive habit Daily water").click());
await act(async () => buttonNamed("Archive habit").click());
expect((await f.json("/habits")).habits).toHaveLength(0);
expect(container.querySelector(".ds-habit-chart")).toBeNull();
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
expect(previous.name).toBe("Water");
expect(previous.target).toBe(8);
expect(previous.value).toBe(5);
});
test("inline task rename keeps its completion and schedule; deletion updates totals and preserves yesterday", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const habit = await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "Stretch", schedule: { type: "weekdays", days: [4, 5] } }, { name: "Clear desk" }] }, 201);
const task = habit.tasks[0];
await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${task.id}`, "PUT", { done: true });
f.setTime("2026-09-04T12:00:00Z");
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${task.id}`, "PUT", { done: true });
await render("/");
await act(async () => buttonNamed("Edit task Stretch").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Stretch"]')!;
await act(async () => {
const input = form.querySelector("input")!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Stretch gently");
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
const saved = await f.json(`/habits/${habit.id}/tasks/${task.id}`);
expect(saved.name).toBe("Stretch gently");
expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] });
expect((await f.json("/today")).habits[0].value).toBe(1);
await act(async () => buttonNamed("Delete task Stretch gently").click());
await act(async () => buttonNamed("Delete task").click());
const today = (await f.json("/today")).habits[0];
expect(today.target).toBe(1);
expect(today.value).toBe(0);
expect(container.textContent).not.toContain("Stretch gently");
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
expect(previous.tasks.find((item: { taskId: string }) => item.taskId === task.id).done).toBe(true);
expect(previous.tasks.find((item: { taskId: string }) => item.taskId === task.id).name).toBe("Stretch");
});
test("task edits retain failed drafts for retry and remain available on days off", async () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Sunday", method: "tasks", schedule: { type: "weekdays", days: [0] }, tasks: [{ name: "Walk" }] }, 201);
await render("/");
expect(container.querySelector(".ds-habit-chart")).toBeNull();
await act(async () => buttonNamed("All habits · 1").click());
expect(container.textContent).toContain("Not scheduled today");
expect(container.querySelector<HTMLInputElement>('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true);
await act(async () => buttonNamed("Edit task Walk").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
await act(async () => {
const input = form.querySelector("input")!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Long walk");
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Please try again" }), { status: 500 }));
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect(form.querySelector("input")?.value).toBe("Long walk");
expect(form.querySelector('[role="alert"]')?.textContent).toBe("Please try again");
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].name).toBe("Walk");
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect((await f.json(`/habits/${habit.id}/tasks`)).tasks[0].name).toBe("Long walk");
expect(container.querySelector("form")).toBeNull();
});
test("task recurrence edits round-trip every schedule type and preserve earlier check-ins", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
const habit = await f.json("/habits", "POST", { name: "Routine", method: "tasks", tasks: [{ name: "Walk" }] }, 201);
const id = habit.tasks[0].id;
await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${id}`, "PUT", { done: true });
f.setTime("2026-09-04T12:00:00Z");
await render("/");
const inputValue = async (input: HTMLInputElement, value: string) => act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
const selectValue = async (select: HTMLSelectElement, value: string) => act(async () => {
select.value = value;
select.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
});
for (const schedule of [
{ type: "interval", every: 3, anchor: "2026-09-05" },
{ type: "weekly", every: 2, anchor: "2026-09-04", weekday: 5 },
{ type: "weekdays", days: [1, 2, 3, 4, 5] },
{ type: "daily" },
] as const) {
await act(async () => buttonNamed("Edit task Walk").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
await selectValue(form.querySelector("select")!, schedule.type);
if (schedule.type === "interval" || schedule.type === "weekly") {
await inputValue(form.querySelector('input[type="number"]')!, String(schedule.every));
await inputValue(form.querySelector('input[type="date"]')!, schedule.anchor);
}
if (schedule.type === "weekly") await selectValue(form.querySelectorAll("select")[1]!, String(schedule.weekday));
await act(async () => form.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect((await f.json(`/habits/${habit.id}/tasks/${id}`)).schedule).toEqual(schedule);
if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true");
expect(container.querySelector("form")).toBeNull();
await act(async () => buttonNamed("Edit task Walk").click());
const reopened = container.querySelector<HTMLFormElement>('form[aria-label="Edit task Walk"]')!;
expect(reopened.querySelector("select")?.value).toBe(schedule.type);
if (schedule.type === "interval" || schedule.type === "weekly") {
expect(reopened.querySelector<HTMLInputElement>('input[type="number"]')?.value).toBe(String(schedule.every));
expect(reopened.querySelector<HTMLInputElement>('input[type="date"]')?.value).toBe(schedule.anchor);
}
await act(async () => buttonNamed("Cancel").click());
}
const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`);
expect(previous.tasks[0].done).toBe(true);
expect(previous.requirements.tasks[0].schedule).toEqual({ type: "daily" });
});
test("habit method and carryover controls save, and task conversion supports adding recurring tasks", async () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await render("/");
const selectMethod = async (method: string) => {
const select = container.querySelector<HTMLSelectElement>('form select')!;
await act(async () => {
select.value = method;
select.dispatchEvent(new dom.Event("change", { bubbles: true }) as unknown as Event);
});
};
const submit = async () => act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
await act(async () => buttonNamed("Edit habit Reading").click());
await selectMethod("count");
await act(async () => container.querySelector<HTMLInputElement>('form input[type="checkbox"]')!.click());
await submit();
expect((await f.json(`/habits/${habit.id}`)).method).toBe("count");
expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true);
await act(async () => buttonNamed("Edit habit Reading").click());
expect(container.querySelector<HTMLInputElement>('form input[type="checkbox"]')?.checked).toBe(true);
await selectMethod("tasks");
await submit();
expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks");
await act(async () => buttonNamed("Add task +").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
await act(async () => {
const input = form.querySelector("input")!;
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, "Read chapter");
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event);
});
await submit();
const task = (await f.json(`/habits/${habit.id}/tasks`)).tasks[0];
expect(task.name).toBe("Read chapter");
expect(task.schedule).toEqual({ type: "daily" });
expect(buttonNamed("Edit task Read chapter")).toBeDefined();
expect((await f.json("/today")).habits[0].target).toBe(1);
});
test("a successful delete is not retried when the subsequent refresh fails", async () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201);
await render("/");
await act(async () => buttonNamed("Archive habit Reading").click());
fetchMock.mockImplementationOnce((async (input, init) => {
return f.request(String(input).replace("/api", ""), init?.method);
}) as typeof fetch);
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }));
await act(async () => buttonNamed("Archive habit").click());
expect(container.querySelector("form")).toBeNull();
expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh");
expect(buttonNamed("Archive habit Reading").disabled).toBe(true);
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
await act(async () => buttonNamed("Try again").click());
expect(container.querySelector(".ds-habit-chart")).toBeNull();
expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1);
});
test("failed saves retain recorded progress and allow another attempt", async () => {
const f = connectAccount();
await f.json("/habits", "POST", { name: "Water", method: "count", target: 8 }, 201);
await render("/");
fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 }));
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable");
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("0");
expect(buttonNamed("Increase Water").disabled).toBe(false);
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector<HTMLInputElement>('[aria-label="Total Water"]')?.value).toBe("1");
expect((await f.json("/today")).habits[0].value).toBe(1);
});
});
test("unscheduled dates stay inspectable without a progress-square fill", async () => {
await act(async () =>
root.render(
<CalendarHeatmap
label="Reading history"
unit="pages"
compact
emptyColor="#ebedf0"
days={[
{
date: "2026-09-03",
value: 0,
target: 0,
state: "not-due",
color: "#f5f5f5",
},
{
date: "2026-09-04",
value: 0,
target: 20,
state: "due",
color: "#ebedf0",
},
{
date: "2026-09-05",
value: 0,
target: 20,
state: "future",
color: "#dbeafe",
},
]}
/>,
),
);
const restDay =
container.querySelector<HTMLButtonElement>(".ds-day--not-due")!;
expect(restDay.style.backgroundColor).toBe("transparent");
expect(restDay.getAttribute("aria-label")).toContain(
"Not included in the score",
);
expect(
container.querySelector<HTMLElement>(".ds-day--due")!.style.backgroundColor,
).toBe("#ebedf0");
expect(
container.querySelector<HTMLElement>(".ds-day--future")!.style
.backgroundColor,
).toBe("#ebedf0");
expect(container.querySelector(".ds-legend-swatch--not-due")).not.toBeNull();
await act(async () => restDay.click());
expect(restDay.getAttribute("aria-pressed")).toBe("true");
expect(restDay.tabIndex).toBe(0);
expect(container.querySelector(".ds-date-inspector")?.textContent).toContain(
"Nothing scheduled",
);
await act(async () =>
restDay.dispatchEvent(
new dom.KeyboardEvent("keydown", {
key: "ArrowDown",
bubbles: true,
}) as unknown as KeyboardEvent,
),
);
expect(
container.querySelector(".ds-day--due")?.getAttribute("aria-pressed"),
).toBe("true");
});
describe("independent design-system routing", () => {
for (const path of ["/design-system", "/design-system/"]) {
test(`renders only the public design system at ${path}`, async () => {
await render(path);
expect(container.querySelector("#ds-title")).not.toBeNull();
expect(container.querySelectorAll("main")).toHaveLength(1);
expect(["/design-system", "/design-system/"]).toContain(container.querySelector('[data-testid="pathname"]')!.textContent!);
expect(container.querySelector(".landing-root")).toBeNull();
expect(container.querySelector(".home-root")).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(container.querySelectorAll('[role="tablist"]')).toHaveLength(1);
expect(container.querySelectorAll('[role="tab"]')).toHaveLength(4);
expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1);
for (const tab of container.querySelectorAll('[role="tab"]')) {
const panel = document.getElementById(tab.getAttribute("aria-controls")!);
expect(panel?.getAttribute("aria-labelledby")).toBe(tab.id);
}
});
}
test("calendar examples update individual and combined progress without API requests", async () => {
await render("/design-system#calendar-states");
const calendars = container.querySelectorAll<HTMLElement>("#ds-panel-calendar-states .ds-calendar");
expect(calendars).toHaveLength(3);
const inspected = (index: number) => calendars[index]!.querySelector(".ds-date-inspector")!.textContent!;
expect(inspected(0)).toContain("7 of 8 glasses");
expect(inspected(2)).toContain("1 of 2 habits complete");
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Increase glasses of water"]')!.click());
expect(inspected(0)).toContain("8 of 8 glasses");
expect(inspected(2)).toContain("2 of 2 habits complete");
const reading = container.querySelector<HTMLInputElement>('#ds-panel-calendar-states input[type="checkbox"]')!;
await act(async () => reading.click());
expect(inspected(1)).toContain("0 of 1 reading session");
expect(inspected(2)).toContain("1 of 2 habits complete");
const reset = [...container.querySelectorAll<HTMLButtonElement>('#ds-panel-calendar-states button')].find(button => button.textContent === "Reset examples ↺")!;
await act(async () => reset.click());
expect(inspected(0)).toContain("7 of 8 glasses");
expect(reading.checked).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("design system tabs", () => {
const tab = (id: string) => container.querySelector<HTMLButtonElement>(`#ds-tab-${id}`)!;
const panel = () => container.querySelector<HTMLElement>('[role="tabpanel"]:not([hidden])')!;
const clickTab = async (id: string) => { await act(async () => tab(id).click()); };
const press = async (id: string, key: string) => {
await act(async () => tab(id).dispatchEvent(
new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent,
));
};
test("defaults to foundations and each tab exposes only its own panel", async () => {
await render();
expect(panel().id).toBe("ds-panel-foundations");
for (const id of ["components", "containers", "calendar-states", "foundations"]) {
await clickTab(id);
expect(panel().id).toBe(`ds-panel-${id}`);
expect(container.querySelectorAll('[role="tabpanel"]:not([hidden])')).toHaveLength(1);
expect(tab(id).getAttribute("aria-selected")).toBe("true");
expect(container.querySelectorAll('[role="tab"][tabindex="0"]')).toHaveLength(1);
expect(container.querySelector('[data-testid="hash"]')?.textContent).toBe(`#${id}`);
}
expect(fetchMock).not.toHaveBeenCalled();
});
test("supports arrow keys, Home, End, and wraparound with focus", async () => {
await render();
await press("foundations", "ArrowLeft");
expect(document.activeElement).toBe(tab("calendar-states"));
expect(panel().id).toBe("ds-panel-calendar-states");
await press("calendar-states", "ArrowRight");
expect(document.activeElement).toBe(tab("foundations"));
await press("foundations", "End");
expect(document.activeElement).toBe(tab("calendar-states"));
await press("calendar-states", "Home");
expect(document.activeElement).toBe(tab("foundations"));
expect(panel().id).toBe("ds-panel-foundations");
});
test("honors deep links and browser history", async () => {
await render("/design-system#calendar-states");
expect(panel().id).toBe("ds-panel-calendar-states");
await clickTab("containers");
const historyButton = (label: string) => Array.from(container.querySelectorAll("button")).find(button => button.textContent === label)!;
await act(async () => historyButton("History back").click());
expect(panel().id).toBe("ds-panel-calendar-states");
await act(async () => historyButton("History forward").click());
expect(panel().id).toBe("ds-panel-containers");
});
test("keeps example state and opens Calendars from View habit", async () => {
await render("/design-system#components");
await act(async () => panel().querySelector<HTMLButtonElement>('[aria-label="Increase example count"]')!.click());
await clickTab("calendar-states");
await clickTab("components");
expect(panel().querySelector<HTMLInputElement>('[aria-label="Total example count"]')?.value).toBe("4");
const viewHabit = Array.from(panel().querySelectorAll("button")).find(button => button.textContent?.startsWith("View habit"))!;
await act(async () => viewHabit.click());
expect(panel().id).toBe("ds-panel-calendar-states");
expect(panel().querySelectorAll(".ds-calendar")).toHaveLength(3);
expect(document.activeElement).toBe(tab("calendar-states"));
});
test("retains child component state and skip link does not switch tabs", async () => {
await render("/design-system#containers");
const reading = panel().querySelector<HTMLInputElement>('input[type="checkbox"]')!;
await act(async () => reading.click());
expect(reading.checked).toBe(true);
await clickTab("foundations");
await clickTab("containers");
expect(reading.checked).toBe(true);
await act(async () => container.querySelector<HTMLAnchorElement>(".ds-skip-link")!.click());
expect(panel().id).toBe("ds-panel-containers");
expect(document.activeElement?.id).toBe("ds-main");
});
for (const [retired, destination] of [["playground", "calendar-states"], ["editing", "foundations"], ["views", "foundations"]]) {
test(`retired #${retired} links resolve to #${destination}`, async () => {
await render(`/design-system#${retired}`);
expect(panel().id).toBe(`ds-panel-${destination}`);
expect(container.querySelector('[data-testid="hash"]')?.textContent).toBe(`#${destination}`);
expect([...container.querySelectorAll('[role="tab"]')].map(item => item.textContent)).toEqual(["Foundations", "Components", "Layout", "Calendars"]);
for (const id of ["playground", "editing", "views"]) {
expect(container.querySelector(`#${id}`)).toBeNull();
expect(container.querySelector(`#ds-panel-${id}`)).toBeNull();
}
});
}
test("an unknown section safely falls back to foundations", async () => {
await render("/design-system#unknown");
expect(panel().id).toBe("ds-panel-foundations");
});
test("mounted panels keep unique IDs and valid accessible references", async () => {
await render();
const ids = Array.from(container.querySelectorAll("[id]"), element => element.id);
expect(new Set(ids).size).toBe(ids.length);
for (const element of container.querySelectorAll("[aria-labelledby], [aria-describedby], [aria-controls], label[for]")) {
for (const attribute of ["aria-labelledby", "aria-describedby", "aria-controls", "for"]) {
for (const id of element.getAttribute(attribute)?.split(/\s+/).filter(Boolean) ?? []) {
expect(document.getElementById(id)).not.toBeNull();
}
}
}
expect(fetchMock).not.toHaveBeenCalled();
});
});
test("reminder form submits native time values and explicit opt-out", async () => {
const saved: unknown[] = [];
fetchMock.mockImplementation((async (_input, init) => {
if (init?.method === "PUT") { saved.push(JSON.parse(String(init.body))); return Response.json({}); }
return Response.json({ enabled: false, time: "20:00", quietStart: "22:00", quietEnd: "08:00", available: true, lastDelivery: null });
}) as typeof fetch);
await act(async () => root.render(<ReminderSettings timezone="Europe/Belgrade" />));
const time = container.querySelector<HTMLInputElement>('input[name="time"]')!;
await act(async () => {
time.value = "14:30";
container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click();
});
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect(saved[0]).toEqual({ enabled: true, time: "14:30", quietStart: "22:00", quietEnd: "08:00" });
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
await act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event));
expect(saved[1]).toEqual({ enabled: false, time: "14:30", quietStart: "22:00", quietEnd: "08:00" });
});