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
This commit is contained in:
parent
749752df4e
commit
b5b6f6bc00
15 changed files with 1290 additions and 52 deletions
177
ui/components/GraphExplorer.history.test.tsx
Normal file
177
ui/components/GraphExplorer.history.test.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// @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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue