747 lines
39 KiB
TypeScript
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" });
|
|
});
|