import { describe, it } from "node:test"; import assert from "node:assert/strict"; import type { GraphRendererAdapterData } from "../src"; import type { SigmaGlobalCameraState, SigmaGlobalSigmaLike } from "../src/render/sigma-global-types"; import { maybeAnimateSigmaCommunitySpotlightCamera, readCameraState, restoreCameraState, sigmaCommunitySpotlightCenter, sigmaGlobalCameraState, sigmaGraphPointToCameraPoint } from "../src/render/sigma-global-camera"; describe("Sigma global camera helpers", () => { it("normalizes missing and non-finite camera state", () => { const sigma = sigmaLike({ x: Number.NaN, y: Infinity, angle: undefined, ratio: "bad" }); assert.deepEqual(readCameraState(sigma), { x: 0, y: 0, angle: 0, ratio: 1 }); assert.equal(readCameraState({}), null); }); it("restores camera state only when a state exists", () => { const sigma = sigmaLike({ x: 1, y: 2, angle: 0, ratio: 1 }); restoreCameraState(sigma, null); assert.deepEqual(sigma.getCamera?.().getState?.(), { x: 1, y: 2, angle: 0, ratio: 1 }); restoreCameraState(sigma, { x: 3, y: 4, angle: 0, ratio: 0.8 }); assert.deepEqual(sigma.getCamera?.().getState?.(), { x: 3, y: 4, angle: 0, ratio: 0.8 }); }); it("uses setState instead of animate for reduced motion or missing animate", () => { const reducedMotionRoot = rootWithReducedMotion(true); const animatedSigma = sigmaLike({ x: 0, y: 0, angle: 0, ratio: 1 }); assert.equal( maybeAnimateSigmaCommunitySpotlightCamera(animatedSigma, reducedMotionRoot, adapterDataFixture(), "community-a", null), "community-a" ); assert.equal(animatedSigma.animateCalls, 0); assert.equal(animatedSigma.setStateCalls, 1); const noAnimateSigma = sigmaLike({ x: 0, y: 0, angle: 0, ratio: 1 }, false); assert.equal( maybeAnimateSigmaCommunitySpotlightCamera(noAnimateSigma, rootWithReducedMotion(false), adapterDataFixture(), "community-a", null), "community-a" ); assert.equal(noAnimateSigma.setStateCalls, 1); }); it("falls back to raw graph points when Sigma projection is unavailable or invalid", () => { assert.deepEqual(sigmaGraphPointToCameraPoint({}, { x: 10, y: 20 }), { x: 10, y: 20 }); assert.deepEqual( sigmaGraphPointToCameraPoint({ graphToViewport: () => ({ x: Number.NaN, y: 1 }), viewportToFramedGraph: () => ({ x: 30, y: 40 }) }, { x: 10, y: 20 }), { x: 10, y: 20 } ); assert.deepEqual( sigmaGraphPointToCameraPoint({ graphToViewport: (point) => ({ x: point.x + 1, y: point.y + 1 }), viewportToFramedGraph: (point) => ({ x: point.x + 2, y: point.y + 2 }) }, { x: 10, y: 20 }), { x: 13, y: 23 } ); }); it("computes full graph camera state and community spotlight centers", () => { const adapterData = adapterDataFixture(); assert.deepEqual(sigmaGlobalCameraState({}, adapterData), { x: 50, y: 50, angle: 0, ratio: 1 }); assert.deepEqual(sigmaCommunitySpotlightCenter(adapterData, "community-a"), { x: 20, y: 30 }); assert.deepEqual(sigmaCommunitySpotlightCenter(adapterDataWithoutWash(), "community-a"), { x: 30, y: 40 }); }); it("does not decide selected community internally", () => { const sigma = sigmaLike({ x: 0, y: 0, angle: 0, ratio: 1 }); assert.equal( maybeAnimateSigmaCommunitySpotlightCamera(sigma, rootWithReducedMotion(false), adapterDataFixture(), null, null), null ); assert.equal(sigma.setStateCalls, 0); assert.equal(sigma.animateCalls, 0); }); }); function sigmaLike( state: Partial & Record, withAnimate = true ): SigmaGlobalSigmaLike & { setStateCalls: number; animateCalls: number } { let current = { ...state } as SigmaGlobalCameraState; const output = { setStateCalls: 0, animateCalls: 0, getCamera() { return { getState: () => current, setState: (next: Partial) => { output.setStateCalls += 1; current = { ...current, ...next }; }, animate: withAnimate ? (next: Partial) => { output.animateCalls += 1; current = { ...current, ...next }; } : undefined }; } }; return output; } function rootWithReducedMotion(reduce: boolean): HTMLElement { return { ownerDocument: { defaultView: { matchMedia: () => ({ matches: reduce }) } } } as HTMLElement; } function adapterDataFixture(): GraphRendererAdapterData { return { counts: { nodes: 2, edges: 0, communities: 1, hidden: 0, renderedNodes: 2, renderedEdges: 0, aggregationContainers: 0 }, selection: { input: { kind: "community", id: "community-a" }, selectionId: "community:community-a", selectedNodeIds: [], selectedCommunityIds: ["community-a"], containsCurrentObject: true }, nodes: [ nodeFixture("alpha", { x: 10, y: 20 }), nodeFixture("beta", { x: 50, y: 60 }) ], edges: [], communities: [ { id: "community-a", object: { kind: "community", communityId: "community-a" }, label: "Community A", nodeIds: ["alpha", "beta"], nodeCount: 2, selected: true, searchResultIds: [], pinHints: [], aggregationIds: [], drawerTarget: { summaryKind: "community-summary", object: { kind: "community", communityId: "community-a" } }, commands: [] } ], aggregations: [], renderable: { nodes: [], edges: [], communities: [ { id: "community-a", role: "community", label: "Community A", nodeCount: 2, selected: true, searchHitCount: 0, pinnedCount: 0, selectedCount: 0, color: "#64748b", x: 20, y: 30, radius: 20, wash: { cx: 20, cy: 30, rx: 20, ry: 20 }, drawerTarget: { summaryKind: "community-summary", object: { kind: "community", communityId: "community-a" } }, commands: [] } ], aggregationContainers: [], minimap: { path: "", nodes: [] }, relationLegend: [], selectedNodeId: null, selectedCommunityId: "community-a", selectedNodeIds: [], hiddenNodeIds: new Set(), searchResultIds: [], worldBounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 }, budgets: { limits: { maxNodes: 2, maxEdges: 0, maxLabels: 0, maxCards: 0, maxInteractionUpdates: 1, maxVisibleCommunities: 1 }, usage: { nodes: 2, edges: 0, labels: 0, cards: 0, interactionUpdate: 1, activeInteraction: 1, communities: 1, aggregationContainers: 0 } }, qualityNotice: null, communityFocus: null, communityQuality: { boundaryCertainty: "high", skeletonLabel: "stable", hiddenNodeCount: 0, hiddenEdgeCount: 0, stableCoreNodeIds: ["alpha"], stableSkeletonEdgeIds: [], temporaryBoostNodeIds: [] } } }; } function adapterDataWithoutWash(): GraphRendererAdapterData { const data = adapterDataFixture(); data.renderable.communities = data.renderable.communities.map((community) => ({ ...community, wash: null })); return data; } function nodeFixture(id: string, point: { x: number; y: number }): GraphRendererAdapterData["nodes"][number] { return { id, object: { kind: "node", nodeId: id }, label: id, type: "topic", communityId: "community-a", sourcePath: `${id}.md`, point, selected: false, searchHit: false, pinHint: { nodeId: id, wikiPath: `${id}.md`, pinned: false, position: null }, aggregationIds: [], drawerTarget: { summaryKind: "node-summary", object: { kind: "node", nodeId: id } }, render: { displayMode: "point", visualRole: "landmark", priority: 1, labelVisible: false } }; }