282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
resolveVisibleSnapshot,
|
|
buildAtlasModel,
|
|
deriveAtlasLayout,
|
|
resolveAtlasVisibleSnapshot,
|
|
resolveAtlasSelectedNodeId,
|
|
getAtlasDensityMode,
|
|
atlasNodePoint,
|
|
getAtlasModelBounds,
|
|
fitAtlasViewport,
|
|
centerAtlasViewportOnPoint,
|
|
zoomAtlasViewport,
|
|
atlasViewportRect,
|
|
atlasPointToMinimap,
|
|
minimapPointToAtlasPoint,
|
|
atlasViewportToMinimapRect
|
|
} from "../src/model";
|
|
|
|
describe("resolveVisibleSnapshot", () => {
|
|
const nodes = [
|
|
{ id: "n1", label: "机器学习基础", content: "监督学习与数据预处理", degree: 2 },
|
|
{ id: "n2", label: "深度学习", content: "神经网络与 Transformer", degree: 3 },
|
|
{ id: "n3", label: "Transformer", content: "语言模型核心架构", degree: 2 },
|
|
{ id: "n4", label: "数据清洗", content: "数据预处理的一部分", degree: 1 }
|
|
];
|
|
|
|
const links = [
|
|
{ id: "e1", source: "n1", target: "n2", type: "EXTRACTED", weight: 0.95 },
|
|
{ id: "e2", source: "n2", target: "n3", type: "EXTRACTED", weight: 0.91 },
|
|
{ id: "e3", source: "n1", target: "n4", type: "INFERRED", weight: 0.55 }
|
|
];
|
|
|
|
it("combines edge filtering, focus mode, and search query", () => {
|
|
const snapshot = resolveVisibleSnapshot({
|
|
nodes,
|
|
links,
|
|
baseNodeIds: ["n1", "n2", "n3", "n4"],
|
|
filters: { EXTRACTED: true, INFERRED: false, AMBIGUOUS: false },
|
|
focusMode: "high_confidence",
|
|
searchQuery: "transformer",
|
|
anchorNodeId: "n2",
|
|
highConfidenceThreshold: 0.9
|
|
});
|
|
|
|
assert.deepEqual(snapshot.node_ids, ["n2", "n3"]);
|
|
assert.deepEqual(snapshot.nodes.map((node) => node.id), ["n2", "n3"]);
|
|
assert.deepEqual(snapshot.links.map((link) => link.id), ["e2"]);
|
|
assert.deepEqual(snapshot.searchIndex.map((entry) => entry.node.id), ["n1", "n2", "n3"]);
|
|
});
|
|
|
|
it("keeps one-hop scope around the selected anchor", () => {
|
|
const snapshot = resolveVisibleSnapshot({
|
|
nodes,
|
|
links,
|
|
baseNodeIds: ["n1", "n2", "n3"],
|
|
filters: { EXTRACTED: true, INFERRED: true, AMBIGUOUS: false },
|
|
focusMode: "one_hop",
|
|
searchQuery: "",
|
|
anchorNodeId: "n2"
|
|
});
|
|
|
|
assert.deepEqual(snapshot.node_ids, ["n1", "n2", "n3"]);
|
|
assert.deepEqual(snapshot.links.map((link) => link.id), ["e1", "e2"]);
|
|
});
|
|
|
|
it("returns empty visible nodes when search has no matches", () => {
|
|
const snapshot = resolveVisibleSnapshot({
|
|
nodes,
|
|
links,
|
|
baseNodeIds: ["n1", "n2", "n3"],
|
|
filters: { EXTRACTED: true, INFERRED: true, AMBIGUOUS: false },
|
|
focusMode: "all",
|
|
searchQuery: "不存在",
|
|
anchorNodeId: "n2"
|
|
});
|
|
|
|
assert.deepEqual(snapshot.node_ids, []);
|
|
assert.deepEqual(snapshot.nodes, []);
|
|
assert.deepEqual(snapshot.links, []);
|
|
assert.equal(snapshot.searchIndex.length, 3);
|
|
});
|
|
|
|
it("keeps an explicitly empty current range empty", () => {
|
|
const snapshot = resolveVisibleSnapshot({
|
|
nodes,
|
|
links,
|
|
baseNodeIds: [],
|
|
filters: { EXTRACTED: true, INFERRED: true, AMBIGUOUS: false },
|
|
focusMode: "all",
|
|
searchQuery: "机器"
|
|
});
|
|
|
|
assert.deepEqual(snapshot.node_ids, []);
|
|
assert.deepEqual(snapshot.nodes, []);
|
|
assert.deepEqual(snapshot.links, []);
|
|
assert.deepEqual(snapshot.searchIndex, []);
|
|
});
|
|
});
|
|
|
|
describe("atlas state contract", () => {
|
|
const rawGraph = {
|
|
meta: { wiki_title: "测试知识库", build_date: "2026-04-27" },
|
|
nodes: [
|
|
{ id: "a", label: "知识编译", type: "topic", community: "method", confidence: "EXTRACTED", content: "# 知识编译\n\n整理一次,持续维护。" },
|
|
{ id: "b", label: "素材消化", type: "topic", community: "method", confidence: "INFERRED", source_path: "wiki/topics/b.md" },
|
|
{ id: "c", label: "网页文章", type: "source", community: "source", confidence: "AMBIGUOUS" }
|
|
],
|
|
edges: [
|
|
{ id: "ab", from: "a", to: "b", type: "EXTRACTED", weight: 0.9 },
|
|
{ id: "ac", from: "a", to: "c", type: "INFERRED", weight: 0.6 }
|
|
],
|
|
learning: {
|
|
entry: { recommended_start_node_id: "a" },
|
|
communities: [
|
|
{ id: "method", label: "方法论", node_count: 2, is_primary: true, recommended_start_node_id: "a" },
|
|
{ id: "source", label: "素材来源", node_count: 1, recommended_start_node_id: "c" }
|
|
]
|
|
}
|
|
};
|
|
|
|
it("normalizes raw graph into one atlas model", () => {
|
|
const model = buildAtlasModel(rawGraph);
|
|
|
|
assert.equal(model.meta.wiki_title, "测试知识库");
|
|
assert.equal(model.nodes.length, 3);
|
|
assert.equal(model.edges.length, 2);
|
|
assert.equal(model.byId.a.degree, 2);
|
|
assert.equal(model.byId.a.summary, "整理一次,持续维护。");
|
|
assert.deepEqual(model.communities.map((community) => community.label), ["方法论", "素材来源"]);
|
|
assert.equal(model.starts[0].node.id, "a");
|
|
});
|
|
|
|
it("treats null atlas coordinates as missing layout input", () => {
|
|
const model = buildAtlasModel({
|
|
nodes: [
|
|
{ id: "nullish", label: "Nullish", x: null, y: null },
|
|
{ id: "origin", label: "Origin", x: 0, y: 0 }
|
|
],
|
|
edges: []
|
|
});
|
|
deriveAtlasLayout(model);
|
|
|
|
assert.notDeepEqual(
|
|
{ x: model.byId.nullish.x, y: model.byId.nullish.y },
|
|
{ x: 5, y: 8 }
|
|
);
|
|
assert.deepEqual(
|
|
{ x: model.byId.origin.x, y: model.byId.origin.y },
|
|
{ x: 5, y: 8 }
|
|
);
|
|
});
|
|
|
|
it("uses one visible snapshot for filters, search, density, and starts", () => {
|
|
const model = buildAtlasModel(rawGraph);
|
|
const layout = deriveAtlasLayout(model);
|
|
const snapshot = resolveAtlasVisibleSnapshot(model, layout, {
|
|
activeCommunityId: "method",
|
|
focusMode: "all",
|
|
query: "素材",
|
|
selectedNodeId: "a",
|
|
filters: { EXTRACTED: true, INFERRED: true }
|
|
});
|
|
|
|
assert.deepEqual(snapshot.node_ids, ["b"]);
|
|
assert.deepEqual(snapshot.nodes.map((node) => node.id), ["b"]);
|
|
assert.deepEqual(snapshot.edges, []);
|
|
assert.equal(snapshot.densityMode, "card");
|
|
assert.equal(snapshot.starts[0].node.id, "b");
|
|
assert.equal(snapshot.importantNodeIds.b, true);
|
|
assert.equal(snapshot.counts.total_nodes, 3);
|
|
});
|
|
|
|
it("keeps recommended starts and high-priority nodes readable as atlas index slips", () => {
|
|
const model = buildAtlasModel(rawGraph);
|
|
const layout = deriveAtlasLayout(model);
|
|
const snapshot = resolveAtlasVisibleSnapshot(model, layout, {
|
|
activeCommunityId: "all",
|
|
focusMode: "all",
|
|
query: "",
|
|
selectedNodeId: null,
|
|
filters: { EXTRACTED: true, INFERRED: true, AMBIGUOUS: true, UNVERIFIED: true }
|
|
});
|
|
|
|
assert.equal(snapshot.starts[0].node.id, "a");
|
|
assert.equal(snapshot.startNodeIds.a, true);
|
|
assert.equal(snapshot.importantNodeIds.a, true);
|
|
assert.equal(snapshot.labelNodeIds.a, true);
|
|
});
|
|
|
|
it("preserves only explicit selections inside the current visible atlas range", () => {
|
|
const model = buildAtlasModel(rawGraph);
|
|
const layout = deriveAtlasLayout(model);
|
|
const methodSnapshot = resolveAtlasVisibleSnapshot(model, layout, {
|
|
activeCommunityId: "source",
|
|
focusMode: "all",
|
|
query: "",
|
|
selectedNodeId: "a",
|
|
filters: { EXTRACTED: true, INFERRED: true, AMBIGUOUS: true, UNVERIFIED: true }
|
|
});
|
|
const emptySnapshot = resolveAtlasVisibleSnapshot(model, layout, {
|
|
activeCommunityId: "source",
|
|
focusMode: "all",
|
|
query: "没有结果",
|
|
selectedNodeId: "c",
|
|
filters: { EXTRACTED: true, INFERRED: true, AMBIGUOUS: true, UNVERIFIED: true }
|
|
});
|
|
|
|
assert.equal(resolveAtlasSelectedNodeId(model, methodSnapshot, "a"), null);
|
|
assert.equal(resolveAtlasSelectedNodeId(model, methodSnapshot, "c"), "c");
|
|
assert.equal(resolveAtlasSelectedNodeId(model, emptySnapshot, "c"), null);
|
|
});
|
|
|
|
it("does not auto-select a recommended start on first open", () => {
|
|
const model = buildAtlasModel(rawGraph);
|
|
const layout = deriveAtlasLayout(model);
|
|
const snapshot = resolveAtlasVisibleSnapshot(model, layout, {
|
|
activeCommunityId: "all",
|
|
focusMode: "all",
|
|
query: "",
|
|
selectedNodeId: null,
|
|
filters: { EXTRACTED: true, INFERRED: true, AMBIGUOUS: true, UNVERIFIED: true }
|
|
});
|
|
|
|
assert.equal(snapshot.starts[0].node.id, "a");
|
|
assert.equal(resolveAtlasSelectedNodeId(model, snapshot, null), null);
|
|
});
|
|
|
|
it("selects density mode by visible node budget", () => {
|
|
assert.equal(getAtlasDensityMode(50), "card");
|
|
assert.equal(getAtlasDensityMode(120), "compact-card");
|
|
assert.equal(getAtlasDensityMode(300), "point-plus-focus");
|
|
assert.equal(getAtlasDensityMode(800), "overview");
|
|
});
|
|
|
|
it("derives one model coordinate space for nodes and bounds", () => {
|
|
const model = buildAtlasModel(rawGraph);
|
|
deriveAtlasLayout(model);
|
|
const point = atlasNodePoint(model.byId.a);
|
|
const bounds = getAtlasModelBounds(model.nodes, 0);
|
|
|
|
assert.equal(point.x, model.byId.a.x * 10);
|
|
assert.equal(point.y, model.byId.a.y * 6.8);
|
|
assert.ok(bounds.width > 0);
|
|
assert.ok(bounds.height > 0);
|
|
assert.ok(bounds.minX <= point.x && point.x <= bounds.maxX);
|
|
assert.ok(bounds.minY <= point.y && point.y <= bounds.maxY);
|
|
});
|
|
|
|
it("fits, zooms, and reports the current viewport rectangle", () => {
|
|
const viewportSize = { width: 1000, height: 680 };
|
|
const bounds = { minX: 250, minY: 180, maxX: 750, maxY: 500, width: 500, height: 320 };
|
|
const fitted = fitAtlasViewport(bounds, viewportSize, { padding: 0.8 });
|
|
const zoomed = zoomAtlasViewport(fitted, 1.5, { x: 500, y: 340 }, viewportSize);
|
|
const rect = atlasViewportRect(zoomed, viewportSize);
|
|
|
|
assert.ok(fitted.scale > 1);
|
|
assert.ok(zoomed.scale > fitted.scale);
|
|
assert.ok(rect.width < 1000);
|
|
assert.ok(rect.height < 680);
|
|
assert.ok(rect.minX >= 0 && rect.maxX <= 1000);
|
|
assert.ok(rect.minY >= 0 && rect.maxY <= 680);
|
|
});
|
|
|
|
it("centers viewport on model points and maps minimap clicks back to atlas coordinates", () => {
|
|
const viewportSize = { width: 800, height: 500 };
|
|
const point = { x: 250, y: 170 };
|
|
const centered = centerAtlasViewportOnPoint(point, viewportSize, 1.4);
|
|
const rect = atlasViewportRect(centered, viewportSize);
|
|
const miniPoint = atlasPointToMinimap(point);
|
|
const restored = minimapPointToAtlasPoint(miniPoint);
|
|
const miniRect = atlasViewportToMinimapRect(centered, viewportSize);
|
|
|
|
assert.ok(rect.minX <= point.x && point.x <= rect.maxX);
|
|
assert.ok(rect.minY <= point.y && point.y <= rect.maxY);
|
|
assert.deepEqual(restored, point);
|
|
assert.ok(miniRect.width > 0);
|
|
assert.ok(miniRect.height > 0);
|
|
});
|
|
});
|