Add personal progress overviews with saved combined calendars
This commit is contained in:
153
src/components/ProgressOverview.test.tsx
Normal file
153
src/components/ProgressOverview.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { act } 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<string, PropertyDescriptor | undefined>();
|
||||
let createRoot: typeof import("react-dom/client").createRoot;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let f: ReturnType<typeof fixture>;
|
||||
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<string, unknown>)[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<HTMLButtonElement>("button")].find((element) => element.textContent?.trim() === text)!;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
await act(async () => root.render(<ProgressOverview date="2026-09-04" revision={0} onExpired={() => {}} onHistorySaved={() => {}} />));
|
||||
}
|
||||
function checkbox(label: string) {
|
||||
return [...container.querySelectorAll('label')].find(element => element.textContent === label)!.querySelector<HTMLInputElement>('input')!;
|
||||
}
|
||||
async function click(text: string) { await act(async () => button(text).click()); }
|
||||
async function name(value: string) {
|
||||
const input = container.querySelector<HTMLInputElement>('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<HTMLButtonElement>('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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLButtonElement>('button[aria-label="6 months"]')!.click());
|
||||
const squares = () => container.querySelectorAll('button[aria-label^="2026-"]').length;
|
||||
const count = squares();
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-label="Close dialog"]')!.click());
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('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);
|
||||
});
|
||||
Reference in New Issue
Block a user