// @vitest-environment happy-dom import { createRoot, type Root } from "react-dom/client"; import { act } from "react"; import { afterEach, describe, expect, it } from "vitest"; import { findGraph } from "@/lib/catalog"; import { browserGraphHistory, type GraphHistory } from "@/lib/graph"; import { GraphExplorer } from "./GraphExplorer"; declare global { var IS_REACT_ACT_ENVIRONMENT: boolean; } globalThis.IS_REACT_ACT_ENVIRONMENT = true; let root: Root | null = null; afterEach(() => { act(() => root?.unmount()); root = null; document.body.innerHTML = ""; }); function sessionHistory(): GraphHistory & { back: () => void; forward: () => void; location: () => string } { const stack = [""]; let index = 0; const listeners = new Set<() => void>(); const notify = () => { for (const listener of listeners) listener(); }; return { read: () => (stack[index] ? `?${stack[index]}` : ""), push: (search) => { stack.splice(index + 1); stack.push(search); index = stack.length - 1; }, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, back: () => { if (index === 0) return; index -= 1; notify(); }, forward: () => { if (index >= stack.length - 1) return; index += 1; notify(); }, location: () => (stack[index] ? `?${stack[index]}` : ""), }; } function focusTitle(): string { return document.querySelector("[data-focus]")?.textContent ?? ""; } function neighborhoodIds(): string[] { return [...document.querySelectorAll("[data-neighborhood]")].map((node) => node.getAttribute("data-neighborhood") ?? ""); } async function renderGraph(history: GraphHistory, initialSearch = "") { const model = findGraph("systems-thinking"); if (!model) throw new Error("missing graph"); const host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); await act(async () => { root?.render(); }); return model; } describe("graph explorer history", () => { it("changes the visible neighborhood with focus and hop distance", async () => { const history = sessionHistory(); const model = await renderGraph(history); const feedback = model.nodes.find((node) => node.id.endsWith(":feedback")); const causal = model.nodes.find((node) => node.id.endsWith(":causal-loop")); const boundary = model.nodes.find((node) => node.id.endsWith(":system-boundary")); if (!feedback || !causal || !boundary) throw new Error("missing fixture nodes"); const wide = neighborhoodIds(); expect(wide).toContain(feedback.id); expect(wide).toContain(boundary.id); const excluded = Number(document.querySelector("[data-excluded-count]")?.getAttribute("data-excluded-count")); expect(excluded).toBeGreaterThan(0); expect(document.body.textContent).toContain(`${excluded} relations outside this neighborhood`); await act(async () => { document.querySelector("button[aria-pressed='false']")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(neighborhoodIds()).not.toContain(boundary.id); expect(Number(document.querySelector("[data-excluded-count]")?.getAttribute("data-excluded-count"))).toBeGreaterThan(excluded); await act(async () => { document.querySelector(`button[data-address="${causal.id}"]`)?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(focusTitle()).toBe(causal.title); expect(neighborhoodIds()).toContain(causal.id); expect(neighborhoodIds()).not.toEqual(wide); const adjacency = document.querySelector("[data-adjacency]")?.textContent ?? ""; const exported = JSON.parse(adjacency) as { edges: { id: string; predicate: string; direction: string }[] }; expect(exported.edges.length).toBeGreaterThan(0); expect(exported.edges.every((edge) => edge.id && edge.predicate && edge.direction === "out")).toBe(true); }); it("restores focus and detail on reload, back, and forward", async () => { const history = sessionHistory(); const model = await renderGraph(history); const feedback = model.nodes.find((node) => node.id.endsWith(":feedback")); const causal = model.nodes.find((node) => node.id.endsWith(":causal-loop")); const boundary = model.nodes.find((node) => node.id.endsWith(":system-boundary")); if (!feedback || !causal || !boundary) throw new Error("missing fixture nodes"); expect(focusTitle()).toBe(feedback.title); expect(document.body.textContent).toContain("Represented by"); expect(document.body.textContent).toContain("Modeled by"); await act(async () => { document.querySelector(`button[data-focus-node="${causal.id}"]`)?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(focusTitle()).toBe(causal.title); expect(decodeURIComponent(history.location())).toContain(causal.id); await act(async () => { document.querySelector(`button[data-open-detail="${feedback.id}"]`)?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(document.querySelector("[data-detail]")?.getAttribute("data-detail")).toBe(feedback.id); expect(focusTitle()).toBe(causal.title); const saved = history.location(); expect(decodeURIComponent(saved)).toContain("detail="); expect(decodeURIComponent(saved)).toContain(causal.id); act(() => root?.unmount()); root = null; document.body.innerHTML = ""; await renderGraph(history, saved); expect(focusTitle()).toBe(causal.title); expect(document.querySelector("[data-detail]")?.getAttribute("data-detail")).toBe(feedback.id); await act(async () => { history.back(); }); expect(focusTitle()).toBe(causal.title); expect(document.querySelector("[data-detail]")).toBeNull(); await act(async () => { history.back(); }); expect(focusTitle()).toBe(feedback.title); expect(neighborhoodIds()).toContain(boundary.id); await act(async () => { history.forward(); }); expect(focusTitle()).toBe(causal.title); expect(document.querySelector("[data-detail]")).toBeNull(); }); it("reads a pushed query and a popstate traversal", () => { const model = findGraph("systems-thinking"); if (!model) throw new Error("missing graph"); window.history.replaceState(null, "", "/systems-thinking/graph/"); const history = browserGraphHistory(); const seen: string[] = []; const stop = history.subscribe(() => seen.push(history.read())); const focus = model.defaultFocusId; history.push(`focus=${encodeURIComponent(focus)}&detail=${encodeURIComponent(focus)}`); expect(window.location.search).toContain("focus="); expect(window.location.search).toContain("detail="); expect(seen).toEqual([]); window.history.pushState(null, "", `/systems-thinking/graph/?focus=${encodeURIComponent(focus)}`); window.dispatchEvent(new PopStateEvent("popstate")); expect(seen).toEqual([`?focus=${encodeURIComponent(focus)}`]); stop(); }); });