import { describe, it } from "node:test"; import assert from "node:assert/strict"; import type { GraphData, GraphTypeFilters, PinMap, SelectionInput, ThemeId } from "../src"; import { createGraphFacadeFromRenderer, createGraphFacadeRouteManager, type GraphFacadeRenderer, type GraphFacadeRouteRendererFactoryInput, type GraphFacadeState } from "../src/facade"; const DATA: GraphData = { meta: { build_date: "2026-06-16", wiki_title: "Route continuity test graph", total_nodes: 2, total_edges: 1 }, nodes: [ { id: "a", label: "Alpha", type: "topic", community: "c1", source_path: "wiki/a.md", content: "Alpha content" }, { id: "b", label: "Beta", type: "source", community: "c1", source_path: "wiki/b.md", content: "Beta content" } ], edges: [ { id: "a->b", from: "a", to: "b", type: "EXTRACTED", confidence: "EXTRACTED", relation_type: "实现", weight: 1 } ] }; describe("graph route state continuity", () => { it("preserves selected, searched, pinned, and community state across a data refresh when objects still exist", () => { const container = { dataset: {} as Record }; const state: GraphFacadeState = { data: DATA, pins: { "wiki/a.md": { x: 10, y: 20, coordinateSpace: "world" } }, theme: "shan-shui", focus: null, typeFilters: { topic: true, source: false }, aggregationMarkers: [], selection: null, searchQuery: "", searchResultIds: [], temporaryObject: null }; const sigmaInputs: GraphFacadeRouteRendererFactoryInput[] = []; const communityInputs: GraphFacadeRouteRendererFactoryInput[] = []; const communityRenderers: Array = []; const manager = createGraphFacadeRouteManager(container as unknown as HTMLElement, { state, factories: { createSigmaGlobal: (input) => { sigmaInputs.push(input); return createFakeRenderer(); }, createDomSvgCommunity: (input) => { communityInputs.push(input); return trackRenderer(communityRenderers, "dom-community"); }, createDomSvgSmallFallback: () => createFakeRenderer(), createOverLimitNotice: () => createFakeRenderer() } }); sigmaInputs[0].options.callbacks.onVisibilityStateChange?.({ searchQuery: "Alpha", searchResultIds: ["a"], typeFilters: { topic: true, source: false }, temporaryObject: { kind: "node", nodeId: "b" } }); manager.select({ kind: "node", id: "a" }); manager.focusCommunity("c1"); const refreshedData: GraphData = { ...DATA, meta: { ...DATA.meta, wiki_title: "Refreshed graph" }, nodes: DATA.nodes.map((node) => node.id === "a" ? { ...node, label: "Alpha refreshed" } : node) }; manager.setData(refreshedData); assert.equal(manager.routeId, "dom-svg-community"); assert.equal(state.data, refreshedData); assert.deepEqual(state.focus, { kind: "community", id: "c1" }); assert.deepEqual(state.selection, { kind: "node", id: "a" }); assert.equal(state.searchQuery, "Alpha"); assert.deepEqual(state.searchResultIds, ["a"]); assert.deepEqual(state.typeFilters, { topic: true, source: false }); assert.deepEqual(state.temporaryObject, { kind: "node", nodeId: "b" }); assert.deepEqual(state.pins, { "wiki/a.md": { x: 10, y: 20, coordinateSpace: "world" } }); assert.deepEqual(communityInputs[0].options.selection, { kind: "node", id: "a" }); assert.deepEqual(communityInputs[0].options.searchResultIds, ["a"]); assert.deepEqual(communityRenderers[0].calls.at(-1), ["setData", refreshedData, undefined]); }); it("makes a disappeared selected object explicitly unavailable after data refresh", () => { const container = { dataset: {} as Record }; const renderer = createFakeRenderer(); const engine = createGraphFacadeFromRenderer(container, renderer, { data: DATA, theme: "shan-shui", pins: { "wiki/a.md": { x: 10, y: 20, coordinateSpace: "world" } } }); engine.select({ kind: "node", id: "a" }); const refreshedData: GraphData = { ...DATA, nodes: DATA.nodes.filter((node) => node.id !== "a"), edges: [] }; engine.setData(refreshedData); const unavailable = engine.summarizeUnavailableObject({ kind: "node", nodeId: "a" }, "missing-node"); assert.equal(unavailable.kind, "unavailable-object"); assert.equal(unavailable.reason, "missing-node"); assert.deepEqual(unavailable.object, { kind: "node", nodeId: "a" }); assert.deepEqual(unavailable.selection.input, { kind: "node", id: "a" }); assert.deepEqual(unavailable.selection.selectedNodeIds, []); assert.equal(unavailable.selection.containsCurrentObject, false); assert.deepEqual(unavailable.pinHints, []); }); it("passes shared interaction state into DOM/SVG small fallback and keeps return-global rules consistent", () => { const container = { dataset: {} as Record }; const state: GraphFacadeState = { data: DATA, pins: { "wiki/a.md": { x: 10, y: 20, coordinateSpace: "world" } }, theme: "shan-shui", focus: null, typeFilters: { topic: true, source: true }, aggregationMarkers: [], selection: { kind: "node", id: "a" }, searchQuery: "Alpha", searchResultIds: ["a"], temporaryObject: null }; const fallbackInputs: GraphFacadeRouteRendererFactoryInput[] = []; const communityInputs: GraphFacadeRouteRendererFactoryInput[] = []; const fallbackRenderers: Array = []; const manager = createGraphFacadeRouteManager(container as unknown as HTMLElement, { state, factories: { createSigmaGlobal: () => { throw new Error("WebGL unavailable"); }, createDomSvgCommunity: (input) => { communityInputs.push(input); return createFakeRenderer(); }, createDomSvgSmallFallback: (input) => { fallbackInputs.push(input); return trackRenderer(fallbackRenderers, "small-fallback"); }, createOverLimitNotice: () => createFakeRenderer() } }); assert.equal(manager.routeId, "dom-svg-small-fallback"); assert.deepEqual(fallbackInputs[0].options.selection, { kind: "node", id: "a" }); assert.equal(fallbackInputs[0].options.searchQuery, "Alpha"); assert.deepEqual(fallbackInputs[0].options.searchResultIds, ["a"]); assert.deepEqual(Object.keys(fallbackInputs[0].options.pins), ["wiki/a.md"]); fallbackInputs[0].options.callbacks.onSelectionInput?.({ kind: "community", id: "c1" }); assert.deepEqual(state.selection, { kind: "community", id: "c1" }); assert.equal(manager.routeId, "dom-svg-small-fallback"); manager.focusCommunity("c1"); assert.equal(manager.routeId, "dom-svg-community"); assert.deepEqual(communityInputs[0].options.selection, { kind: "community", id: "c1" }); communityInputs[0].options.callbacks.onGlobalResetRequested?.(); assert.equal(manager.routeId, "dom-svg-small-fallback"); assert.equal(manager.sigmaKnownUnavailable, true); assert.equal(state.selection, null); assert.deepEqual(fallbackInputs.at(-1)?.options.selection, null); assert.equal(fallbackInputs.at(-1)?.options.searchQuery, "Alpha"); assert.deepEqual(fallbackInputs.at(-1)?.options.searchResultIds, ["a"]); assert.deepEqual(Object.keys(fallbackInputs.at(-1)?.options.pins || {}), ["wiki/a.md"]); assert.deepEqual(fallbackRenderers.at(-1)?.calls.filter((call) => call[0] === "resetView"), [["resetView"]]); }); }); function createFakeRenderer(): GraphFacadeRenderer & { calls: unknown[][] } { const calls: unknown[][] = []; return { calls, async applyDiff(diff, options) { calls.push(["applyDiff", diff, options]); }, isDragging() { calls.push(["isDragging"]); return false; }, setData(data: GraphData, pins?: PinMap) { calls.push(["setData", data, pins]); }, setAggregationMarkers(markers) { calls.push(["setAggregationMarkers", markers]); }, focusNode(path: string) { calls.push(["focusNode", path]); }, focusCommunity(id: string) { calls.push(["focusCommunity", id]); }, previewNode(id: string | null) { calls.push(["previewNode", id]); }, setTypeFilters(filters: GraphTypeFilters) { calls.push(["setTypeFilters", filters]); }, showTemporaryObject(object) { calls.push(["showTemporaryObject", object]); }, clearTemporaryObjectDisplay() { calls.push(["clearTemporaryObjectDisplay"]); }, resetView() { calls.push(["resetView"]); }, select(selection: SelectionInput) { calls.push(["select", selection]); }, clearSelection() { calls.push(["clearSelection"]); }, clearInteraction() { calls.push(["clearInteraction"]); }, setNodeFixed(id: string, mode: "fix" | "unfix") { calls.push(["setNodeFixed", id, mode]); return true; }, setTheme(theme: ThemeId) { calls.push(["setTheme", theme]); }, setPins(pins: PinMap) { calls.push(["setPins", pins]); }, resetLayout() { calls.push(["resetLayout"]); }, destroy() { calls.push(["destroy"]); } }; } function trackRenderer( renderers: Array, route: string ): GraphFacadeRenderer & { calls: unknown[][] } { const renderer = createFakeRenderer(); renderer.calls.push(["create", route]); renderers.push(renderer); return renderer; }