content-explorer/ui/components/GraphExplorer.history.test.tsx
tegwick b5b6f6bc00 Add a bounded relation graph and knowledge journeys.
CONTENT-WP-0004 keeps outgoing and incoming labels apart, limits the graph to two hops, and stores focus and detail in the URL.

Assistant: grok
Assistant-Session: 01a1531b-6698-7732-8587-f28ea604bcdf
2026-10-19 10:00:20 +02:00

177 lines
7 KiB
TypeScript

// @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(<GraphExplorer model={model} initialSearch={initialSearch} history={history} />);
});
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();
});
});