first
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
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<string, string | undefined> };
|
||||
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<GraphFacadeRenderer & { calls: unknown[][] }> = [];
|
||||
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<string, string | undefined> };
|
||||
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<string, string | undefined> };
|
||||
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<GraphFacadeRenderer & { calls: unknown[][] }> = [];
|
||||
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<GraphFacadeRenderer & { calls: unknown[][] }>,
|
||||
route: string
|
||||
): GraphFacadeRenderer & { calls: unknown[][] } {
|
||||
const renderer = createFakeRenderer();
|
||||
renderer.calls.push(["create", route]);
|
||||
renderers.push(renderer);
|
||||
return renderer;
|
||||
}
|
||||
Reference in New Issue
Block a user