1712 lines
63 KiB
TypeScript
1712 lines
63 KiB
TypeScript
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import type { GraphData, GraphDiff, SelectionInput } from "../src";
|
|
import { createGraphRenderer } from "../src/render";
|
|
import {
|
|
createGraphFacadeRouteManager,
|
|
type GraphFacadeRenderer,
|
|
type GraphFacadeRouteRendererFactoryInput
|
|
} from "../src/facade";
|
|
|
|
describe("graph renderer lifecycle", () => {
|
|
it("renders a static over-limit notice without aggregation containers or DOM full graph", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
let sigmaAttempts = 0;
|
|
const manager = createGraphFacadeRouteManager(container as unknown as HTMLElement, {
|
|
state: {
|
|
data: largeFallbackGraphData(),
|
|
pins: {},
|
|
theme: "shan-shui",
|
|
focus: null,
|
|
typeFilters: {},
|
|
aggregationMarkers: [],
|
|
selection: null,
|
|
searchResultIds: [],
|
|
temporaryObject: null
|
|
},
|
|
factories: {
|
|
createSigmaGlobal: () => {
|
|
sigmaAttempts += 1;
|
|
throw new Error("WebGL unavailable");
|
|
}
|
|
}
|
|
});
|
|
const overLimitView = findByClass(container, "graph-over-limit-notice-view")[0];
|
|
const notice = findByClass(container, "graph-over-limit-notice")[0];
|
|
const title = findByClass(container, "graph-over-limit-notice-title")[0];
|
|
const body = findByClass(container, "graph-over-limit-notice-body")[0];
|
|
const oldContainers = findByClass(container, "graph-aggregation-safety-container");
|
|
const oldActions = findByClass(container, "graph-aggregation-safety-actions");
|
|
const retry = findByDataset(container, "action", "retry-sigma");
|
|
const clear = findByDataset(container, "action", "clear-selection");
|
|
|
|
assert.equal(manager.routeId, "over-limit-notice");
|
|
assert.equal(overLimitView?.dataset.notice, "node-count-over-limit");
|
|
assert.equal(overLimitView?.dataset.nodeLimit, "2000");
|
|
assert.equal(overLimitView?.dataset.containerCount, "0");
|
|
assert.equal(notice?.dataset.role, "over-limit-notice");
|
|
assert.equal(title?.textContent, "图谱节点较多");
|
|
assert.equal(body?.textContent, "当前图谱超过 2000 个节点。请用搜索、筛选或进入社区缩小范围。");
|
|
assert.deepEqual(oldContainers, []);
|
|
assert.deepEqual(oldActions, []);
|
|
assert.equal(retry, undefined);
|
|
assert.equal(clear, undefined);
|
|
assert.deepEqual(visibleNodeIds({ root: container as unknown as HTMLElement }), []);
|
|
assert.equal(sigmaAttempts, 0);
|
|
});
|
|
|
|
it("routes a community click to lightweight selection instead of focusing the community", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const selections: SelectionInput[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataWithCommunities([
|
|
["a", "community-a"],
|
|
["b", "community-b"]
|
|
]),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
onSelectionInput: (selection) => selections.push(selection)
|
|
});
|
|
|
|
findByDataset(renderer.root as unknown as FakeElement, "communityId", "community-a")?.dispatch("click");
|
|
|
|
assert.deepEqual(selections, [{ kind: "community", id: "community-a" }]);
|
|
assert.ok(nodeElement(renderer, "a"));
|
|
assert.ok(nodeElement(renderer, "b"));
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("does not render aggregation marker containers in normal DOM/SVG output", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const selections: SelectionInput[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataWithCommunities([
|
|
["a", "community-a"],
|
|
["b", "community-a"],
|
|
["c", "community-b"]
|
|
]),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
aggregationMarkers: [
|
|
{
|
|
id: "agg-community-a",
|
|
label: "Community A overflow",
|
|
communityId: "community-a",
|
|
nodeIds: ["a", "b"],
|
|
selectedNodeIds: ["a"],
|
|
searchResultIds: ["b"],
|
|
totalCount: 6
|
|
}
|
|
],
|
|
onSelectionInput: (selection) => selections.push(selection)
|
|
});
|
|
const aggregation = findByDataset(renderer.root as unknown as FakeElement, "aggregationId", "agg-community-a");
|
|
|
|
assert.equal(aggregation, undefined);
|
|
assert.deepEqual(selections, []);
|
|
assert.deepEqual(visibleNodeIds(renderer), ["a", "b", "c"]);
|
|
assert.equal(renderer.root.dataset.llmWikiGraphFocus, undefined);
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("renders poor community quality as a light warning with only core connectivity fallback", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: poorCommunityQualityGraphData(),
|
|
theme: "shan-shui",
|
|
live: false
|
|
});
|
|
const notice = findByClass(renderer.root as unknown as FakeElement, "graph-quality-notice")[0];
|
|
const action = findByClass(renderer.root as unknown as FakeElement, "graph-quality-notice-action")[0];
|
|
const wash = findByClass(renderer.root as unknown as FakeElement, "community-wash")[0];
|
|
|
|
assert.equal(renderer.root.dataset.communityQuality, "poor");
|
|
assert.equal(renderer.root.dataset.communityBoundaryCertainty, "low");
|
|
assert.equal(renderer.root.dataset.communityAuxiliaryViews, "core-structure-connectivity");
|
|
assert.ok(notice);
|
|
assert.equal(notice.dataset.qualityLevel, "poor");
|
|
assert.equal(action?.dataset.auxiliaryViewId, "core-structure-connectivity");
|
|
assert.equal(/type|source|time/i.test(renderer.root.dataset.communityAuxiliaryViews || ""), false);
|
|
assert.equal(wash?.dataset.boundaryCertainty, "low");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("routes a global node click to lightweight selection instead of opening the page", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const opened: string[] = [];
|
|
const selections: SelectionInput[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphData(["a"]),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
onNodeOpen: (id) => opened.push(id),
|
|
onSelectionInput: (selection) => selections.push(selection)
|
|
});
|
|
|
|
nodeElement(renderer, "a")?.dispatch("click", { detail: 0 });
|
|
|
|
assert.deepEqual(opened, []);
|
|
assert.deepEqual(selections, [{ kind: "node", id: "a" }]);
|
|
assert.equal(nodeElement(renderer, "a")?.getAttribute("aria-pressed"), "true");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("preserves DOM community selection when refreshed data still contains the selected object", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const clearRequests: number[] = [];
|
|
const selections: SelectionInput[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataWithCommunities([
|
|
["a", "community-a"],
|
|
["b", "community-a"],
|
|
["c", "community-b"]
|
|
]),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" },
|
|
onSelectionInput: (selection) => selections.push(selection),
|
|
onSelectionClearRequested: () => clearRequests.push(1)
|
|
});
|
|
|
|
renderer.select({ kind: "node", id: "a" });
|
|
assert.deepEqual(selections, []);
|
|
assert.equal(nodeElement(renderer, "a")?.getAttribute("aria-pressed"), "true");
|
|
|
|
renderer.setData(graphDataWithCommunities([
|
|
["a", "community-a"],
|
|
["b", "community-a"],
|
|
["d", "community-b"]
|
|
]));
|
|
|
|
assert.deepEqual(clearRequests, []);
|
|
assert.equal(nodeElement(renderer, "a")?.getAttribute("aria-pressed"), "true");
|
|
assert.ok(nodeElement(renderer, "b"));
|
|
assert.equal(nodeElement(renderer, "c"), undefined);
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("clears selection on a blank click without leaving community focus", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const clearRequests: number[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataWithCommunities([
|
|
["a", "community-a"],
|
|
["b", "community-a"],
|
|
["c", "community-b"]
|
|
]),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" },
|
|
onSelectionClearRequested: () => clearRequests.push(1)
|
|
});
|
|
|
|
renderer.select({ kind: "node", id: "a" });
|
|
dispatchPointerSequence(renderer.root as unknown as FakeElement, 20, 20);
|
|
|
|
assert.equal(clearRequests.length, 1);
|
|
assert.equal(nodeElement(renderer, "a")?.getAttribute("aria-pressed"), "false");
|
|
assert.ok(nodeElement(renderer, "a"));
|
|
assert.ok(nodeElement(renderer, "b"));
|
|
assert.equal(nodeElement(renderer, "c"), undefined);
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("applies community relation focus immediately on node hover and clears it on leave", async () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: relationFocusGraphData(),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" }
|
|
});
|
|
|
|
nodeElement(renderer, "a")?.dispatch("pointerenter");
|
|
|
|
assert.equal(renderer.root.dataset.relationFocus, "active");
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "a");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.relationFocusDepth, "focus");
|
|
assert.equal(nodeElement(renderer, "b")?.dataset.relationFocusDepth, "first");
|
|
assert.equal(nodeElement(renderer, "c")?.dataset.relationFocusDepth, "first");
|
|
assert.equal(nodeElement(renderer, "d")?.dataset.relationFocusDepth, "second");
|
|
assert.equal(nodeElement(renderer, "e")?.dataset.relationFocusDepth, "unrelated");
|
|
assert.equal(edgeElement(renderer, "a-b")?.dataset.relationFocusDepth, "first");
|
|
assert.equal(edgeElement(renderer, "b-d")?.dataset.relationFocusDepth, "second");
|
|
assert.equal(edgeElement(renderer, "d-e")?.dataset.relationFocusDepth, "unrelated");
|
|
|
|
nodeElement(renderer, "a")?.dispatch("pointerleave");
|
|
await delay(100);
|
|
|
|
assert.equal(renderer.root.dataset.relationFocus, "idle");
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.relationFocusDepth, "none");
|
|
assert.equal(edgeElement(renderer, "a-b")?.dataset.relationFocusDepth, "none");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("keeps clicked community node as fixed relation focus and lets hover temporarily override it", async () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: relationFocusGraphData(),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" }
|
|
});
|
|
|
|
nodeElement(renderer, "a")?.dispatch("click", { detail: 0 });
|
|
|
|
assert.equal(renderer.root.dataset.relationFocus, "active");
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "a");
|
|
assert.equal(nodeElement(renderer, "a")?.getAttribute("aria-pressed"), "true");
|
|
assert.equal(nodeElement(renderer, "b")?.dataset.relationFocusDepth, "first");
|
|
|
|
nodeElement(renderer, "d")?.dispatch("pointerenter");
|
|
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "d");
|
|
assert.equal(nodeElement(renderer, "d")?.dataset.relationFocusDepth, "focus");
|
|
assert.equal(nodeElement(renderer, "b")?.dataset.relationFocusDepth, "first");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.relationFocusDepth, "second");
|
|
|
|
nodeElement(renderer, "d")?.dispatch("pointerleave");
|
|
await delay(100);
|
|
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "a");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.relationFocusDepth, "focus");
|
|
assert.equal(nodeElement(renderer, "d")?.dataset.relationFocusDepth, "second");
|
|
|
|
dispatchPointerSequence(renderer.root as unknown as FakeElement, 20, 20);
|
|
|
|
assert.equal(renderer.root.dataset.relationFocus, "idle");
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "");
|
|
assert.equal(nodeElement(renderer, "a")?.getAttribute("aria-pressed"), "false");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("does not open node hover preview cards inside focused community view", async () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: relationFocusGraphData(),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" }
|
|
});
|
|
|
|
nodeElement(renderer, "a")?.dispatch("pointerenter");
|
|
await delay(360);
|
|
|
|
const preview = findByClass(renderer.root as unknown as FakeElement, "graph-hover-preview")[0];
|
|
assert.notEqual(preview?.dataset.state, "open");
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "a");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("keeps relation focus continuous when hovering between community nodes", async () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: relationFocusGraphData(),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" }
|
|
});
|
|
|
|
nodeElement(renderer, "a")?.dispatch("pointerenter");
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "a");
|
|
|
|
nodeElement(renderer, "a")?.dispatch("pointerleave");
|
|
nodeElement(renderer, "b")?.dispatch("pointerenter");
|
|
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "b");
|
|
assert.notEqual(renderer.root.dataset.relationFocus, "idle");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("does not apply relation focus outside focused community view", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: relationFocusGraphData(),
|
|
theme: "shan-shui",
|
|
live: false
|
|
});
|
|
|
|
nodeElement(renderer, "a")?.dispatch("pointerenter");
|
|
|
|
assert.equal(renderer.root.dataset.relationFocus, "idle");
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.relationFocusDepth, "none");
|
|
assert.equal(edgeElement(renderer, "a-b")?.dataset.relationFocusDepth, "none");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("keeps type-filter-hidden nodes and edges hidden during community relation focus", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: relationFocusGraphData(),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" }
|
|
});
|
|
|
|
renderer.setTypeFilters({ entity: false });
|
|
nodeElement(renderer, "a")?.dispatch("pointerenter");
|
|
|
|
assert.equal(nodeElement(renderer, "b")?.dataset.filterState, "hidden");
|
|
assert.equal(nodeElement(renderer, "d")?.dataset.filterState, "hidden");
|
|
assert.equal(edgeElement(renderer, "a-b")?.dataset.filterState, "hidden");
|
|
assert.equal(edgeElement(renderer, "b-d")?.dataset.filterState, "hidden");
|
|
assert.equal(edgeElement(renderer, "a-b")?.dataset.relationFocusDepth, "first");
|
|
assert.equal(edgeElement(renderer, "b-d")?.dataset.relationFocusDepth, "second");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("marks focused community DOM output as the scoped lightweight graph view", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: relationFocusGraphData(),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" }
|
|
});
|
|
|
|
assert.equal(renderer.root.dataset.communityMapState, "lightweight");
|
|
assert.equal(renderer.root.dataset.relationFocus, "idle");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.relationFocusDepth, "none");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("clears community relation focus when the view returns to global", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: relationFocusGraphData(),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" }
|
|
});
|
|
|
|
nodeElement(renderer, "a")?.dispatch("pointerenter");
|
|
assert.equal(renderer.root.dataset.relationFocus, "active");
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "a");
|
|
|
|
renderer.render({ focus: null });
|
|
|
|
assert.equal(renderer.root.dataset.relationFocus, "idle");
|
|
assert.equal(renderer.root.dataset.relationFocusNode, "");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.relationFocusDepth, "none");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("keeps node double click from silently unpinning or changing focus", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const pinsChanged: unknown[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataWithCommunities([
|
|
["a", "community-a"],
|
|
["b", "community-a"],
|
|
["c", "community-b"]
|
|
]),
|
|
pins: { "wiki/a.md": { x: 120, y: 140, coordinateSpace: "world" } },
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" },
|
|
onPinsChanged: (pins) => pinsChanged.push(pins)
|
|
});
|
|
|
|
nodeElement(renderer, "a")?.dispatch("dblclick");
|
|
|
|
assert.deepEqual(pinsChanged, []);
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.pinned, "true");
|
|
assert.ok(nodeElement(renderer, "a"));
|
|
assert.ok(nodeElement(renderer, "b"));
|
|
assert.equal(nodeElement(renderer, "c"), undefined);
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("fixes and unfixes node position only through the explicit renderer action", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const pinsChanged: unknown[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphData(["a"]),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
onPinsChanged: (pins) => pinsChanged.push(pins)
|
|
});
|
|
|
|
assert.equal(renderer.setNodeFixed("a", "fix"), true);
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.pinned, "true");
|
|
assert.deepEqual(Object.keys(pinsChanged.at(-1) as Record<string, unknown>), ["wiki/a.md"]);
|
|
|
|
assert.equal(renderer.setNodeFixed("a", "unfix"), true);
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.pinned, "false");
|
|
assert.deepEqual(pinsChanged.at(-1), {});
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("returns global while preserving selection, search, filters, and fixed positions", async () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const clearRequests: number[] = [];
|
|
const viewResets: number[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataForReturnGlobal(),
|
|
pins: { "wiki/a.md": { x: 120, y: 140, coordinateSpace: "world" } },
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" },
|
|
typeFilters: { entity: true, source: true },
|
|
onSelectionClearRequested: () => clearRequests.push(1),
|
|
onViewReset: () => viewResets.push(1)
|
|
});
|
|
|
|
renderer.setTypeFilters({ entity: true, source: false });
|
|
renderer.select({ kind: "node", id: "a" });
|
|
const searchInput = findByClass(renderer.root as unknown as FakeElement, "graph-search-input")[0];
|
|
searchInput.value = "Node a";
|
|
searchInput.dispatch("input");
|
|
|
|
assert.deepEqual(visibleNodeIds(renderer), ["a", "b"]);
|
|
assert.equal(nodeElement(renderer, "b")?.dataset.filterState, "hidden");
|
|
|
|
renderer.resetView();
|
|
await waitForInteractionSettle();
|
|
|
|
assert.deepEqual(viewResets, [1]);
|
|
assert.deepEqual(clearRequests, []);
|
|
assert.deepEqual(visibleNodeIds(renderer), ["a", "b", "c"]);
|
|
assert.equal(nodeElement(renderer, "a")?.getAttribute("aria-pressed"), "true");
|
|
assert.equal(nodeElement(renderer, "c")?.getAttribute("aria-pressed"), "false");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.pinned, "true");
|
|
assert.equal(nodeElement(renderer, "b")?.dataset.filterState, "hidden");
|
|
assert.equal(findByClass(renderer.root as unknown as FakeElement, "graph-search-input")[0]?.value, "Node a");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.searchState, "match");
|
|
assert.equal(nodeElement(renderer, "c")?.dataset.searchState, "faded");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("routes the community toolbar return through Sigma global without rendering DOM full graph", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const viewResets: number[] = [];
|
|
const sigmaInputs: GraphFacadeRouteRendererFactoryInput[] = [];
|
|
const manager = createGraphFacadeRouteManager(container as unknown as HTMLElement, {
|
|
state: {
|
|
data: graphDataForReturnGlobal(),
|
|
pins: { "wiki/a.md": { x: 120, y: 140, coordinateSpace: "world" } },
|
|
theme: "shan-shui",
|
|
focus: null,
|
|
typeFilters: { entity: true, source: true },
|
|
aggregationMarkers: [],
|
|
selection: null,
|
|
searchResultIds: [],
|
|
temporaryObject: null
|
|
},
|
|
callbacks: {
|
|
onViewReset: () => viewResets.push(1)
|
|
},
|
|
factories: {
|
|
createSigmaGlobal: (input) => {
|
|
sigmaInputs.push(input);
|
|
return createSigmaShellRenderer(input);
|
|
}
|
|
}
|
|
});
|
|
|
|
manager.focusCommunity("community-a");
|
|
manager.setTypeFilters({ entity: true, source: false });
|
|
manager.select({ kind: "node", id: "a" });
|
|
manager.setPins({ "wiki/a.md": { x: 120, y: 140, coordinateSpace: "world" } });
|
|
const searchInput = findByClass(container, "graph-search-input")[0];
|
|
searchInput.value = "Node a";
|
|
searchInput.dispatch("input");
|
|
|
|
assert.equal(manager.routeId, "dom-svg-community");
|
|
assert.deepEqual(visibleNodeIds({ root: container as unknown as HTMLElement }), ["a", "b"]);
|
|
assert.equal(nodeElement({ root: container as unknown as HTMLElement }, "b")?.dataset.filterState, "hidden");
|
|
|
|
findByText(container, "回全图")?.dispatch("click");
|
|
|
|
assert.equal(manager.routeId, "sigma-global");
|
|
assert.equal(findByClass(container, "sigma-global-route").length, 1);
|
|
assert.deepEqual(visibleNodeIds({ root: container as unknown as HTMLElement }), []);
|
|
assert.equal(sigmaInputs.at(-1)?.options.focus, null);
|
|
const activeSigmaShell = findByClass(container, "sigma-global-route")[0];
|
|
assert.equal(activeSigmaShell?.dataset.selectedKind, "node");
|
|
assert.equal(activeSigmaShell?.dataset.selectedId, "a");
|
|
assert.equal(activeSigmaShell?.dataset.searchResultIds, "a");
|
|
assert.equal(activeSigmaShell?.dataset.typeFilters, "entity:true,source:false");
|
|
assert.equal(activeSigmaShell?.dataset.pinnedPaths, "wiki/a.md");
|
|
assert.deepEqual(sigmaInputs.at(-1)?.options.selection, { kind: "node", id: "a" });
|
|
assert.deepEqual(sigmaInputs.at(-1)?.options.searchResultIds, ["a"]);
|
|
assert.deepEqual(sigmaInputs.at(-1)?.options.typeFilters, { entity: true, source: false });
|
|
assert.deepEqual(Object.keys(sigmaInputs.at(-1)?.options.pins || {}), ["wiki/a.md"]);
|
|
assert.deepEqual(viewResets, [1]);
|
|
|
|
manager.destroy();
|
|
});
|
|
|
|
it("clears saved pins when resetting the Sigma global layout", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const pinsChanged: unknown[] = [];
|
|
const state = {
|
|
data: graphDataForReturnGlobal(),
|
|
pins: { "wiki/a.md": { x: 120, y: 140, coordinateSpace: "world" } },
|
|
theme: "shan-shui" as const,
|
|
focus: null,
|
|
typeFilters: { entity: true, source: true },
|
|
aggregationMarkers: [],
|
|
selection: null,
|
|
searchResultIds: [],
|
|
temporaryObject: null
|
|
};
|
|
const manager = createGraphFacadeRouteManager(container as unknown as HTMLElement, {
|
|
state,
|
|
callbacks: {
|
|
onPinsChanged: (pins) => pinsChanged.push(pins)
|
|
},
|
|
factories: {
|
|
createSigmaGlobal: (input) => createSigmaShellRenderer(input)
|
|
}
|
|
});
|
|
const sigmaShell = findByClass(container, "sigma-global-route")[0];
|
|
|
|
assert.equal(manager.routeId, "sigma-global");
|
|
assert.equal(sigmaShell?.dataset.pinnedPaths, "wiki/a.md");
|
|
|
|
manager.resetLayout();
|
|
|
|
assert.deepEqual(pinsChanged, [{}]);
|
|
assert.deepEqual(state.pins, {});
|
|
assert.equal(sigmaShell?.dataset.pinnedPaths, "");
|
|
|
|
manager.select({ kind: "community", id: "community-a" });
|
|
assert.deepEqual(state.pins, {});
|
|
assert.equal(sigmaShell?.dataset.selectedKind, "community");
|
|
assert.equal(sigmaShell?.dataset.selectedId, "community-a");
|
|
assert.equal(sigmaShell?.dataset.pinnedPaths, "");
|
|
|
|
manager.destroy();
|
|
});
|
|
|
|
it("clears saved pins when resetting the over-limit notice layout", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const pinsChanged: unknown[] = [];
|
|
const state = {
|
|
data: largeFallbackGraphData(),
|
|
pins: { "wiki/large/0.md": { x: 120, y: 140, coordinateSpace: "world" } },
|
|
theme: "shan-shui" as const,
|
|
focus: null,
|
|
typeFilters: {},
|
|
aggregationMarkers: [],
|
|
selection: null,
|
|
searchResultIds: [],
|
|
temporaryObject: null
|
|
};
|
|
const manager = createGraphFacadeRouteManager(container as unknown as HTMLElement, {
|
|
state,
|
|
callbacks: {
|
|
onPinsChanged: (pins) => pinsChanged.push(pins)
|
|
}
|
|
});
|
|
const overLimitView = findByClass(container, "graph-over-limit-notice-view")[0];
|
|
|
|
assert.equal(manager.routeId, "over-limit-notice");
|
|
assert.equal(overLimitView?.dataset.pinnedCount, "1");
|
|
|
|
manager.resetLayout();
|
|
|
|
assert.deepEqual(pinsChanged, [{}]);
|
|
assert.deepEqual(state.pins, {});
|
|
assert.equal(overLimitView?.dataset.pinnedCount, "0");
|
|
|
|
manager.destroy();
|
|
});
|
|
|
|
it("keeps DOM/SVG small fallback on the same lightweight global interaction rules", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const selections: SelectionInput[] = [];
|
|
const manager = createGraphFacadeRouteManager(container as unknown as HTMLElement, {
|
|
state: {
|
|
data: graphDataForReturnGlobal(),
|
|
pins: { "wiki/a.md": { x: 120, y: 140, coordinateSpace: "world" } },
|
|
theme: "shan-shui",
|
|
focus: null,
|
|
typeFilters: { entity: true, source: true },
|
|
aggregationMarkers: [],
|
|
selection: null,
|
|
searchQuery: "Node a",
|
|
searchResultIds: ["a"],
|
|
temporaryObject: null
|
|
},
|
|
callbacks: {
|
|
onSelectionInput: (selection) => selections.push(selection)
|
|
},
|
|
factories: {
|
|
createSigmaGlobal: () => {
|
|
throw new Error("WebGL unavailable");
|
|
}
|
|
}
|
|
});
|
|
|
|
assert.equal(manager.routeId, "dom-svg-small-fallback");
|
|
assert.equal(findByClass(container, "graph-aggregation-safety-container").length, 0);
|
|
assert.equal(findByClass(container, "graph-aggregation-safety-actions").length, 0);
|
|
assert.equal(findByClass(container, "community-button").length, 0);
|
|
assert.equal(findByClass(container, "community-wash").length > 0, true);
|
|
assert.deepEqual(visibleNodeIds({ root: container as unknown as HTMLElement }), ["a", "b", "c"]);
|
|
assert.equal(nodeElement({ root: container as unknown as HTMLElement }, "a")?.dataset.searchState, "match");
|
|
assert.equal(nodeElement({ root: container as unknown as HTMLElement }, "a")?.dataset.pinned, "true");
|
|
|
|
nodeElement({ root: container as unknown as HTMLElement }, "a")?.dispatch("click", { detail: 0 });
|
|
assert.deepEqual(selections.at(-1), { kind: "node", id: "a" });
|
|
assert.equal(manager.routeId, "dom-svg-small-fallback");
|
|
|
|
findByDataset(container, "communityId", "community-a")?.dispatch("click");
|
|
assert.deepEqual(selections.at(-1), { kind: "community", id: "community-a" });
|
|
assert.equal(manager.routeId, "dom-svg-small-fallback");
|
|
|
|
manager.focusCommunity("community-a");
|
|
assert.equal(manager.routeId, "dom-svg-community");
|
|
findByText(container, "回全图")?.dispatch("click");
|
|
assert.equal(manager.routeId, "dom-svg-small-fallback");
|
|
|
|
manager.setData(largeFallbackGraphData());
|
|
assert.equal(manager.routeId, "over-limit-notice");
|
|
assert.equal(findByClass(container, "graph-over-limit-notice").length, 1);
|
|
assert.deepEqual(visibleNodeIds({ root: container as unknown as HTMLElement }), []);
|
|
|
|
manager.destroy();
|
|
});
|
|
|
|
it("keeps reset layout separate from return global", async () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const viewResets: number[] = [];
|
|
const pinsChanged: unknown[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataForReturnGlobal(),
|
|
pins: { "wiki/a.md": { x: 120, y: 140, coordinateSpace: "world" } },
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" },
|
|
typeFilters: { entity: true, source: true },
|
|
onViewReset: () => viewResets.push(1),
|
|
onPinsChanged: (pins) => pinsChanged.push(pins)
|
|
});
|
|
|
|
renderer.select({ kind: "node", id: "a" });
|
|
const searchInput = findByClass(renderer.root as unknown as FakeElement, "graph-search-input")[0];
|
|
searchInput.value = "Node a";
|
|
searchInput.dispatch("input");
|
|
|
|
renderer.resetLayout();
|
|
await waitForViewportCommit();
|
|
|
|
assert.deepEqual(viewResets, []);
|
|
assert.deepEqual(pinsChanged.at(-1), {});
|
|
assert.deepEqual(visibleNodeIds(renderer), ["a", "b"]);
|
|
assert.equal(nodeElement(renderer, "a")?.getAttribute("aria-pressed"), "true");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.pinned, "false");
|
|
assert.equal(findByClass(renderer.root as unknown as FakeElement, "graph-search-input")[0]?.value, "Node a");
|
|
assert.equal(nodeElement(renderer, "b")?.dataset.filterState, "visible");
|
|
assert.equal(nodeElement(renderer, "c"), undefined);
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("returns global with a selected community still selected", async () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataForReturnGlobal(),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" }
|
|
});
|
|
|
|
renderer.select({ kind: "community", id: "community-a" });
|
|
renderer.resetView();
|
|
await waitForInteractionSettle();
|
|
|
|
assert.deepEqual(visibleNodeIds(renderer), ["a", "b", "c"]);
|
|
assert.equal(nodeElement(renderer, "a")?.getAttribute("aria-pressed"), "true");
|
|
assert.equal(nodeElement(renderer, "b")?.getAttribute("aria-pressed"), "true");
|
|
assert.equal(nodeElement(renderer, "c")?.getAttribute("aria-pressed"), "false");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("updates toolbar panel state without repainting the graph", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphData(["a"]),
|
|
theme: "shan-shui",
|
|
live: false
|
|
});
|
|
|
|
const toolbar = findByClass(renderer.root as unknown as FakeElement, "graph-toolbar")[0];
|
|
const filtersButton = findByText(toolbar, "筛选");
|
|
const legendButton = findByText(toolbar, "图例");
|
|
const panel = findByClass(toolbar, "graph-toolbar-panel")[0];
|
|
const node = nodeElement(renderer, "a");
|
|
|
|
filtersButton?.dispatch("click");
|
|
|
|
assert.equal(renderer.root.dataset.toolbarPanel, "filters");
|
|
assert.equal(toolbar.dataset.panel, "filters");
|
|
assert.equal(panel.dataset.state, "filters");
|
|
assert.equal(filtersButton?.dataset.active, "true");
|
|
assert.equal(legendButton?.dataset.active, "false");
|
|
assert.equal(findByClass(renderer.root as unknown as FakeElement, "graph-toolbar")[0], toolbar);
|
|
assert.equal(nodeElement(renderer, "a"), node);
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("updates search highlights without rebuilding graph elements or moving layout", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataForReturnGlobal(),
|
|
theme: "shan-shui",
|
|
live: false
|
|
});
|
|
|
|
const node = nodeElement(renderer, "a");
|
|
const otherNode = nodeElement(renderer, "b");
|
|
const edge = edgeElement(renderer, "a-b");
|
|
const contentLayer = findByClass(renderer.root as unknown as FakeElement, "graph-content-layer")[0];
|
|
assert.ok(node);
|
|
assert.ok(otherNode);
|
|
assert.ok(edge);
|
|
const nodeLeft = node.style.left;
|
|
const nodeTop = node.style.top;
|
|
const transform = contentLayer?.style.transform;
|
|
|
|
const searchInput = findByClass(renderer.root as unknown as FakeElement, "graph-search-input")[0];
|
|
searchInput.value = "Node a";
|
|
searchInput.dispatch("input");
|
|
|
|
assert.equal(nodeElement(renderer, "a"), node);
|
|
assert.equal(nodeElement(renderer, "b"), otherNode);
|
|
assert.equal(edgeElement(renderer, "a-b"), edge);
|
|
assert.equal(node.style.left, nodeLeft);
|
|
assert.equal(node.style.top, nodeTop);
|
|
assert.equal(contentLayer?.style.transform, transform);
|
|
assert.equal(node.dataset.searchState, "match");
|
|
assert.equal(otherNode.dataset.searchState, "faded");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("supports keyboard search result navigation and lightweight activation", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const selections: SelectionInput[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataForReturnGlobal(),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
onSelectionInput: (selection) => selections.push(selection)
|
|
});
|
|
|
|
const searchInput = findByClass(renderer.root as unknown as FakeElement, "graph-search-input")[0];
|
|
searchInput.value = "Node";
|
|
searchInput.dispatch("focus");
|
|
searchInput.dispatch("input");
|
|
|
|
searchInput.dispatch("keydown", { key: "ArrowDown" });
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.searchFocus, "true");
|
|
assert.equal(findByClass(renderer.root as unknown as FakeElement, "graph-search-status")[0]?.textContent, "1/3");
|
|
|
|
searchInput.dispatch("keydown", { key: "ArrowUp" });
|
|
assert.equal(nodeElement(renderer, "c")?.dataset.searchFocus, "true");
|
|
assert.equal(findByClass(renderer.root as unknown as FakeElement, "graph-search-status")[0]?.textContent, "3/3");
|
|
|
|
searchInput.dispatch("keydown", { key: "Enter" });
|
|
assert.deepEqual(selections.at(-1), { kind: "node", id: "c" });
|
|
assert.equal(nodeElement(renderer, "c")?.getAttribute("aria-pressed"), "true");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("applies Escape priority without clearing pins or resetting layout", async () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const clearRequests: number[] = [];
|
|
const viewResets: number[] = [];
|
|
const pinsChanged: unknown[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataForReturnGlobal(),
|
|
pins: { "wiki/a.md": { x: 120, y: 140, coordinateSpace: "world" } },
|
|
theme: "shan-shui",
|
|
live: false,
|
|
focus: { kind: "community", id: "community-a" },
|
|
onSelectionClearRequested: () => clearRequests.push(1),
|
|
onViewReset: () => viewResets.push(1),
|
|
onPinsChanged: (pins) => pinsChanged.push(pins)
|
|
});
|
|
|
|
renderer.root.focus();
|
|
assert.equal(renderer.root.tabIndex, 0);
|
|
assert.equal(ownerDocument.activeElement, renderer.root);
|
|
ownerDocument.dispatch("keydown", { key: "f", metaKey: true });
|
|
assert.equal(renderer.root.dataset.searchOpen, "true");
|
|
const searchInput = findByClass(renderer.root as unknown as FakeElement, "graph-search-input")[0];
|
|
assert.equal(ownerDocument.activeElement, searchInput);
|
|
|
|
ownerDocument.dispatch("keydown", { key: "Escape" });
|
|
assert.equal(renderer.root.dataset.searchOpen, "false");
|
|
assert.equal(ownerDocument.activeElement, renderer.root);
|
|
assert.deepEqual(viewResets, []);
|
|
assert.deepEqual(pinsChanged, []);
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.pinned, "true");
|
|
|
|
renderer.select({ kind: "node", id: "a" });
|
|
renderer.root.focus();
|
|
ownerDocument.dispatch("keydown", { key: "Escape" });
|
|
assert.deepEqual(clearRequests, [1]);
|
|
assert.deepEqual(viewResets, []);
|
|
assert.deepEqual(visibleNodeIds(renderer), ["a", "b"]);
|
|
assert.equal(nodeElement(renderer, "a")?.getAttribute("aria-pressed"), "false");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.pinned, "true");
|
|
|
|
ownerDocument.dispatch("keydown", { key: "Escape" });
|
|
await waitForViewportCommit();
|
|
|
|
assert.deepEqual(viewResets, [1]);
|
|
assert.deepEqual(pinsChanged, []);
|
|
assert.deepEqual(visibleNodeIds(renderer), ["a", "b", "c"]);
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.pinned, "true");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("updates type filters without rebuilding graph elements or moving layout", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataForReturnGlobal(),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
typeFilters: { entity: true, source: true }
|
|
});
|
|
|
|
const entityNode = nodeElement(renderer, "a");
|
|
const sourceNode = nodeElement(renderer, "b");
|
|
const edge = edgeElement(renderer, "a-b");
|
|
assert.ok(entityNode);
|
|
assert.ok(sourceNode);
|
|
assert.ok(edge);
|
|
const entityLeft = entityNode.style.left;
|
|
const entityTop = entityNode.style.top;
|
|
const sourceLeft = sourceNode.style.left;
|
|
const sourceTop = sourceNode.style.top;
|
|
const edgePath = edge.getAttribute("d");
|
|
|
|
renderer.setTypeFilters({ entity: true, source: false });
|
|
|
|
assert.equal(nodeElement(renderer, "a"), entityNode);
|
|
assert.equal(nodeElement(renderer, "b"), sourceNode);
|
|
assert.equal(edgeElement(renderer, "a-b"), edge);
|
|
assert.equal(entityNode.style.left, entityLeft);
|
|
assert.equal(entityNode.style.top, entityTop);
|
|
assert.equal(sourceNode.style.left, sourceLeft);
|
|
assert.equal(sourceNode.style.top, sourceTop);
|
|
assert.equal(edge.getAttribute("d"), edgePath);
|
|
assert.equal(entityNode.dataset.filterState, "visible");
|
|
assert.equal(sourceNode.dataset.filterState, "hidden");
|
|
assert.equal(edge.dataset.filterState, "hidden");
|
|
|
|
renderer.setTypeFilters({ entity: true, source: true });
|
|
|
|
assert.equal(nodeElement(renderer, "a"), entityNode);
|
|
assert.equal(nodeElement(renderer, "b"), sourceNode);
|
|
assert.equal(edgeElement(renderer, "a-b"), edge);
|
|
assert.equal(entityNode.dataset.filterState, "visible");
|
|
assert.equal(sourceNode.dataset.filterState, "visible");
|
|
assert.equal(edge.dataset.filterState, "visible");
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("temporarily reveals a filtered selected node with one-hop context without clearing filters", () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const visibilityStates: unknown[] = [];
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphDataForReturnGlobal(),
|
|
theme: "shan-shui",
|
|
live: false,
|
|
typeFilters: { entity: true, source: true },
|
|
onVisibilityStateChange: (state) => visibilityStates.push(state)
|
|
});
|
|
|
|
renderer.setTypeFilters({ entity: true, source: false });
|
|
|
|
assert.equal(nodeElement(renderer, "b")?.dataset.filterState, "hidden");
|
|
assert.equal(edgeElement(renderer, "a-b")?.dataset.filterState, "hidden");
|
|
|
|
renderer.showTemporaryObject({ kind: "node", nodeId: "b" });
|
|
|
|
assert.equal(nodeElement(renderer, "b")?.dataset.filterState, "visible");
|
|
assert.equal(nodeElement(renderer, "a")?.dataset.filterState, "visible");
|
|
assert.equal(edgeElement(renderer, "a-b")?.dataset.filterState, "visible");
|
|
assert.equal(renderer.root.dataset.typeFiltersActive, "true");
|
|
const temporaryState = visibilityStates.find((state) =>
|
|
JSON.stringify((state as { temporaryObject: unknown }).temporaryObject) === JSON.stringify({ kind: "node", nodeId: "b" })
|
|
) as { temporaryObject: unknown; typeFilters: Record<string, boolean> } | undefined;
|
|
assert.ok(temporaryState);
|
|
assert.equal(temporaryState.typeFilters.source, false);
|
|
|
|
renderer.clearTemporaryObjectDisplay();
|
|
|
|
assert.equal(nodeElement(renderer, "b")?.dataset.filterState, "hidden");
|
|
assert.equal(edgeElement(renderer, "a-b")?.dataset.filterState, "hidden");
|
|
assert.equal(renderer.root.dataset.typeFiltersActive, "true");
|
|
assert.equal((visibilityStates.at(-1) as { temporaryObject: unknown } | undefined)?.temporaryObject, null);
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("degrades interaction detail during lightweight viewport changes and restores after settle", async () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: connectedGraphData(["a", "b", "c"]),
|
|
theme: "shan-shui",
|
|
live: false
|
|
});
|
|
|
|
const nodeBefore = nodeElement(renderer, "a");
|
|
const edgeBefore = edgeElement(renderer, "a-b");
|
|
assert.equal(renderer.root.dataset.interactionMode, "idle");
|
|
|
|
const root = renderer.root as unknown as FakeElement;
|
|
root.dispatch("pointerdown", { pointerId: 1, clientX: 60, clientY: 80 });
|
|
root.dispatch("pointermove", { pointerId: 1, clientX: 120, clientY: 120 });
|
|
|
|
assert.equal(renderer.root.dataset.interactionMode, "active");
|
|
assert.equal(nodeElement(renderer, "a"), nodeBefore);
|
|
assert.equal(edgeElement(renderer, "a-b"), edgeBefore);
|
|
assert.ok(Number(renderer.root.dataset.interactionUpdatedObjects || "0") <= Number(renderer.root.dataset.interactionMaxUpdates || "0"));
|
|
assert.equal(nodeBefore?.dataset.coreAnchor, "true");
|
|
assert.equal(nodeBefore?.dataset.traceable, "true");
|
|
|
|
await waitForInteractionSettle();
|
|
assert.equal(renderer.root.dataset.interactionMode, "idle");
|
|
assert.equal(nodeElement(renderer, "a"), nodeBefore);
|
|
assert.equal(edgeElement(renderer, "a-b"), edgeBefore);
|
|
|
|
renderer.destroy();
|
|
});
|
|
|
|
it("does not let stale diff settlement mutate a refreshed graph", async () => {
|
|
const ownerDocument = new FakeDocument();
|
|
const container = ownerDocument.createElement("div");
|
|
const renderer = createGraphRenderer(container as unknown as HTMLElement, {
|
|
data: graphData(["a"]),
|
|
theme: "shan-shui",
|
|
live: false
|
|
});
|
|
|
|
const staleDiff = renderer.applyDiff(diff({ addedNodes: ["a"], nodeCount: 1 }), { durationMs: 420 });
|
|
assert.equal(renderer.root.dataset.diffState, "playing");
|
|
assert.equal(nodeElement(renderer, "a")?.classList.contains("is-diff-added"), true);
|
|
|
|
renderer.setData(graphData(["b"]));
|
|
assert.equal(renderer.root.dataset.diffState, undefined);
|
|
assert.equal(nodeElement(renderer, "a"), undefined);
|
|
|
|
const currentDiff = renderer.applyDiff(diff({ addedNodes: ["b"], nodeCount: 1 }), { durationMs: 420 });
|
|
assert.equal(renderer.root.dataset.diffState, "playing");
|
|
assert.equal(nodeElement(renderer, "b")?.classList.contains("is-diff-added"), true);
|
|
|
|
await staleDiff;
|
|
assert.equal(renderer.root.dataset.diffState, "playing");
|
|
assert.equal(nodeElement(renderer, "b")?.classList.contains("is-diff-added"), true);
|
|
|
|
await currentDiff;
|
|
assert.equal(renderer.root.dataset.diffState, "settled");
|
|
assert.equal(nodeElement(renderer, "b")?.classList.contains("is-diff-added"), false);
|
|
|
|
renderer.destroy();
|
|
});
|
|
});
|
|
|
|
function graphData(ids: string[]): GraphData {
|
|
return graphDataWithCommunities(ids.map((id) => [id, "community-a"]));
|
|
}
|
|
|
|
function connectedGraphData(ids: string[]): GraphData {
|
|
const data = graphData(ids);
|
|
data.meta.total_edges = Math.max(0, ids.length - 1);
|
|
data.edges = ids.slice(1).map((id, index) => ({
|
|
id: `${ids[index]}-${id}`,
|
|
from: ids[index],
|
|
to: id,
|
|
type: "EXTRACTED"
|
|
}));
|
|
return data;
|
|
}
|
|
|
|
function graphDataWithCommunities(entries: Array<[string, string]>): GraphData {
|
|
return {
|
|
meta: {
|
|
build_date: "2026-06-17",
|
|
wiki_title: "Lifecycle graph",
|
|
total_nodes: entries.length,
|
|
total_edges: 0
|
|
},
|
|
nodes: entries.map(([id, community]) => ({
|
|
id,
|
|
label: `Node ${id}`,
|
|
type: "topic",
|
|
community,
|
|
source_path: `wiki/${id}.md`,
|
|
content: `Node ${id}`
|
|
})),
|
|
edges: []
|
|
};
|
|
}
|
|
|
|
function poorCommunityQualityGraphData(): GraphData {
|
|
const nodes = Array.from({ length: 90 }, (_, index) => ({
|
|
id: `poor-${index}`,
|
|
label: `Poor node ${index}`,
|
|
type: "entity",
|
|
community: "community",
|
|
source_path: `wiki/poor/${index}.md`,
|
|
content: `Poor node ${index}`
|
|
}));
|
|
return {
|
|
meta: {
|
|
build_date: "2026-06-17",
|
|
wiki_title: "Poor community quality",
|
|
total_nodes: nodes.length,
|
|
total_edges: 0
|
|
},
|
|
nodes,
|
|
edges: [],
|
|
learning: {
|
|
version: 1,
|
|
entry: { recommended_start_node_id: "poor-0", recommended_start_reason: "fixture", default_mode: "global" },
|
|
views: {
|
|
path: { enabled: false, start_node_id: null, node_ids: [], degraded: true },
|
|
community: { enabled: false, community_id: null, label: null, node_ids: [], is_weak: true, degraded: true },
|
|
global: { enabled: true, node_ids: nodes.map((node) => node.id), degraded: false }
|
|
},
|
|
communities: [
|
|
{ id: "community", label: "community", node_count: nodes.length, is_weak: true }
|
|
]
|
|
}
|
|
};
|
|
}
|
|
|
|
function graphDataForReturnGlobal(): GraphData {
|
|
return {
|
|
meta: {
|
|
build_date: "2026-06-17",
|
|
wiki_title: "Return global graph",
|
|
total_nodes: 3,
|
|
total_edges: 1
|
|
},
|
|
nodes: [
|
|
{ id: "a", label: "Node a", type: "entity", community: "community-a", source_path: "wiki/a.md", content: "Node a detail" },
|
|
{ id: "b", label: "Node b", type: "source", community: "community-a", source_path: "wiki/b.md", content: "Node b detail" },
|
|
{ id: "c", label: "Node c", type: "entity", community: "community-b", source_path: "wiki/c.md", content: "Node c detail" }
|
|
],
|
|
edges: [
|
|
{ id: "a-b", from: "a", to: "b", type: "EXTRACTED" }
|
|
]
|
|
};
|
|
}
|
|
|
|
function relationFocusGraphData(): GraphData {
|
|
return {
|
|
meta: {
|
|
build_date: "2026-06-22",
|
|
wiki_title: "Relation focus graph",
|
|
total_nodes: 5,
|
|
total_edges: 4
|
|
},
|
|
nodes: [
|
|
{ id: "a", label: "Node a", type: "topic", community: "community-a", source_path: "wiki/a.md", content: "Node a" },
|
|
{ id: "b", label: "Node b", type: "entity", community: "community-a", source_path: "wiki/b.md", content: "Node b" },
|
|
{ id: "c", label: "Node c", type: "source", community: "community-a", source_path: "wiki/c.md", content: "Node c" },
|
|
{ id: "d", label: "Node d", type: "entity", community: "community-a", source_path: "wiki/d.md", content: "Node d" },
|
|
{ id: "e", label: "Node e", type: "entity", community: "community-a", source_path: "wiki/e.md", content: "Node e" }
|
|
],
|
|
edges: [
|
|
{ id: "a-b", from: "a", to: "b", type: "EXTRACTED", confidence: "EXTRACTED", relation_type: "实现", weight: 1 },
|
|
{ id: "a-c", from: "a", to: "c", type: "INFERRED", confidence: "INFERRED", relation_type: "对比", weight: 0.7 },
|
|
{ id: "b-d", from: "b", to: "d", type: "EXTRACTED", confidence: "EXTRACTED", relation_type: "依赖", weight: 0.6 },
|
|
{ id: "d-e", from: "d", to: "e", type: "EXTRACTED", confidence: "EXTRACTED", relation_type: "衍生", weight: 0.5 }
|
|
]
|
|
};
|
|
}
|
|
|
|
function largeFallbackGraphData(): GraphData {
|
|
const nodes = Array.from({ length: 2101 }, (_, index) => ({
|
|
id: `large-${index}`,
|
|
label: `Large ${index}`,
|
|
type: "topic",
|
|
community: index < 600 ? "large-community" : `community-${index}`,
|
|
source_path: `wiki/large/${index}.md`,
|
|
content: `Large ${index}`
|
|
}));
|
|
const edges = Array.from({ length: 4101 }, (_, index) => ({
|
|
id: `large-edge-${index}`,
|
|
from: nodes[index % nodes.length].id,
|
|
to: nodes[(index + 1) % nodes.length].id,
|
|
type: "EXTRACTED"
|
|
}));
|
|
return {
|
|
meta: {
|
|
build_date: "2026-06-19",
|
|
wiki_title: "Large fallback graph",
|
|
total_nodes: nodes.length,
|
|
total_edges: edges.length
|
|
},
|
|
nodes,
|
|
edges
|
|
};
|
|
}
|
|
|
|
function createSigmaShellRenderer(input: GraphFacadeRouteRendererFactoryInput): GraphFacadeRenderer {
|
|
const shell = input.container.ownerDocument.createElement("div");
|
|
shell.className = "sigma-global-route";
|
|
shell.dataset.route = "sigma-global";
|
|
input.container.append(shell);
|
|
let options = input.options;
|
|
renderSigmaShellState();
|
|
|
|
return {
|
|
applyDiff() {
|
|
return Promise.resolve();
|
|
},
|
|
isDragging() {
|
|
return false;
|
|
},
|
|
setData(data, pins) {
|
|
options = { ...options, data, pins: pins || options.pins };
|
|
renderSigmaShellState();
|
|
},
|
|
setAggregationMarkers(markers) {
|
|
options = { ...options, aggregationMarkers: markers };
|
|
renderSigmaShellState();
|
|
},
|
|
focusNode(path) {
|
|
const node = options.data.nodes.find((item) => item.id === path || item.source_path === path);
|
|
options = { ...options, selection: node ? { kind: "node", id: node.id } : options.selection };
|
|
renderSigmaShellState();
|
|
},
|
|
focusCommunity() {},
|
|
setTypeFilters(filters) {
|
|
options = { ...options, typeFilters: filters };
|
|
renderSigmaShellState();
|
|
},
|
|
showTemporaryObject(object) {
|
|
options = { ...options, temporaryObject: object };
|
|
renderSigmaShellState();
|
|
},
|
|
clearTemporaryObjectDisplay() {
|
|
options = { ...options, temporaryObject: null };
|
|
renderSigmaShellState();
|
|
},
|
|
resetView() {
|
|
options = { ...options, focus: null, selection: null };
|
|
renderSigmaShellState();
|
|
},
|
|
select(selection) {
|
|
options = { ...options, selection };
|
|
renderSigmaShellState();
|
|
},
|
|
previewNode() {},
|
|
clearSelection() {
|
|
options = { ...options, selection: null };
|
|
renderSigmaShellState();
|
|
},
|
|
clearInteraction() {
|
|
options = { ...options, focus: null, selection: null, temporaryObject: null };
|
|
renderSigmaShellState();
|
|
},
|
|
setNodeFixed() {
|
|
return false;
|
|
},
|
|
setTheme(theme) {
|
|
options = { ...options, theme };
|
|
renderSigmaShellState();
|
|
},
|
|
setPins(pins) {
|
|
options = { ...options, pins };
|
|
renderSigmaShellState();
|
|
},
|
|
resetLayout() {
|
|
const nextPins = {};
|
|
options = { ...options, pins: nextPins };
|
|
renderSigmaShellState();
|
|
input.options.callbacks.onPinsChanged?.(nextPins);
|
|
},
|
|
destroy() {
|
|
shell.remove();
|
|
}
|
|
};
|
|
|
|
function renderSigmaShellState(): void {
|
|
const selection = options.selection;
|
|
shell.dataset.focus = options.focus ? JSON.stringify(options.focus) : "";
|
|
shell.dataset.selectedKind = selection?.kind || "";
|
|
shell.dataset.selectedId = selection && "id" in selection ? selection.id : "";
|
|
shell.dataset.searchResultIds = options.searchResultIds.join(",");
|
|
shell.dataset.typeFilters = Object.entries(options.typeFilters)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([type, enabled]) => `${type}:${enabled}`)
|
|
.join(",");
|
|
shell.dataset.pinnedPaths = Object.keys(options.pins).sort().join(",");
|
|
}
|
|
}
|
|
|
|
function visibleNodeIds(renderer: { root: HTMLElement }): string[] {
|
|
return collectNodes(renderer.root as unknown as FakeElement).map((node) => node.dataset.id || "").sort();
|
|
}
|
|
|
|
function collectNodes(root: FakeElement): FakeElement[] {
|
|
const matches: FakeElement[] = [];
|
|
if (root.classList.contains("node")) matches.push(root);
|
|
for (const child of root.children) matches.push(...collectNodes(child));
|
|
return matches;
|
|
}
|
|
|
|
async function waitForViewportCommit(): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, 24));
|
|
}
|
|
|
|
async function waitForInteractionSettle(): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, 240));
|
|
}
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function diff(overrides: Partial<GraphDiff> & { nodeCount: number }): GraphDiff {
|
|
return {
|
|
addedNodes: overrides.addedNodes || [],
|
|
removedNodes: overrides.removedNodes || [],
|
|
recoloredNodes: overrides.recoloredNodes || [],
|
|
addedEdges: overrides.addedEdges || [],
|
|
removedEdges: overrides.removedEdges || [],
|
|
newCommunities: overrides.newCommunities || [],
|
|
stats: {
|
|
nodeCount: overrides.nodeCount,
|
|
edgeCount: 0,
|
|
communityCount: 1
|
|
}
|
|
};
|
|
}
|
|
|
|
function nodeElement(renderer: { root: HTMLElement }, id: string): FakeElement | undefined {
|
|
return findByDataset(renderer.root as unknown as FakeElement, "id", id);
|
|
}
|
|
|
|
function edgeElement(renderer: { root: HTMLElement }, id: string): FakeElement | undefined {
|
|
return findByDataset(renderer.root as unknown as FakeElement, "edgeId", id);
|
|
}
|
|
|
|
function findByDataset(root: FakeElement, key: string, value: string): FakeElement | undefined {
|
|
if (root.dataset[key] === value) return root;
|
|
for (const child of root.children) {
|
|
const match = findByDataset(child, key, value);
|
|
if (match) return match;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
class FakeDocument {
|
|
readonly head = new FakeElement("head", this);
|
|
activeElement: FakeElement | null = null;
|
|
private readonly listeners = new Map<string, Array<(event: FakeEvent) => void>>();
|
|
readonly defaultView = {
|
|
localStorage: null,
|
|
matchMedia: () => ({ matches: false }),
|
|
requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0) as unknown as number
|
|
};
|
|
|
|
createElement(tagName: string): FakeElement {
|
|
return new FakeElement(tagName, this);
|
|
}
|
|
|
|
createElementNS(_namespace: string, tagName: string): FakeElement {
|
|
return new FakeElement(tagName, this);
|
|
}
|
|
|
|
getElementById(id: string): FakeElement | null {
|
|
return findById(this.head, id) || null;
|
|
}
|
|
|
|
addEventListener(type: string, listener: unknown): void {
|
|
const listeners = this.listeners.get(type) || [];
|
|
listeners.push(listener as (event: FakeEvent) => void);
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
|
|
removeEventListener(type: string, listener: unknown): void {
|
|
const listeners = this.listeners.get(type) || [];
|
|
this.listeners.set(type, listeners.filter((candidate) => candidate !== listener));
|
|
}
|
|
|
|
dispatch(type: string, init: Partial<FakeEvent> = {}): FakeEvent {
|
|
const event = new FakeEvent(type, { ...init, target: init.target || this.activeElement });
|
|
for (const listener of this.listeners.get(type) || []) listener(event);
|
|
return event;
|
|
}
|
|
}
|
|
|
|
class FakeElement {
|
|
readonly children: FakeElement[] = [];
|
|
private readonly listeners = new Map<string, Array<(event: FakeEvent) => void>>();
|
|
readonly dataset: Record<string, string | undefined> = {};
|
|
readonly style = new FakeStyle();
|
|
readonly classList = new FakeClassList(this);
|
|
ownerDocument: FakeDocument;
|
|
parentElement: FakeElement | null = null;
|
|
className = "";
|
|
textContent = "";
|
|
type = "";
|
|
title = "";
|
|
href = "";
|
|
innerHTML = "";
|
|
checked = false;
|
|
value = "";
|
|
tabIndex = -1;
|
|
scrollLeft = 0;
|
|
scrollTop = 0;
|
|
id = "";
|
|
private capturedPointerId: number | null = null;
|
|
|
|
constructor(readonly tagName: string, ownerDocument: FakeDocument) {
|
|
this.ownerDocument = ownerDocument;
|
|
}
|
|
|
|
append(...children: Array<FakeElement | string>): void {
|
|
for (const child of children) {
|
|
if (typeof child === "string") {
|
|
this.textContent += child;
|
|
} else {
|
|
this.appendChild(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
appendChild(child: FakeElement): FakeElement {
|
|
child.parentElement = this;
|
|
this.children.push(child);
|
|
return child;
|
|
}
|
|
|
|
prepend(child: FakeElement): void {
|
|
child.parentElement = this;
|
|
this.children.unshift(child);
|
|
}
|
|
|
|
replaceChildren(...children: FakeElement[]): void {
|
|
for (const child of this.children) child.parentElement = null;
|
|
this.children.splice(0);
|
|
for (const child of children) this.appendChild(child);
|
|
}
|
|
|
|
remove(): void {
|
|
if (!this.parentElement) return;
|
|
const siblings = this.parentElement.children;
|
|
const index = siblings.indexOf(this);
|
|
if (index >= 0) siblings.splice(index, 1);
|
|
this.parentElement = null;
|
|
}
|
|
|
|
contains(candidate: FakeElement): boolean {
|
|
if (candidate === this) return true;
|
|
return this.children.some((child) => child.contains(candidate));
|
|
}
|
|
|
|
setAttribute(name: string, value: string): void {
|
|
if (name === "class") this.className = value;
|
|
else if (name === "href") this.href = value;
|
|
else if (name === "id") this.id = value;
|
|
else if (name.startsWith("data-")) this.dataset[dataKey(name)] = value;
|
|
else (this as unknown as Record<string, string>)[name] = value;
|
|
}
|
|
|
|
getAttribute(name: string): string | null {
|
|
if (name === "class") return this.className;
|
|
if (name === "href") return this.href || null;
|
|
if (name === "id") return this.id || null;
|
|
if (name.startsWith("data-")) return this.dataset[dataKey(name)] || null;
|
|
const value = (this as unknown as Record<string, string>)[name];
|
|
return value || null;
|
|
}
|
|
|
|
addEventListener(type: string, listener: unknown): void {
|
|
const listeners = this.listeners.get(type) || [];
|
|
listeners.push(listener as (event: FakeEvent) => void);
|
|
this.listeners.set(type, listeners);
|
|
}
|
|
|
|
removeEventListener(type: string, listener: unknown): void {
|
|
const listeners = this.listeners.get(type) || [];
|
|
this.listeners.set(type, listeners.filter((candidate) => candidate !== listener));
|
|
}
|
|
|
|
dispatch(type: string, init: Partial<FakeEvent> = {}): void {
|
|
const event = new FakeEvent(type, { ...init, target: init.target || this });
|
|
for (const listener of this.listeners.get(type) || []) listener(event);
|
|
}
|
|
|
|
focus(_options?: unknown): void {
|
|
this.ownerDocument.activeElement = this;
|
|
}
|
|
|
|
select(): void {}
|
|
|
|
setPointerCapture(pointerId: number): void {
|
|
this.capturedPointerId = pointerId;
|
|
}
|
|
|
|
releasePointerCapture(pointerId: number): void {
|
|
if (this.capturedPointerId === pointerId) this.capturedPointerId = null;
|
|
}
|
|
|
|
hasPointerCapture(pointerId: number): boolean {
|
|
return this.capturedPointerId === pointerId;
|
|
}
|
|
|
|
getBoundingClientRect(): { left: number; top: number; width: number; height: number } {
|
|
return { left: 0, top: 0, width: 960, height: 640 };
|
|
}
|
|
|
|
querySelectorAll(selector: string): FakeElement[] {
|
|
if (selector !== ".graph-type-filter input[data-type]") return [];
|
|
return collectElements(this).filter((element) =>
|
|
element.tagName === "input" &&
|
|
element.dataset.type !== undefined &&
|
|
hasAncestorClass(element, "graph-type-filter")
|
|
);
|
|
}
|
|
|
|
querySelector(selector: string): FakeElement | null {
|
|
if (!selector.startsWith(".")) return null;
|
|
const className = selector.slice(1);
|
|
return findByClass(this, className)[0] || null;
|
|
}
|
|
}
|
|
|
|
class FakeEvent {
|
|
propagationStopped = false;
|
|
defaultPrevented = false;
|
|
detail = 1;
|
|
shiftKey = false;
|
|
button = 0;
|
|
pointerId = 1;
|
|
clientX = 0;
|
|
clientY = 0;
|
|
deltaY = 0;
|
|
deltaMode = 0;
|
|
ctrlKey = false;
|
|
metaKey = false;
|
|
target: FakeElement | null = null;
|
|
|
|
constructor(readonly type: string, init: Partial<FakeEvent> = {}) {
|
|
Object.assign(this, init);
|
|
}
|
|
|
|
stopPropagation(): void {
|
|
this.propagationStopped = true;
|
|
}
|
|
|
|
preventDefault(): void {
|
|
this.defaultPrevented = true;
|
|
}
|
|
}
|
|
|
|
class FakeStyle {
|
|
private readonly values = new Map<string, string>();
|
|
|
|
setProperty(name: string, value: string): void {
|
|
this.values.set(name, value);
|
|
}
|
|
|
|
removeProperty(name: string): string {
|
|
const value = this.values.get(name) || "";
|
|
this.values.delete(name);
|
|
return value;
|
|
}
|
|
|
|
set colorScheme(value: string) {
|
|
this.setProperty("color-scheme", value);
|
|
}
|
|
|
|
set left(value: string) {
|
|
this.setProperty("left", value);
|
|
}
|
|
|
|
set top(value: string) {
|
|
this.setProperty("top", value);
|
|
}
|
|
|
|
set translate(value: string) {
|
|
this.setProperty("translate", value);
|
|
}
|
|
|
|
set strokeWidth(value: string) {
|
|
this.setProperty("stroke-width", value);
|
|
}
|
|
|
|
set opacity(value: string) {
|
|
this.setProperty("opacity", value);
|
|
}
|
|
|
|
set cursor(value: string) {
|
|
this.setProperty("cursor", value);
|
|
}
|
|
|
|
set background(value: string) {
|
|
this.setProperty("background", value);
|
|
}
|
|
|
|
get left(): string {
|
|
return this.values.get("left") || "";
|
|
}
|
|
|
|
get top(): string {
|
|
return this.values.get("top") || "";
|
|
}
|
|
|
|
get transform(): string {
|
|
return this.values.get("transform") || "";
|
|
}
|
|
|
|
set transform(value: string) {
|
|
this.setProperty("transform", value);
|
|
}
|
|
|
|
set transformOrigin(value: string) {
|
|
this.setProperty("transform-origin", value);
|
|
}
|
|
}
|
|
|
|
class FakeClassList {
|
|
constructor(private readonly element: FakeElement) {}
|
|
|
|
add(...classNames: string[]): void {
|
|
this.write([...this.read(), ...classNames]);
|
|
}
|
|
|
|
remove(...classNames: string[]): void {
|
|
const remove = new Set(classNames);
|
|
this.write(this.read().filter((className) => !remove.has(className)));
|
|
}
|
|
|
|
toggle(className: string, force?: boolean): void {
|
|
const classNames = new Set(this.read());
|
|
const shouldAdd = force ?? !classNames.has(className);
|
|
if (shouldAdd) classNames.add(className);
|
|
else classNames.delete(className);
|
|
this.write([...classNames]);
|
|
}
|
|
|
|
contains(className: string): boolean {
|
|
return this.read().includes(className);
|
|
}
|
|
|
|
private read(): string[] {
|
|
return this.element.className.split(/\s+/).filter(Boolean);
|
|
}
|
|
|
|
private write(classNames: string[]): void {
|
|
this.element.className = [...new Set(classNames)].join(" ");
|
|
}
|
|
}
|
|
|
|
function findById(root: FakeElement, id: string): FakeElement | undefined {
|
|
if (root.id === id) return root;
|
|
for (const child of root.children) {
|
|
const match = findById(child, id);
|
|
if (match) return match;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function findByClass(root: FakeElement, className: string): FakeElement[] {
|
|
const matches: FakeElement[] = [];
|
|
const classes = new Set(root.className.split(/\s+/).filter(Boolean));
|
|
if (classes.has(className)) matches.push(root);
|
|
for (const child of root.children) matches.push(...findByClass(child, className));
|
|
return matches;
|
|
}
|
|
|
|
function findByText(root: FakeElement, text: string): FakeElement | undefined {
|
|
if (root.textContent === text) return root;
|
|
for (const child of root.children) {
|
|
const match = findByText(child, text);
|
|
if (match) return match;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function collectElements(root: FakeElement): FakeElement[] {
|
|
return [root, ...root.children.flatMap((child) => collectElements(child))];
|
|
}
|
|
|
|
function hasAncestorClass(element: FakeElement, className: string): boolean {
|
|
let current = element.parentElement;
|
|
while (current) {
|
|
if (current.classList.contains(className)) return true;
|
|
current = current.parentElement;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function dispatchPointerSequence(root: FakeElement, x: number, y: number): void {
|
|
root.dispatch("pointerdown", { pointerId: 1, clientX: x, clientY: y });
|
|
root.dispatch("pointerup", { pointerId: 1, clientX: x, clientY: y });
|
|
}
|
|
|
|
function dataKey(attribute: string): string {
|
|
return attribute.slice("data-".length).replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
}
|