import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act, useState } from "react"; import type { Root } from "react-dom/client"; import { ProgressOverview } from "./ProgressOverview"; import { fixture } from "../habits/test-fixture"; const dom = new Window({ url: "http://localhost:3000/design-system" }); const originalGlobals = new Map(); let createRoot: typeof import("react-dom/client").createRoot; let root: Root; let container: HTMLDivElement; let f: ReturnType; const originalFetch = globalThis.fetch; 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)[key], }); } ({ createRoot } = await import("react-dom/client")); }); beforeEach(() => { f = fixture(); globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { return f.request(String(input).replace(/^\/api/, ""), init?.method, init?.body ? JSON.parse(String(init.body)) : undefined); }) as typeof fetch; container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); globalThis.fetch = originalFetch; f.close(); }); afterAll(() => { dom.happyDOM.abort(); for (const [key, descriptor] of originalGlobals) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } }); function button(text: string) { return [...container.querySelectorAll("button")].find((element) => element.textContent?.trim() === text || element.getAttribute("aria-label") === text || element.getAttribute("aria-label")?.startsWith(`${text} `))!; } function Dashboard() { const [creating, setCreating] = useState(false); return <>
setCreating(false)} date="2026-09-04" revision={0} onExpired={() => {}} onHistorySaved={() => {}} />
; } async function render() { await act(async () => root.render()); } function checkbox(label: string) { return [...container.querySelectorAll('label')].find(element => element.textContent === label)!.querySelector('input')!; } async function click(text: string) { await act(async () => button(text).click()); } async function name(value: string) { const input = container.querySelector('input[placeholder="e.g. Morning routine"]')!; await act(async () => { Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); }); } test("saved overview creation, membership editing with archived habits, and deletion preserve habits", async () => { const water = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8 }, 201); const reading = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201); expect((await f.request(`/habits/${reading.id}`, 'DELETE')).status).toBe(204); await render(); await click('Create overview'); expect(container.textContent).toContain('Reading · Archived'); await name('Essentials'); await act(async () => checkbox('Water').click()); await click('Save overview'); const saved = (await f.json('/charts')).charts[0]; expect(saved.name).toBe('Essentials'); expect(saved.habitIds).toEqual([water.id]); expect(container.textContent).toContain('View day details'); await click('Edit overview'); await name('My progress'); await act(async () => checkbox('Reading · Archived').click()); expect(container.textContent).toContain('recalculates this overview’s entire history'); await click('Save overview'); expect((await f.json(`/charts/${saved.id}`)).habitIds).toEqual([water.id, reading.id]); await click('Delete overview'); const dialog = container.querySelector('#delete-overview')!; await act(async () => [...dialog.querySelectorAll('button')].find(b => b.textContent === 'Delete overview')!.click()); expect((await f.json('/charts')).charts).toHaveLength(0); expect((await f.json('/habits?archived=true')).habits).toHaveLength(2); }); test("combined calendars show completed versus due and corrections refresh the aggregate", async () => { const water = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8, unit: 'glasses' }, 201); const read = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201); await f.json(`/habits/${water.id}/days/2026-09-04/progress`, 'PUT', { count: 7 }); await f.json('/charts', 'POST', { name: 'Essentials', habitIds: [water.id, read.id] }, 201); await render(); expect(container.querySelector('button[aria-label^="2026-09-04:"]')?.getAttribute('aria-label')).toContain('0 of 2 habits complete'); await click('View day details'); expect(container.textContent).toContain('7 of 8 glasses'); await click('Edit Reading check-in'); await act(async () => container.querySelector('input[type="checkbox"]')!.click()); expect(container.textContent).toContain('1 of 2 due habits completed'); expect(container.querySelector('button[aria-label^="2026-09-04:"]')?.getAttribute('aria-label')).toContain('1 of 2 habits complete'); }); test("failed lists retry and failed saves preserve the overview draft", async () => { await f.json('/habits', 'POST', { name: 'Reading', method: 'manual' }, 201); const working = globalThis.fetch; globalThis.fetch = (async () => Response.json({ error: 'Temporarily unavailable' }, { status: 503 })) as unknown as typeof fetch; await render(); expect(container.textContent).toContain('Temporarily unavailable'); globalThis.fetch = working; await click('Retry overviews'); await click('Create overview'); await name('Keep this draft'); await act(async () => container.querySelector('input[type="checkbox"]')!.click()); globalThis.fetch = (async () => Response.json({ error: 'Save failed' }, { status: 503 })) as unknown as typeof fetch; await click('Save overview'); expect(container.textContent).toContain('Save failed'); expect(container.querySelector('input[placeholder="e.g. Morning routine"]')!.value).toBe('Keep this draft'); globalThis.fetch = working; await click('Save overview'); expect((await f.json('/charts')).charts[0].name).toBe('Keep this draft'); }); test("range changes retain the selected window and future or unscheduled days have no edit controls", async () => { const habit = await f.json('/habits', 'POST', { name: 'Reading', method: 'manual', schedule: { type: 'weekdays', days: [5] } }, 201); await f.json('/charts', 'POST', { name: 'Fridays', habitIds: [habit.id] }, 201); await render(); await act(async () => container.querySelector('button[aria-label="6 months"]')!.click()); const squares = () => container.querySelectorAll('button[aria-label^="2026-"]').length; const count = squares(); await act(async () => container.querySelector('button[aria-label^="2026-09-05:"]')!.click()); expect(squares()).toBe(count); expect(container.querySelector('.ds-calendar')?.getAttribute('data-months')).toBe('6'); await click('View day details'); const dialog = container.querySelector('#overview-day')!; expect(dialog.textContent).toContain('Nothing scheduled'); expect([...dialog.querySelectorAll('button')].some(b => b.textContent?.startsWith('Edit '))).toBe(false); await act(async () => dialog.querySelector('button[aria-label="Close dialog"]')!.click()); await act(async () => container.querySelector('button[aria-label^="2026-09-11:"]')!.click()); await click('View day details'); expect(container.querySelector('#overview-day')!.textContent).toContain('Upcoming'); expect([...container.querySelectorAll('#overview-day button')].some(b => b.textContent?.startsWith('Edit '))).toBe(false); }); test("all overviews render in the habit list and custom colors persist without resetting other settings", async () => { const habit = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201); const first = await f.json('/charts', 'POST', { name: 'Health', habitIds: [habit.id], settings: { mainColor: '#112233', shadeCount: 7 } }, 201); await f.json('/charts', 'POST', { name: 'Learning', habitIds: [habit.id] }, 201); await render(); expect(container.querySelectorAll('.ds-habit-list > .ds-overview')).toHaveLength(2); expect(container.querySelector('select')).toBeNull(); expect([...container.querySelectorAll('.ds-goal-meta')].map(el => el.textContent)).toEqual(['Combined chart · 1 habit', 'Combined chart · 1 habit']); await click('Edit overview Health'); await click('Custom'); const hex = container.querySelector('input[placeholder="#426582"]')!; await act(async () => { Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, 'value')!.set!.call(hex, '#a35488'); hex.dispatchEvent(new Event('input', { bubbles: true })); }); await click('Save overview'); expect((await f.json(`/charts/${first.id}`)).settings).toMatchObject({ mainColor: '#a35488', shadeCount: 7 }); expect(container.querySelectorAll('.ds-overview')).toHaveLength(2); await click('Edit overview Health'); expect(container.querySelector('input[placeholder="#426582"]')!.value).toBe('#a35488'); }); test("day graphics show partial task and count progress without crediting incomplete habits", async () => { const count = await f.json('/habits', 'POST', { name: 'Water', method: 'count', target: 8, unit: 'glasses' }, 201); const routine = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', tasks: [{ name: 'Stretch' }, { name: 'Walk' }] }, 201); const manual = await f.json('/habits', 'POST', { name: 'Read', method: 'manual' }, 201); await f.json(`/habits/${count.id}/days/2026-09-04/progress`, 'PUT', { count: 7 }); await f.json(`/habits/${manual.id}/days/2026-09-04/progress`, 'PUT', { done: true }); await f.json(`/habits/${routine.id}/days/2026-09-04/tasks/${routine.tasks[0].id}`, 'PUT', { done: true }); await f.json('/charts', 'POST', { name: 'Together', habitIds: [count.id, routine.id, manual.id] }, 201); await render(); await click('View day details'); expect(container.querySelector('.ds-overview-ring')?.textContent).toBe('33%'); expect(container.querySelector('progress[aria-label="Water progress"]')?.getAttribute('value')).toBe('7'); expect(container.querySelector('progress[aria-label="Routine progress"]')?.getAttribute('value')).toBe('1'); expect(container.querySelectorAll('.ds-overview-tasks li')).toHaveLength(2); expect(container.querySelector('.ds-overview-check')?.textContent).toBe('Checked in'); });