import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { taskFixtures } from './page-fixtures'; import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from '@/lib/freshness/model'; import { writeSnapshotCache } from '@/lib/freshness/snapshot-cache'; import { validateTaskCollection } from '@/lib/freshness/validators'; const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn(), })); vi.mock('@/lib/api', () => ({ api: apiMock, })); import { TasksPage } from './tasks'; interface Deferred { promise: Promise; resolve: (value: T) => void; } function createDeferred(): Deferred { let resolve!: (value: T) => void; const promise = new Promise((res) => { resolve = res; }); return { promise, resolve }; } let root: Root | null = null; let container: HTMLDivElement; beforeAll(() => { Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true, }); }); afterAll(() => { Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); }); afterEach(async () => { await act(async () => { root?.unmount(); }); document.body.replaceChildren(); root = null; apiMock.mockReset(); sessionStorage.clear(); }); async function renderTasksPage(): Promise { const routes: RouteObject[] = [{ path: '/tasks', element: }]; const router = createMemoryRouter(routes, { initialEntries: ['/tasks'] }); container = document.createElement('div'); document.body.append(container); root = createRoot(container); await act(async () => { root?.render(); }); } function clickButtonByText(text: string): void { const button = [...container.querySelectorAll('button')].find((candidate) => candidate.textContent?.includes(text), ); if (!button) { throw new Error(`Button containing "${text}" not found`); } button.dispatchEvent(new MouseEvent('click', { bubbles: true })); } /** Flush pending promise callbacks inside the act environment. */ async function flushAct(): Promise { await act(async () => { await Promise.resolve(); }); } describe('TasksPage', () => { it('shows a visible loading state before the tasks request settles', async () => { const deferred = createDeferred(); apiMock.mockReturnValueOnce(deferred.promise); await renderTasksPage(); expect(container.textContent).toContain('Loading tasks...'); await act(async () => { deferred.resolve(taskFixtures); await deferred.promise; }); }); it('starts in kanban view, toggles to list view, and opens the read-only modal from cards and rows', async () => { apiMock.mockResolvedValueOnce(taskFixtures); await renderTasksPage(); expect(container.textContent).toContain('Not Started'); expect(container.textContent).toContain('In Progress'); expect(container.textContent).toContain('Blocked'); const kanbanCard = [...container.querySelectorAll('button')].find((candidate) => candidate.textContent?.includes('Route /projects/:id'), ); expect(kanbanCard).toBeTruthy(); await act(async () => { kanbanCard?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(container.querySelector('[role="dialog"]')).toBeTruthy(); await act(async () => { container .querySelector('button[aria-label="Close task details"]') ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(container.querySelector('[role="dialog"]')).toBeNull(); await act(async () => { clickButtonByText('List'); }); const row = [...container.querySelectorAll('tr')].find((candidate) => candidate.textContent?.includes('Route /tasks'), ); expect(row).toBeTruthy(); await act(async () => { row?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(container.querySelector('[role="dialog"]')).toBeTruthy(); expect(container.textContent).toContain('Wire list and kanban modal interactions'); }); it('renders a failed fetch as an explicit unavailable state, never an empty healthy board', async () => { apiMock.mockRejectedValueOnce(new Error('Tasks request failed')); await renderTasksPage(); const alert = container.querySelector('[role="alert"]'); expect(alert).toBeTruthy(); expect(alert?.textContent).toContain('Tasks request failed'); expect(alert?.textContent).toContain('not an empty result'); // Negative controls: no board, no healthy empty-state markers, and the // surface is marked unavailable rather than current. expect(container.textContent).not.toContain('Not Started'); expect(container.textContent).not.toContain('No tasks'); expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( 'unavailable', ); }); it('recovers to a current board after retrying a failed fetch', async () => { apiMock .mockRejectedValueOnce(new Error('Tasks request failed')) .mockResolvedValueOnce(taskFixtures); await renderTasksPage(); expect(container.querySelector('[role="alert"]')).toBeTruthy(); await act(async () => { clickButtonByText('Retry'); }); await flushAct(); expect(container.querySelector('[role="alert"]')).toBeNull(); expect(container.textContent).toContain('Not Started'); expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( 'current', ); }); it('labels restored last-known data as stale with source, version, and age until verified', async () => { // Seed a last-known snapshot fetched five minutes ago; the page must // render it only under an explicit staleness label while the fetch is // still in flight. const restored = acceptSnapshot({ value: taskFixtures, validate: validateTaskCollection, previous: null, policy: DEFAULT_FRESHNESS_POLICY, source: 'gateway:/api/tasks', now: Date.now() - 5 * 60_000, }); if (restored.outcome !== 'accepted') throw new Error('fixture setup failed'); writeSnapshotCache('tasks', restored.snapshot); const deferred = createDeferred(); apiMock.mockReturnValueOnce(deferred.promise); await renderTasksPage(); expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( 'stale', ); const banner = container.querySelector('[role="status"]'); expect(banner?.textContent).toContain('last-known'); expect(banner?.textContent).toContain('may be out of date'); expect(banner?.textContent).toContain('gateway:/api/tasks'); expect(banner?.textContent).toContain('snapshot v1'); expect(banner?.textContent).toContain('5m ago'); // Last-known data still renders as situational awareness under the label. expect(container.textContent).toContain('Route /tasks'); expect(container.textContent).not.toContain('Loading tasks...'); // Verification lands: the banner clears and the surface becomes current. await act(async () => { deferred.resolve(taskFixtures); await deferred.promise; }); expect(container.querySelector('[role="status"]')).toBeNull(); expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe( 'current', ); }); });