// @vitest-environment happy-dom import { createRoot, type Root } from "react-dom/client"; import { act, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, describe, expect, it } from "vitest"; import { EntityDetail } from "@/components/EntityDetail"; import { GraphExplorer } from "@/components/GraphExplorer"; import { RecordBrowser } from "@/components/RecordBrowser"; import { findEntity, findGraph, findKind, type EntityView } from "@/lib/catalog"; import type { GraphHistory } from "@/lib/graph"; import type { ViewHistory } from "@/lib/table-view"; declare global { var IS_REACT_ACT_ENVIRONMENT: boolean; } globalThis.IS_REACT_ACT_ENVIRONMENT = true; const SPACES = ["systems-thinking", "software-architecture"] as const; let root: Root | null = null; afterEach(() => { act(() => root?.unmount()); root = null; document.body.innerHTML = ""; }); function memoryHistory(): ViewHistory { let current = ""; return { read: () => (current ? `?${current}` : ""), push: (search) => { current = search; }, subscribe: () => () => undefined, }; } function fixedHistory(search: string): GraphHistory { return { read: () => search, push: () => undefined, subscribe: () => () => undefined, }; } function journeyEntity(dataspaceId: string): EntityView { const kind = findKind(dataspaceId, "concept"); if (!kind) throw new Error(`missing concept kind in ${dataspaceId}`); for (const row of kind.rows) { const slug = row.route.split("/").filter(Boolean).at(-1) ?? ""; const entity = findEntity(dataspaceId, "concept", decodeURIComponent(slug)); if (entity && (entity.outgoing.length > 0 || entity.incoming.length > 0) && entity.sources.length > 0) { return entity; } } throw new Error(`no related record in ${dataspaceId}`); } function mount(node: ReactNode) { const host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); return act(async () => { root?.render(node); }); } describe("critical journeys", () => { it("browses, searches, opens a record, follows a neighbour, shows a source, and opens the graph", async () => { for (const id of SPACES) { const kind = findKind(id, "concept"); const entity = journeyEntity(id); expect(kind).not.toBeNull(); document.body.innerHTML = ""; await mount(); const names = () => [...document.querySelectorAll("tbody tr td:first-child")].map((cell) => cell.textContent ?? ""); expect(names().length).toBe(kind!.rows.length); expect(names()).toContain(entity.title); const input = document.querySelector("input[aria-label='Search records']"); if (!(input instanceof HTMLInputElement)) throw new Error("missing search"); await act(async () => { const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; setValue?.call(input, entity.title); input.dispatchEvent(new Event("input", { bubbles: true })); input.form?.requestSubmit(); }); expect(names()).toEqual([entity.title]); const detailHtml = renderToStaticMarkup(); const detail = new DOMParser().parseFromString(detailHtml, "text/html"); const source = entity.sources[0]; expect(source).toBeTruthy(); expect(detail.body.textContent).toContain(source.title); expect(detail.body.textContent).toContain(source.license); const neighbour = entity.outgoing[0] ?? entity.incoming[0]; expect(neighbour.other.href).toBeTruthy(); const neighbourLink = detail.querySelector(`a[href="${neighbour.other.href}"]`); expect(neighbourLink?.textContent).toContain(neighbour.other.title); const parts = neighbour.other.href!.split("/").filter(Boolean); const followed = findEntity(parts[0], parts[1], decodeURIComponent(parts[2])); expect(followed?.title).toBe(neighbour.other.title); expect(renderToStaticMarkup()).toContain(followed!.title); expect(detailHtml).toContain(entity.graphHref); act(() => root?.unmount()); root = null; document.body.innerHTML = ""; const graph = findGraph(id); expect(graph).not.toBeNull(); const search = `?${new URLSearchParams({ focus: entity.id }).toString()}`; await mount(); expect(document.querySelector("[data-focus]")?.getAttribute("data-focus")).toBe(entity.id); expect(document.body.textContent).toContain(neighbour.wording); expect(document.querySelector("[data-excluded-count]")).toBeTruthy(); act(() => root?.unmount()); root = null; } }); it("can focus search, a result, the record link, a relation, and the way back", async () => { for (const id of SPACES) { const kind = findKind(id, "concept"); const entity = journeyEntity(id); document.body.innerHTML = ""; await mount(); const search = document.querySelector("input[aria-label='Search records']"); const result = document.querySelector("tbody a"); if (!(search instanceof HTMLElement) || !(result instanceof HTMLAnchorElement)) throw new Error("missing focus targets"); search.focus(); expect(document.activeElement).toBe(search); expect(search.tabIndex).toBeGreaterThanOrEqual(0); result.focus(); expect(document.activeElement).toBe(result); expect(result.getAttribute("href")).toBeTruthy(); act(() => root?.unmount()); root = null; document.body.innerHTML = ""; const host = document.createElement("div"); host.innerHTML = renderToStaticMarkup(); document.body.appendChild(host); const relation = host.querySelector("[data-relation] a"); const back = [...host.querySelectorAll("a")].find((link) => link.textContent?.startsWith("Back to")); if (!(relation instanceof HTMLAnchorElement) || !(back instanceof HTMLAnchorElement)) { throw new Error(`missing record links in ${id}`); } relation.focus(); expect(document.activeElement).toBe(relation); back.focus(); expect(document.activeElement).toBe(back); expect(back.getAttribute("href")).toBe(entity.kindHref); } }); });