208 lines
8.1 KiB
TypeScript
208 lines
8.1 KiB
TypeScript
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { buildRenderableGraph, screenPointToWorldPoint, worldPointToScreenPoint } from "../src/render";
|
|
import {
|
|
constrainDragTargetToLayoutBounds,
|
|
createLiveGraphSimulation,
|
|
pinsToPositions,
|
|
PinState
|
|
} from "../src/sim";
|
|
import type { GraphData, PinMap } from "../src/types";
|
|
|
|
describe("LiveGraphSimulation", () => {
|
|
it("starts from cold-start graph coordinates and pinned positions", () => {
|
|
const graph = buildRenderableGraph(sampleGraph(), {
|
|
theme: "shan-shui",
|
|
pins: {
|
|
"wiki/near.md": { x: 800, y: 340, coordinateSpace: "world" }
|
|
}
|
|
});
|
|
const simulation = createLiveGraphSimulation(graph);
|
|
const snapshot = simulation.snapshot();
|
|
|
|
assert.deepEqual(snapshot.positions.drag, { x: 200, y: 340 });
|
|
assert.deepEqual(snapshot.positions.near, { x: 800, y: 340 });
|
|
assert.deepEqual(snapshot.positions.far, { x: 850, y: 340 });
|
|
simulation.destroy();
|
|
});
|
|
|
|
it("settles to alpha zero instead of moving forever", () => {
|
|
const graph = buildRenderableGraph(sampleGraph(), { theme: "shan-shui" });
|
|
const simulation = createLiveGraphSimulation(graph, { coldStartAlpha: 0.09, alphaMin: 0.004 });
|
|
const settled = simulation.settle();
|
|
|
|
assert.equal(settled.alpha, 0);
|
|
assert.equal(simulation.alpha, 0);
|
|
simulation.destroy();
|
|
});
|
|
|
|
it("moves direct neighbors during low-heat drag while keeping far nodes fixed", () => {
|
|
const graph = buildRenderableGraph(sampleGraph(), { theme: "shan-shui" });
|
|
const simulation = createLiveGraphSimulation(graph, { lowHeatAlphaTarget: 0.15 });
|
|
simulation.settle();
|
|
const before = simulation.snapshot();
|
|
|
|
simulation.beginDrag("drag");
|
|
simulation.dragTo("drag", { x: 460, y: 340 });
|
|
const during = simulation.tick(36);
|
|
|
|
assert.ok(during.positions.near.x > before.positions.near.x + 4, "direct neighbor should slide toward the dragged node");
|
|
assert.deepEqual(during.positions.far, before.positions.far, "far node should remain fixed during the drag");
|
|
assert.equal(during.positions.drag.x, 460);
|
|
|
|
simulation.endDrag();
|
|
const settled = simulation.settle();
|
|
assert.equal(settled.alpha, 0);
|
|
simulation.destroy();
|
|
});
|
|
|
|
it("keeps drag bounds as an explicit layout constraint outside projection helpers", () => {
|
|
assert.deepEqual(
|
|
constrainDragTargetToLayoutBounds({ x: -120, y: 940 }, { fallback: { x: 200, y: 340 } }),
|
|
{ x: 0, y: 680 }
|
|
);
|
|
|
|
const graph = buildRenderableGraph(sampleGraph(), { theme: "shan-shui" });
|
|
const simulation = createLiveGraphSimulation(graph);
|
|
simulation.beginDrag("drag");
|
|
const dragged = simulation.dragTo("drag", { x: 1200, y: -250 });
|
|
|
|
assert.equal(dragged.fx, 1200);
|
|
assert.equal(dragged.fy, -250);
|
|
simulation.destroy();
|
|
|
|
const boundedSimulation = createLiveGraphSimulation(graph, {
|
|
dragBounds: { minX: 0, minY: 0, maxX: 1000, maxY: 680 }
|
|
});
|
|
boundedSimulation.beginDrag("drag");
|
|
const boundedDrag = boundedSimulation.dragTo("drag", { x: 1200, y: -250 });
|
|
|
|
assert.equal(boundedDrag.fx, 1000);
|
|
assert.equal(boundedDrag.fy, 0);
|
|
boundedSimulation.destroy();
|
|
});
|
|
});
|
|
|
|
describe("PinState", () => {
|
|
it("pins, unpins, and resets by wiki-relative node path", () => {
|
|
const graph = buildRenderableGraph(sampleGraph(), { theme: "shan-shui" });
|
|
const pins = new PinState(graph);
|
|
|
|
const pinned = pins.pin("drag", { x: 412.5, y: -88.2 });
|
|
assert.equal(pins.isPinned("drag"), true);
|
|
assert.deepEqual(pinned.pins, {
|
|
"wiki/drag.md": { x: 412.5, y: -88.2, coordinateSpace: "world" }
|
|
});
|
|
assert.deepEqual(pinned.pinnedNodeIds, ["drag"]);
|
|
|
|
const unpinned = pins.unpin("drag");
|
|
assert.equal(pins.isPinned("drag"), false);
|
|
assert.deepEqual(unpinned.pins, {});
|
|
|
|
pins.pin("near", { x: 130, y: 245.7 });
|
|
assert.deepEqual(pins.reset().pins, {});
|
|
});
|
|
|
|
it("normalizes initial pins and exposes render positions for known graph nodes only", () => {
|
|
const graph = buildRenderableGraph(sampleGraph(), { theme: "shan-shui" });
|
|
const pins = {
|
|
"wiki/near.md": { x: 130, y: 245.7, coordinateSpace: "world" as const },
|
|
"wiki/missing.md": { x: 999, y: 999 }
|
|
};
|
|
const state = new PinState(graph, pins).snapshot();
|
|
|
|
assert.deepEqual(state.pins, {
|
|
"wiki/near.md": { x: 130, y: 245.7, coordinateSpace: "world" }
|
|
});
|
|
assert.deepEqual(pinsToPositions(graph, state.pins), {
|
|
near: { x: 130, y: 245.7 }
|
|
});
|
|
});
|
|
|
|
it("keeps wiki-relative pin format readable and separate from projection math", () => {
|
|
const graph = buildRenderableGraph(sampleGraph(), { theme: "shan-shui" });
|
|
const pins: PinMap = {
|
|
"wiki/drag.md": { x: -42.5, y: 812.75, coordinateSpace: "world" },
|
|
"wiki/near.md": { x: Number.NaN, y: 245.7 }
|
|
};
|
|
const beforeProjection = structuredClone(pins);
|
|
|
|
const projected = worldPointToScreenPoint(pins["wiki/drag.md"], { x: -180, y: 64, scale: 1.25 }, { width: 1170, height: 856 });
|
|
const roundTrip = screenPointToWorldPoint(projected, { x: -180, y: 64, scale: 1.25 }, { width: 1170, height: 856 });
|
|
const state = new PinState(graph, pins).snapshot();
|
|
|
|
assert.deepEqual(pins, beforeProjection, "projection helpers must not rewrite the host pin map");
|
|
assert.ok(roundTrip.x < 0, "projection round trip should preserve an out-of-world finite pin x");
|
|
assert.ok(roundTrip.y > 680, "projection round trip should preserve an out-of-world finite pin y");
|
|
assert.deepEqual(state.pins, {
|
|
"wiki/drag.md": { x: -42.5, y: 812.75, coordinateSpace: "world" },
|
|
"wiki/near.md": { x: 0, y: 245.7 }
|
|
});
|
|
assert.deepEqual(pinsToPositions(graph, state.pins), {
|
|
drag: { x: -42.5, y: 812.75 },
|
|
near: { x: 0, y: 245.7 }
|
|
});
|
|
});
|
|
|
|
it("separates old percent pins from new world pins", () => {
|
|
const graph = buildRenderableGraph(sampleGraph(), { theme: "shan-shui" });
|
|
const pins = new PinState(graph, {
|
|
"wiki/drag.md": { x: 8, y: 12, coordinateSpace: "world" },
|
|
"wiki/near.md": { x: 13, y: 50, coordinateSpace: "legacy-percent" }
|
|
}).snapshot();
|
|
|
|
assert.deepEqual(pins.pins, {
|
|
"wiki/drag.md": { x: 8, y: 12, coordinateSpace: "world" },
|
|
"wiki/near.md": { x: 13, y: 50, coordinateSpace: "legacy-percent" }
|
|
});
|
|
assert.deepEqual(pinsToPositions(graph, pins.pins), {
|
|
drag: { x: 8, y: 12 },
|
|
near: { x: 130, y: 340 }
|
|
});
|
|
});
|
|
|
|
it("treats unmarked small pin values as world coordinates instead of guessing percent", () => {
|
|
const graph = buildRenderableGraph(sampleGraph(), { theme: "shan-shui" });
|
|
const pins = new PinState(graph, {
|
|
"wiki/drag.md": { x: 8, y: 12 }
|
|
}).snapshot();
|
|
|
|
assert.deepEqual(pinsToPositions(graph, pins.pins), {
|
|
drag: { x: 8, y: 12 }
|
|
});
|
|
});
|
|
});
|
|
|
|
function sampleGraph(): GraphData {
|
|
return {
|
|
meta: {
|
|
build_date: "2026-06-12T00:00:00.000Z",
|
|
wiki_title: "Simulation Fixture",
|
|
total_nodes: 3,
|
|
total_edges: 1
|
|
},
|
|
nodes: [
|
|
{ id: "drag", label: "Drag", type: "topic", community: "c1", source_path: "wiki/drag.md", weight: 80, x: 20, y: 50 },
|
|
{ id: "near", label: "Near", type: "entity", community: "c1", source_path: "wiki/near.md", weight: 50, x: 32, y: 50 },
|
|
{ id: "far", label: "Far", type: "entity", community: "c2", source_path: "wiki/far.md", weight: 20, x: 85, y: 50 }
|
|
],
|
|
edges: [
|
|
{ id: "drag-near", from: "drag", to: "near", type: "EXTRACTED", weight: 1 }
|
|
],
|
|
learning: {
|
|
version: 1,
|
|
entry: { recommended_start_node_id: "drag", recommended_start_reason: "community_hub", 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: false, degraded: true },
|
|
global: { enabled: true, node_ids: ["drag", "near", "far"], degraded: false }
|
|
},
|
|
communities: [
|
|
{ id: "c1", label: "Core", node_count: 2, color_index: 0, recommended_start_node_id: "drag" },
|
|
{ id: "c2", label: "Edge", node_count: 1, color_index: 1 }
|
|
]
|
|
}
|
|
};
|
|
}
|