first
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
const { describe, it } = require("node:test");
|
||||
// DEPRECATED: Covers the legacy wash template; remove in the same retirement cycle as templates/graph-styles/wash/.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const GRAPH_WASH_PATH = path.resolve(__dirname, "../../templates/graph-styles/wash/graph-wash.js");
|
||||
const GRAPH_WASH_SOURCE = fs.readFileSync(GRAPH_WASH_PATH, "utf8");
|
||||
const GRAPH_WASH_BOOTSTRAP_SOURCE = GRAPH_WASH_SOURCE.match(
|
||||
/const helpers = window\.WikiGraphWashHelpers;[\s\S]*?const safeLocalStorage = createSafeStorage\(rawLocalStorage, console\.warn\);/
|
||||
)[0];
|
||||
|
||||
describe("graph-wash bootstrap", () => {
|
||||
it("exports helpers to window even when CommonJS exists", () => {
|
||||
const helpersSource = fs.readFileSync(path.resolve(__dirname, "../../templates/graph-styles/wash/graph-wash-helpers.js"), "utf8");
|
||||
const sandbox = {
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
require,
|
||||
console,
|
||||
Intl,
|
||||
window: {}
|
||||
};
|
||||
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(helpersSource, sandbox, { filename: "graph-wash-helpers.js" });
|
||||
|
||||
assert.equal(typeof sandbox.module.exports.truncateLabel, "function");
|
||||
assert.equal(typeof sandbox.window.WikiGraphWashHelpers.truncateLabel, "function");
|
||||
});
|
||||
|
||||
it("logs and exits when helpers are missing", () => {
|
||||
const errors = [];
|
||||
const sandbox = {
|
||||
window: {},
|
||||
console: {
|
||||
error: (...args) => errors.push(args.join(" "))
|
||||
}
|
||||
};
|
||||
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(GRAPH_WASH_SOURCE, sandbox, { filename: GRAPH_WASH_PATH });
|
||||
|
||||
assert.deepEqual(errors, ["[wiki] graph-wash-helpers.js is missing or failed to load"]);
|
||||
});
|
||||
|
||||
it("passes null to createSafeStorage when localStorage getter throws", () => {
|
||||
let capturedStorage;
|
||||
const sandbox = {
|
||||
window: {
|
||||
WikiGraphWashHelpers: {
|
||||
truncateLabel: () => ({ text: "", truncated: false }),
|
||||
cardDims: () => ({ w: 72, h: 36 }),
|
||||
createSafeStorage: (storage) => {
|
||||
capturedStorage = storage;
|
||||
return { get: () => null, set: () => {} };
|
||||
},
|
||||
getWikiStorageNamespace: () => "llm-wiki:test:abc",
|
||||
defaultQueue: () => ({ version: 1, favorites: [], notes: [], recentNoteIds: [] }),
|
||||
normalizeQueue: (queue) => queue,
|
||||
toggleQueueFavorite: (queue) => queue,
|
||||
appendQueueNote: (queue) => queue,
|
||||
summarizeQueue: () => ({ favorite_count: 0, note_count: 0, recent_items: [] }),
|
||||
buildAtlasModel: () => ({ meta: {}, nodes: [], edges: [] }),
|
||||
deriveAtlasLayout: () => ({ nodePositions: {}, bounds: { minX: 0, minY: 0, maxX: 0, maxY: 0 } }),
|
||||
resolveAtlasVisibleSnapshot: () => ({ nodes: [], edges: [], nodeIds: new Set(), labelNodeIds: {} }),
|
||||
resolveAtlasSelectedNodeId: () => null,
|
||||
atlasConfidenceLabel: (value) => value,
|
||||
atlasTypeLabel: (value) => value,
|
||||
atlasNodeKind: (value) => value,
|
||||
stripAtlasMarkdown: (value) => value,
|
||||
defaultLearning: () => ({ version: 1, entry: { recommended_start_node_id: null, recommended_start_reason: null, 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: [], degraded: false } }, communities: [], degraded: { path_to_community: true, community_to_global: true } }),
|
||||
normalizeLearning: () => ({ version: 1, entry: { recommended_start_node_id: null, recommended_start_reason: null, 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: [], degraded: false } }, communities: [], degraded: { path_to_community: true, community_to_global: true } }),
|
||||
resolveInitialMode: () => "global",
|
||||
getVisibleNodeIds: () => [],
|
||||
getVisibleLinks: () => [],
|
||||
shouldAutoOpenDrawer: () => false
|
||||
},
|
||||
get localStorage() {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
},
|
||||
document: {
|
||||
getElementById: (id) => {
|
||||
if (id === "graph-data") {
|
||||
return { textContent: '{"nodes":[],"edges":[],"insights":{}}' };
|
||||
}
|
||||
if (id === "atlas" || id === "node-layer" || id === "edge-layer") {
|
||||
return {};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
d3: {
|
||||
select: () => ({ node: () => null })
|
||||
},
|
||||
console: {
|
||||
warn: () => {}
|
||||
},
|
||||
JSON,
|
||||
Object
|
||||
};
|
||||
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(`(function () {\n${GRAPH_WASH_BOOTSTRAP_SOURCE}\n})();`, sandbox, { filename: GRAPH_WASH_PATH });
|
||||
|
||||
assert.equal(capturedStorage, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
const { describe, it } = require("node:test");
|
||||
// DEPRECATED: Covers the legacy wash helpers; remove in the same retirement cycle as templates/graph-styles/wash/.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const {
|
||||
splitLabelGraphemes,
|
||||
labelCharWidth,
|
||||
measureLabelWidth,
|
||||
truncateLabel,
|
||||
cardDims,
|
||||
createSafeStorage
|
||||
} = require("../../templates/graph-styles/wash/graph-wash-helpers");
|
||||
|
||||
const LABEL_CJK_WIDTH = 15;
|
||||
const LABEL_LATIN_WIDTH = 8.5;
|
||||
const LABEL_MIN_WIDTH = 72;
|
||||
const LABEL_MAX_WIDTH = 180;
|
||||
const LABEL_ELLIPSIS = "…";
|
||||
const HELPERS_PATH = path.resolve(__dirname, "../../templates/graph-styles/wash/graph-wash-helpers.js");
|
||||
|
||||
function loadHelpersWith(overrides = {}) {
|
||||
const source = fs.readFileSync(HELPERS_PATH, "utf8");
|
||||
const sandbox = {
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
require,
|
||||
console,
|
||||
Intl,
|
||||
window: {},
|
||||
...overrides
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(source, sandbox, { filename: HELPERS_PATH });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
// --- splitLabelGraphemes ---
|
||||
|
||||
describe("splitLabelGraphemes", () => {
|
||||
it("splits empty string", () => {
|
||||
assert.deepEqual(splitLabelGraphemes(""), []);
|
||||
});
|
||||
|
||||
it("splits ASCII", () => {
|
||||
assert.deepEqual(splitLabelGraphemes("abc"), ["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("splits CJK characters", () => {
|
||||
assert.deepEqual(splitLabelGraphemes("中文"), ["中", "文"]);
|
||||
});
|
||||
|
||||
it("does not corrupt emoji with surrogate pairs", () => {
|
||||
const result = splitLabelGraphemes("a👨👩👧👦b");
|
||||
assert.ok(result.includes("👨👩👧👦"), "family emoji kept as single grapheme");
|
||||
assert.equal(result[0], "a");
|
||||
assert.equal(result[result.length - 1], "b");
|
||||
});
|
||||
|
||||
it("does not corrupt surrogate pairs", () => {
|
||||
const result = splitLabelGraphemes("𠮷");
|
||||
// Whether it's 1 grapheme (with Intl.Segmenter) or split into code points,
|
||||
// the result must not contain unmatched surrogate halves
|
||||
assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(result.join("")));
|
||||
});
|
||||
|
||||
it("falls back when Intl.Segmenter is unavailable", () => {
|
||||
const sandbox = loadHelpersWith({ Intl: {} });
|
||||
const fallbackHelpers = sandbox.module.exports;
|
||||
|
||||
assert.deepEqual(Array.from(fallbackHelpers.splitLabelGraphemes("abc")), ["a", "b", "c"]);
|
||||
assert.deepEqual(Array.from(fallbackHelpers.splitLabelGraphemes("中文")), ["中", "文"]);
|
||||
assert.deepEqual(Array.from(fallbackHelpers.splitLabelGraphemes("👨👩👧👦")), ["👨👩👧👦"]);
|
||||
|
||||
const truncated = fallbackHelpers.truncateLabel("节点A👨👩👧👦AlphaBeta超长标签" + "超".repeat(20), 120);
|
||||
assert.equal(truncated.truncated, true);
|
||||
assert.ok(truncated.text.endsWith(LABEL_ELLIPSIS));
|
||||
assert.ok(
|
||||
!/\uD800(?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(truncated.text),
|
||||
"fallback path keeps surrogate pairs intact"
|
||||
);
|
||||
|
||||
const emojiBoundary = fallbackHelpers.truncateLabel("节点A👨👩👧👦AlphaBeta超长标签" + "超".repeat(20), 90);
|
||||
assert.ok(emojiBoundary.text.endsWith(LABEL_ELLIPSIS));
|
||||
assert.ok(
|
||||
!emojiBoundary.text.startsWith("") && !emojiBoundary.text.includes("" + LABEL_ELLIPSIS),
|
||||
"fallback path should not cut through a ZWJ sequence"
|
||||
);
|
||||
assert.ok(
|
||||
!emojiBoundary.text.includes("👨") || emojiBoundary.text.includes("👨👩👧👦"),
|
||||
"fallback path should keep the family emoji intact if it is included"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- labelCharWidth ---
|
||||
|
||||
describe("labelCharWidth", () => {
|
||||
it("returns CJK width for CJK character", () => {
|
||||
assert.equal(labelCharWidth("中"), LABEL_CJK_WIDTH);
|
||||
});
|
||||
|
||||
it("returns Latin width for Latin character", () => {
|
||||
assert.equal(labelCharWidth("a"), LABEL_LATIN_WIDTH);
|
||||
});
|
||||
|
||||
it("returns Latin width for digit", () => {
|
||||
assert.equal(labelCharWidth("5"), LABEL_LATIN_WIDTH);
|
||||
});
|
||||
|
||||
it("returns Latin width for punctuation", () => {
|
||||
assert.equal(labelCharWidth("-"), LABEL_LATIN_WIDTH);
|
||||
});
|
||||
});
|
||||
|
||||
// --- measureLabelWidth ---
|
||||
|
||||
describe("measureLabelWidth", () => {
|
||||
it("returns 0 for empty array", () => {
|
||||
assert.equal(measureLabelWidth([]), 0);
|
||||
});
|
||||
|
||||
it("returns correct width for single grapheme", () => {
|
||||
assert.equal(measureLabelWidth(["a"]), LABEL_LATIN_WIDTH);
|
||||
});
|
||||
|
||||
it("sums mixed CJK and Latin widths", () => {
|
||||
const width = measureLabelWidth(["中", "a", "文"]);
|
||||
assert.equal(width, LABEL_CJK_WIDTH * 2 + LABEL_LATIN_WIDTH);
|
||||
});
|
||||
});
|
||||
|
||||
// --- truncateLabel ---
|
||||
|
||||
describe("truncateLabel", () => {
|
||||
it("handles empty string", () => {
|
||||
const r = truncateLabel("", 100);
|
||||
assert.equal(r.text, "");
|
||||
assert.equal(r.truncated, false);
|
||||
});
|
||||
|
||||
it("handles null", () => {
|
||||
const r = truncateLabel(null, 100);
|
||||
assert.equal(r.text, "");
|
||||
assert.equal(r.truncated, false);
|
||||
});
|
||||
|
||||
it("handles undefined", () => {
|
||||
const r = truncateLabel(undefined, 100);
|
||||
assert.equal(r.text, "");
|
||||
assert.equal(r.truncated, false);
|
||||
});
|
||||
|
||||
it("does not truncate short label", () => {
|
||||
const r = truncateLabel("短标签", 120);
|
||||
assert.equal(r.truncated, false);
|
||||
assert.equal(r.text, "短标签");
|
||||
});
|
||||
|
||||
it("truncates long label with ellipsis", () => {
|
||||
const longLabel = "超".repeat(30);
|
||||
const r = truncateLabel(longLabel, 100);
|
||||
assert.equal(r.truncated, true);
|
||||
assert.ok(r.text.endsWith(LABEL_ELLIPSIS));
|
||||
});
|
||||
|
||||
it("does not corrupt emoji when truncating", () => {
|
||||
const r = truncateLabel("节点A👨👩👧👦AlphaBeta超长标签" + "超".repeat(20), 120);
|
||||
assert.equal(r.truncated, true);
|
||||
assert.ok(!r.text.includes("undefined"));
|
||||
assert.ok(
|
||||
!/\uD800(?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(r.text),
|
||||
"no unmatched surrogate halves"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles CJK + Latin mix", () => {
|
||||
const r = truncateLabel("中文English混合标签" + "超".repeat(20), 120);
|
||||
assert.equal(r.truncated, true);
|
||||
assert.ok(r.text.endsWith(LABEL_ELLIPSIS));
|
||||
});
|
||||
|
||||
it("respects maxWidth exactly at boundary", () => {
|
||||
const label = "a".repeat(10);
|
||||
const r = truncateLabel(label, 1000);
|
||||
assert.equal(r.truncated, false);
|
||||
assert.equal(r.text, label);
|
||||
});
|
||||
});
|
||||
|
||||
// --- cardDims ---
|
||||
|
||||
describe("cardDims", () => {
|
||||
it("returns dimensions for short label", () => {
|
||||
const r = cardDims({ id: "1", label: "短", type: "entity" });
|
||||
assert.ok(r.w >= LABEL_MIN_WIDTH);
|
||||
assert.ok(r.w <= LABEL_MAX_WIDTH);
|
||||
assert.equal(r.h, 36);
|
||||
});
|
||||
|
||||
it("caps width at LABEL_MAX_WIDTH for long label", () => {
|
||||
const r = cardDims({ id: "1", label: "超".repeat(30), type: "entity" });
|
||||
assert.equal(r.w, LABEL_MAX_WIDTH);
|
||||
});
|
||||
|
||||
it("enforces LABEL_MIN_WIDTH for empty label", () => {
|
||||
const r = cardDims({ id: "1", label: "", type: "entity" });
|
||||
assert.equal(r.w, LABEL_MIN_WIDTH);
|
||||
});
|
||||
|
||||
it("taller for topic type", () => {
|
||||
const r = cardDims({ id: "1", label: "T", type: "topic" });
|
||||
assert.equal(r.h, 40);
|
||||
});
|
||||
|
||||
it("shorter for source type", () => {
|
||||
const r = cardDims({ id: "1", label: "S", type: "source" });
|
||||
assert.equal(r.h, 32);
|
||||
});
|
||||
|
||||
it("returns dimensions for generic entity", () => {
|
||||
const r = cardDims({ id: "1", label: "X", type: "entity" });
|
||||
assert.ok(r.w > 0);
|
||||
assert.ok(r.h > 0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- browser export ---
|
||||
|
||||
describe("browser export", () => {
|
||||
it("exports helpers to window when CommonJS is unavailable", () => {
|
||||
const source = fs.readFileSync(HELPERS_PATH, "utf8");
|
||||
const sandbox = {
|
||||
console,
|
||||
Intl,
|
||||
window: {}
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(source, sandbox, { filename: HELPERS_PATH });
|
||||
|
||||
assert.equal(typeof sandbox.window.WikiGraphWashHelpers, "object");
|
||||
assert.equal(typeof sandbox.window.WikiGraphWashHelpers.truncateLabel, "function");
|
||||
assert.equal(typeof sandbox.window.WikiGraphWashHelpers.cardDims, "function");
|
||||
assert.equal(typeof sandbox.window.WikiGraphWashHelpers.createSafeStorage, "function");
|
||||
});
|
||||
});
|
||||
|
||||
// --- createSafeStorage ---
|
||||
|
||||
describe("createSafeStorage", () => {
|
||||
it("gets and sets normally", () => {
|
||||
const store = {};
|
||||
const storage = createSafeStorage({
|
||||
getItem: (k) => store[k],
|
||||
setItem: (k, v) => { store[k] = v; }
|
||||
});
|
||||
storage.set("k", "v");
|
||||
assert.equal(storage.get("k"), "v");
|
||||
});
|
||||
|
||||
it("returns null when get throws", () => {
|
||||
const logs = [];
|
||||
const storage = createSafeStorage({
|
||||
getItem: () => { throw new Error("boom"); },
|
||||
setItem: () => {}
|
||||
}, (...args) => logs.push(args));
|
||||
assert.equal(storage.get("k"), null);
|
||||
assert.equal(logs.length, 1);
|
||||
});
|
||||
|
||||
it("swallows set errors", () => {
|
||||
const logs = [];
|
||||
const storage = createSafeStorage({
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error("boom"); }
|
||||
}, (...args) => logs.push(args));
|
||||
storage.set("k", "v");
|
||||
assert.equal(logs.length, 1);
|
||||
});
|
||||
|
||||
it("handles null logger", () => {
|
||||
const storage = createSafeStorage({
|
||||
getItem: () => { throw new Error("boom"); },
|
||||
setItem: () => { throw new Error("boom"); }
|
||||
}, null);
|
||||
assert.equal(storage.get("k"), null);
|
||||
storage.set("k", "v");
|
||||
});
|
||||
|
||||
it("handles null storage", () => {
|
||||
const storage = createSafeStorage(null, null);
|
||||
assert.equal(storage.get("k"), null);
|
||||
storage.set("k", "v");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
const { describe, it } = require("node:test");
|
||||
// DEPRECATED: Covers the legacy wash helpers; remove in the same retirement cycle as templates/graph-styles/wash/.
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
defaultLearning,
|
||||
normalizeLearning,
|
||||
resolveInitialMode,
|
||||
getCommunityNodeIds,
|
||||
getVisibleNodeIds,
|
||||
getVisibleLinks,
|
||||
buildSearchIndex,
|
||||
applySearchToNodeIds,
|
||||
filterLinksByTypes,
|
||||
applyFocusMode,
|
||||
resolveVisibleSnapshot,
|
||||
shouldAutoOpenDrawer
|
||||
} = require("../../templates/graph-styles/wash/graph-wash-helpers");
|
||||
|
||||
describe("defaultLearning", () => {
|
||||
it("returns a stable empty learning structure", () => {
|
||||
const d = defaultLearning();
|
||||
assert.equal(d.version, 1);
|
||||
assert.equal(d.entry.default_mode, "global");
|
||||
assert.equal(d.entry.recommended_start_node_id, null);
|
||||
assert.equal(d.views.path.enabled, false);
|
||||
assert.equal(d.views.path.degraded, true);
|
||||
assert.equal(d.views.community.enabled, false);
|
||||
assert.equal(d.views.community.degraded, true);
|
||||
assert.equal(d.views.global.enabled, true);
|
||||
assert.equal(d.views.global.degraded, false);
|
||||
assert.deepEqual(d.communities, []);
|
||||
assert.equal(d.degraded.path_to_community, true);
|
||||
assert.equal(d.degraded.community_to_global, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeLearning", () => {
|
||||
it("returns default when input is null", () => {
|
||||
const n = normalizeLearning(null);
|
||||
assert.equal(n.version, 1);
|
||||
assert.equal(n.entry.default_mode, "global");
|
||||
});
|
||||
|
||||
it("returns default when input is undefined", () => {
|
||||
const n = normalizeLearning(undefined);
|
||||
assert.equal(n.version, 1);
|
||||
});
|
||||
|
||||
it("preserves valid learning data", () => {
|
||||
const raw = {
|
||||
version: 1,
|
||||
entry: { recommended_start_node_id: "A", recommended_start_reason: "community_hub", default_mode: "path" },
|
||||
views: {
|
||||
path: { enabled: true, start_node_id: "A", node_ids: ["A", "B"], degraded: false },
|
||||
community: { enabled: true, community_id: "c1", label: "Community 1", node_ids: ["A", "B", "C"], is_weak: false, degraded: false },
|
||||
global: { enabled: true, node_ids: ["A", "B", "C"], degraded: false }
|
||||
},
|
||||
communities: [{ id: "c1", label: "Community 1", node_count: 3, source_count: 1, is_primary: true }],
|
||||
degraded: { path_to_community: false, community_to_global: false }
|
||||
};
|
||||
const n = normalizeLearning(raw);
|
||||
assert.equal(n.entry.recommended_start_node_id, "A");
|
||||
assert.deepEqual(n.views.path.node_ids, ["A", "B"]);
|
||||
assert.equal(n.communities.length, 1);
|
||||
});
|
||||
|
||||
it("fills missing views with defaults", () => {
|
||||
const n = normalizeLearning({ version: 1 });
|
||||
assert.equal(n.views.path.enabled, false);
|
||||
assert.equal(n.views.community.enabled, false);
|
||||
assert.equal(n.views.global.enabled, true);
|
||||
});
|
||||
|
||||
it("handles missing node_ids arrays", () => {
|
||||
const n = normalizeLearning({ views: { path: { enabled: true }, community: {}, global: {} } });
|
||||
assert.deepEqual(n.views.path.node_ids, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInitialMode", () => {
|
||||
it("returns global when learning is null", () => {
|
||||
assert.equal(resolveInitialMode(null), "global");
|
||||
});
|
||||
|
||||
it("keeps the first paint global even when path is available", () => {
|
||||
const learning = { entry: { default_mode: "path" }, views: { path: { degraded: false }, community: { degraded: false } } };
|
||||
assert.equal(resolveInitialMode(learning), "global");
|
||||
});
|
||||
|
||||
it("keeps the first paint global when community is available", () => {
|
||||
const learning = { entry: { default_mode: "community" }, views: { path: { degraded: true }, community: { degraded: false } } };
|
||||
assert.equal(resolveInitialMode(learning), "global");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCommunityNodeIds", () => {
|
||||
it("returns sorted node ids for a matching community", () => {
|
||||
const nodes = [
|
||||
{ id: "B", community: "c2" },
|
||||
{ id: "A", community: "c1" },
|
||||
{ id: "C", community: "c1" },
|
||||
{ id: "D", community: null }
|
||||
];
|
||||
assert.deepEqual(getCommunityNodeIds(nodes, "c1"), ["A", "C"]);
|
||||
});
|
||||
|
||||
it("returns empty array for missing community", () => {
|
||||
const nodes = [{ id: "A", community: "c1" }];
|
||||
assert.deepEqual(getCommunityNodeIds(nodes, "c9"), []);
|
||||
});
|
||||
|
||||
it("returns empty array when community id is absent", () => {
|
||||
const nodes = [{ id: "A", community: "c1" }];
|
||||
assert.deepEqual(getCommunityNodeIds(nodes, null), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleNodeIds", () => {
|
||||
it("returns empty array when learning is null", () => {
|
||||
assert.deepEqual(getVisibleNodeIds(null, "path"), []);
|
||||
});
|
||||
|
||||
it("returns node_ids for valid mode", () => {
|
||||
const learning = { views: { path: { enabled: true, node_ids: ["A", "B"] } } };
|
||||
assert.deepEqual(getVisibleNodeIds(learning, "path"), ["A", "B"]);
|
||||
});
|
||||
|
||||
it("returns empty when view is disabled", () => {
|
||||
const learning = { views: { path: { enabled: false, node_ids: ["A"] } } };
|
||||
assert.deepEqual(getVisibleNodeIds(learning, "path"), []);
|
||||
});
|
||||
|
||||
it("returns empty for global mode when view node_ids is empty", () => {
|
||||
const learning = { views: { global: { enabled: true, node_ids: [] } } };
|
||||
assert.deepEqual(getVisibleNodeIds(learning, "global"), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVisibleLinks", () => {
|
||||
it("returns all links when visibleIds is empty", () => {
|
||||
const links = [{ source: "A", target: "B" }];
|
||||
assert.deepEqual(getVisibleLinks(links, []), links);
|
||||
});
|
||||
|
||||
it("filters links to only those with both endpoints visible", () => {
|
||||
const links = [
|
||||
{ source: { id: "A" }, target: { id: "B" } },
|
||||
{ source: { id: "A" }, target: { id: "C" } },
|
||||
{ source: { id: "B" }, target: { id: "C" } }
|
||||
];
|
||||
const result = getVisibleLinks(links, ["A", "B"]);
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].source.id, "A");
|
||||
assert.equal(result[0].target.id, "B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search helpers", () => {
|
||||
it("builds searchable haystacks and finds matches", () => {
|
||||
const nodes = [
|
||||
{ id: "A", label: "Transformer", content: "attention and language" },
|
||||
{ id: "B", label: "CNN", content: "vision only" }
|
||||
];
|
||||
const index = buildSearchIndex(nodes);
|
||||
assert.equal(index.length, 2);
|
||||
assert.deepEqual(applySearchToNodeIds(index, "attention"), ["A"]);
|
||||
assert.deepEqual(applySearchToNodeIds(index, ""), ["A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterLinksByTypes", () => {
|
||||
it("keeps only enabled edge types", () => {
|
||||
const links = [
|
||||
{ id: "e1", type: "EXTRACTED" },
|
||||
{ id: "e2", type: "INFERRED" },
|
||||
{ id: "e3", type: "AMBIGUOUS" }
|
||||
];
|
||||
const result = filterLinksByTypes(links, { EXTRACTED: true, INFERRED: false, AMBIGUOUS: false });
|
||||
assert.deepEqual(result.map((link) => link.id), ["e1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyFocusMode", () => {
|
||||
const nodes = [
|
||||
{ id: "A", degree: 3 },
|
||||
{ id: "B", degree: 2 },
|
||||
{ id: "C", degree: 2 },
|
||||
{ id: "D", degree: 1 }
|
||||
];
|
||||
const links = [
|
||||
{ source: "A", target: "B", weight: 0.95 },
|
||||
{ source: "A", target: "C", weight: 0.82 },
|
||||
{ source: "C", target: "D", weight: 0.45 }
|
||||
];
|
||||
const nodeIds = ["A", "B", "C", "D"];
|
||||
|
||||
it("returns scoped nodes unchanged for all mode", () => {
|
||||
const result = applyFocusMode({ mode: "all", nodes, links, nodeIds });
|
||||
assert.deepEqual(result.node_ids, nodeIds);
|
||||
assert.equal(result.links.length, 3);
|
||||
});
|
||||
|
||||
it("keeps anchor and one-hop neighbors for one_hop mode", () => {
|
||||
const result = applyFocusMode({ mode: "one_hop", nodes, links, nodeIds, anchorNodeId: "A" });
|
||||
assert.deepEqual(result.node_ids, ["A", "B", "C"]);
|
||||
assert.equal(result.links.length, 2);
|
||||
});
|
||||
|
||||
it("keeps only strong links for high_confidence mode", () => {
|
||||
const result = applyFocusMode({ mode: "high_confidence", nodes, links, nodeIds, anchorNodeId: "A", highConfidenceThreshold: 0.8 });
|
||||
assert.deepEqual(result.node_ids, ["A", "B", "C"]);
|
||||
assert.equal(result.links.length, 2);
|
||||
});
|
||||
|
||||
it("prefers high-score nodes for core mode", () => {
|
||||
const result = applyFocusMode({ mode: "core", nodes, links, nodeIds, coreLimit: 2 });
|
||||
assert.deepEqual(result.node_ids, ["A", "C"]);
|
||||
assert.equal(result.links.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveVisibleSnapshot", () => {
|
||||
it("applies search after focus mode", () => {
|
||||
const nodes = [
|
||||
{ id: "A", label: "Transformer", content: "attention", degree: 3 },
|
||||
{ id: "B", label: "Attention", content: "weights", degree: 2 },
|
||||
{ id: "C", label: "CNN", content: "vision", degree: 1 }
|
||||
];
|
||||
const links = [
|
||||
{ source: "A", target: "B", type: "EXTRACTED", weight: 0.95 },
|
||||
{ source: "A", target: "C", type: "INFERRED", weight: 0.4 }
|
||||
];
|
||||
const result = resolveVisibleSnapshot({
|
||||
nodes,
|
||||
links,
|
||||
baseNodeIds: ["A", "B", "C"],
|
||||
filters: { EXTRACTED: true, INFERRED: false, AMBIGUOUS: false },
|
||||
focusMode: "high_confidence",
|
||||
searchQuery: "attention",
|
||||
anchorNodeId: "A",
|
||||
highConfidenceThreshold: 0.8
|
||||
});
|
||||
assert.deepEqual(result.node_ids, ["A", "B"]);
|
||||
assert.equal(result.links.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAutoOpenDrawer", () => {
|
||||
it("returns true for path mode after explicit path entry", () => {
|
||||
assert.equal(shouldAutoOpenDrawer("path"), true);
|
||||
});
|
||||
|
||||
it("returns false for community mode", () => {
|
||||
assert.equal(shouldAutoOpenDrawer("community"), false);
|
||||
});
|
||||
|
||||
it("returns false for global mode", () => {
|
||||
assert.equal(shouldAutoOpenDrawer("global"), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
const { describe, it } = require("node:test");
|
||||
// DEPRECATED: Covers the legacy wash helpers; remove in the same retirement cycle as templates/graph-styles/wash/.
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
getWikiStorageNamespace,
|
||||
defaultQueue,
|
||||
normalizeQueue,
|
||||
toggleQueueFavorite,
|
||||
appendQueueNote,
|
||||
summarizeQueue
|
||||
} = require("../../templates/graph-styles/wash/graph-wash-helpers");
|
||||
|
||||
describe("getWikiStorageNamespace", () => {
|
||||
it("returns a stable namespace for the same wiki", () => {
|
||||
const a = getWikiStorageNamespace({ wiki_title: "AI知识图谱Demo" }, "/wiki/graph.html");
|
||||
const b = getWikiStorageNamespace({ wiki_title: "AI知识图谱Demo" }, "/wiki/graph.html");
|
||||
assert.equal(a, b);
|
||||
assert.match(a, /^llm-wiki:/);
|
||||
});
|
||||
|
||||
it("changes namespace when pathname changes", () => {
|
||||
const a = getWikiStorageNamespace({ wiki_title: "AI知识图谱Demo" }, "/wiki-a/graph.html");
|
||||
const b = getWikiStorageNamespace({ wiki_title: "AI知识图谱Demo" }, "/wiki-b/graph.html");
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultQueue and normalizeQueue", () => {
|
||||
it("returns an empty queue by default", () => {
|
||||
assert.deepEqual(defaultQueue(), {
|
||||
version: 1,
|
||||
favorites: [],
|
||||
notes: [],
|
||||
recentNoteIds: []
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes malformed queue data", () => {
|
||||
const queue = normalizeQueue({
|
||||
favorites: ["n1", "n1", 2, null],
|
||||
notes: [
|
||||
{ id: "a", node_id: "n1", label: "节点 A", text: "摘录", created_at: "2026-04-24T00:00:00Z" },
|
||||
{ bad: true }
|
||||
],
|
||||
recentNoteIds: ["a", "missing", "a"]
|
||||
});
|
||||
|
||||
assert.deepEqual(queue.favorites, ["n1", "2"]);
|
||||
assert.equal(queue.notes.length, 1);
|
||||
assert.deepEqual(queue.recentNoteIds, ["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleQueueFavorite", () => {
|
||||
it("adds and removes favorites", () => {
|
||||
let queue = defaultQueue();
|
||||
queue = toggleQueueFavorite(queue, "n1");
|
||||
assert.deepEqual(queue.favorites, ["n1"]);
|
||||
|
||||
queue = toggleQueueFavorite(queue, "n2");
|
||||
assert.deepEqual(queue.favorites, ["n2", "n1"]);
|
||||
|
||||
queue = toggleQueueFavorite(queue, "n1");
|
||||
assert.deepEqual(queue.favorites, ["n2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendQueueNote", () => {
|
||||
it("prepends note and updates recent ids", () => {
|
||||
let queue = defaultQueue();
|
||||
queue = appendQueueNote(queue, {
|
||||
id: "note-1",
|
||||
node_id: "n1",
|
||||
label: "节点一",
|
||||
text: "第一条",
|
||||
created_at: "2026-04-24T00:00:00Z"
|
||||
});
|
||||
queue = appendQueueNote(queue, {
|
||||
id: "note-2",
|
||||
node_id: "n2",
|
||||
label: "节点二",
|
||||
text: "第二条",
|
||||
created_at: "2026-04-24T00:01:00Z"
|
||||
});
|
||||
|
||||
assert.deepEqual(queue.notes.map((note) => note.id), ["note-2", "note-1"]);
|
||||
assert.deepEqual(queue.recentNoteIds, ["note-2", "note-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeQueue", () => {
|
||||
it("summarizes counts and recent items", () => {
|
||||
let queue = defaultQueue();
|
||||
queue = toggleQueueFavorite(queue, "n3");
|
||||
queue = appendQueueNote(queue, {
|
||||
id: "note-1",
|
||||
node_id: "n1",
|
||||
label: "节点一",
|
||||
text: "摘录一",
|
||||
created_at: "2026-04-24T00:00:00Z"
|
||||
});
|
||||
|
||||
const summary = summarizeQueue(queue, {
|
||||
n1: { id: "n1", label: "节点一" },
|
||||
n3: { id: "n3", label: "节点三" }
|
||||
}, 4);
|
||||
|
||||
assert.equal(summary.favorite_count, 1);
|
||||
assert.equal(summary.note_count, 1);
|
||||
assert.deepEqual(summary.recent_items, [
|
||||
{ kind: "note", node_id: "n1", label: "节点一", text: "摘录一" },
|
||||
{ kind: "favorite", node_id: "n3", label: "节点三", text: "" }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
const { describe, it } = require("node:test");
|
||||
// DEPRECATED: Covers the legacy wash helpers; remove in the same retirement cycle as templates/graph-styles/wash/.
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
resolveVisibleSnapshot,
|
||||
buildAtlasModel,
|
||||
deriveAtlasLayout,
|
||||
resolveAtlasVisibleSnapshot,
|
||||
resolveAtlasSelectedNodeId,
|
||||
getAtlasDensityMode,
|
||||
atlasNodePoint,
|
||||
getAtlasModelBounds,
|
||||
fitAtlasViewport,
|
||||
centerAtlasViewportOnPoint,
|
||||
zoomAtlasViewport,
|
||||
atlasViewportRect,
|
||||
atlasPointToMinimap,
|
||||
minimapPointToAtlasPoint,
|
||||
atlasViewportToMinimapRect
|
||||
} = require("../../templates/graph-styles/wash/graph-wash-helpers");
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { scanWiki } = require("../../scripts/source-signal-coverage");
|
||||
const path = require("path");
|
||||
|
||||
const FIXTURE_ROOT = path.join(__dirname, "..", "fixtures", "coverage-sample-wiki");
|
||||
|
||||
describe("scanWiki", () => {
|
||||
it("returns correct summary counts", () => {
|
||||
const { summary } = scanWiki(FIXTURE_ROOT);
|
||||
assert.equal(summary.ok, 2);
|
||||
assert.equal(summary.missing_sources, 1);
|
||||
assert.equal(summary.empty_sources, 1);
|
||||
assert.equal(summary.invalid_sources, 1);
|
||||
assert.equal(summary.not_applicable, 2);
|
||||
assert.equal(summary.applicable_total, 5);
|
||||
});
|
||||
|
||||
it("returns pages for all scanned files", () => {
|
||||
const { pages } = scanWiki(FIXTURE_ROOT);
|
||||
assert.equal(pages.length, 7);
|
||||
});
|
||||
|
||||
it("marks synthesis as not_applicable", () => {
|
||||
const { pages } = scanWiki(FIXTURE_ROOT);
|
||||
const crystal = pages.find((p) => p.id === "Crystal");
|
||||
assert.equal(crystal.reason, "not_applicable");
|
||||
});
|
||||
|
||||
it("marks query as not_applicable", () => {
|
||||
const { pages } = scanWiki(FIXTURE_ROOT);
|
||||
const query = pages.find((p) => p.pageType === "query");
|
||||
assert.equal(query.reason, "not_applicable");
|
||||
});
|
||||
|
||||
it("detects ok with correct sourceCount", () => {
|
||||
const { pages } = scanWiki(FIXTURE_ROOT);
|
||||
const alpha = pages.find((p) => p.id === "Alpha");
|
||||
assert.equal(alpha.reason, "ok");
|
||||
assert.equal(alpha.sourceCount, 2);
|
||||
});
|
||||
|
||||
it("detects invalid_sources", () => {
|
||||
const { pages } = scanWiki(FIXTURE_ROOT);
|
||||
const delta = pages.find((p) => p.id === "Delta");
|
||||
assert.equal(delta.reason, "invalid_sources");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
SCAN_KINDS,
|
||||
extractFrontmatter,
|
||||
evaluateSourceSignalEligibility,
|
||||
parseSourcesFrontmatter
|
||||
} = require("../../scripts/lib/source-signal-eligibility");
|
||||
|
||||
describe("extractFrontmatter", () => {
|
||||
it("returns hasFrontmatter:false when no frontmatter", () => {
|
||||
const result = extractFrontmatter("just body text");
|
||||
assert.equal(result.hasFrontmatter, false);
|
||||
assert.equal(result.frontmatter, "");
|
||||
assert.equal(result.body, "just body text");
|
||||
});
|
||||
|
||||
it("extracts frontmatter from valid document", () => {
|
||||
const result = extractFrontmatter("---\ntitle: Test\n---\nbody");
|
||||
assert.equal(result.hasFrontmatter, true);
|
||||
assert.equal(result.frontmatter, "title: Test");
|
||||
assert.equal(result.body, "body");
|
||||
});
|
||||
|
||||
it("returns hasFrontmatter:false for broken frontmatter", () => {
|
||||
const result = extractFrontmatter("---\ntitle: Test\nno closing");
|
||||
assert.equal(result.hasFrontmatter, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSourcesFrontmatter", () => {
|
||||
it("returns hasField:false for empty input", () => {
|
||||
const result = parseSourcesFrontmatter("");
|
||||
assert.equal(result.hasField, false);
|
||||
assert.equal(result.parsed, false);
|
||||
assert.deepEqual(result.sources, []);
|
||||
});
|
||||
|
||||
it("returns hasField:false when no sources key", () => {
|
||||
const result = parseSourcesFrontmatter("title: Test\nauthor: me");
|
||||
assert.equal(result.hasField, false);
|
||||
});
|
||||
|
||||
it("parses single string source", () => {
|
||||
const result = parseSourcesFrontmatter('sources: "paper.md"');
|
||||
assert.equal(result.hasField, true);
|
||||
assert.equal(result.parsed, true);
|
||||
assert.deepEqual(result.sources, ["paper.md"]);
|
||||
assert.equal(result.signalAvailable, true);
|
||||
});
|
||||
|
||||
it("parses inline array", () => {
|
||||
const result = parseSourcesFrontmatter('sources: ["a.md", "b.md"]');
|
||||
assert.equal(result.hasField, true);
|
||||
assert.deepEqual(result.sources, ["a.md", "b.md"]);
|
||||
});
|
||||
|
||||
it("parses multiline list", () => {
|
||||
const result = parseSourcesFrontmatter("sources:\n - paper.md\n - note.md");
|
||||
assert.equal(result.hasField, true);
|
||||
assert.deepEqual(result.sources, ["note.md", "paper.md"]);
|
||||
});
|
||||
|
||||
it("returns empty_sources for empty array", () => {
|
||||
const result = parseSourcesFrontmatter("sources: []");
|
||||
assert.equal(result.hasField, true);
|
||||
assert.equal(result.parsed, true);
|
||||
assert.deepEqual(result.sources, []);
|
||||
assert.equal(result.signalAvailable, false);
|
||||
});
|
||||
|
||||
it("returns empty_sources for whitespace-only token", () => {
|
||||
const result = parseSourcesFrontmatter('sources:\n - ""');
|
||||
assert.equal(result.hasField, true);
|
||||
assert.deepEqual(result.sources, []);
|
||||
});
|
||||
|
||||
it("returns parsed:false for broken inline array", () => {
|
||||
const result = parseSourcesFrontmatter("sources: [");
|
||||
assert.equal(result.hasField, true);
|
||||
assert.equal(result.parsed, false);
|
||||
assert.deepEqual(result.sources, []);
|
||||
});
|
||||
|
||||
it("returns parsed:false for invalid multiline content", () => {
|
||||
const result = parseSourcesFrontmatter("sources:\n foo: bar");
|
||||
assert.equal(result.hasField, true);
|
||||
assert.equal(result.parsed, false);
|
||||
});
|
||||
|
||||
it("handles numeric sources by stringifying", () => {
|
||||
const result = parseSourcesFrontmatter("sources: [1, 2]");
|
||||
assert.equal(result.hasField, true);
|
||||
assert.equal(result.parsed, true);
|
||||
assert.deepEqual(result.sources, ["1", "2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateSourceSignalEligibility", () => {
|
||||
it("returns not_applicable for synthesis", () => {
|
||||
const result = evaluateSourceSignalEligibility({
|
||||
pageType: "synthesis",
|
||||
frontmatter: "title: Test"
|
||||
});
|
||||
assert.equal(result.eligible, false);
|
||||
assert.equal(result.reason, "not_applicable");
|
||||
});
|
||||
|
||||
it("returns not_applicable for query", () => {
|
||||
const result = evaluateSourceSignalEligibility({
|
||||
pageType: "query",
|
||||
frontmatter: "sources: [a.md]"
|
||||
});
|
||||
assert.equal(result.eligible, false);
|
||||
assert.equal(result.reason, "not_applicable");
|
||||
});
|
||||
|
||||
it("returns not_applicable for unknown page type", () => {
|
||||
const result = evaluateSourceSignalEligibility({
|
||||
pageType: "unknown",
|
||||
frontmatter: "sources: [a.md]"
|
||||
});
|
||||
assert.equal(result.eligible, false);
|
||||
assert.equal(result.reason, "not_applicable");
|
||||
});
|
||||
|
||||
it("returns ok for entity with valid sources", () => {
|
||||
const result = evaluateSourceSignalEligibility({
|
||||
pageType: "entity",
|
||||
frontmatter: "sources:\n - paper.md\n - note.md"
|
||||
});
|
||||
assert.equal(result.eligible, true);
|
||||
assert.equal(result.reason, "ok");
|
||||
assert.deepEqual(result.sources, ["note.md", "paper.md"]);
|
||||
});
|
||||
|
||||
it("returns ok for topic with single source", () => {
|
||||
const result = evaluateSourceSignalEligibility({
|
||||
pageType: "topic",
|
||||
frontmatter: 'sources: "paper.md"'
|
||||
});
|
||||
assert.equal(result.eligible, true);
|
||||
assert.equal(result.reason, "ok");
|
||||
});
|
||||
|
||||
it("returns missing_sources when no frontmatter", () => {
|
||||
const result = evaluateSourceSignalEligibility({
|
||||
pageType: "entity",
|
||||
frontmatter: ""
|
||||
});
|
||||
assert.equal(result.eligible, false);
|
||||
assert.equal(result.reason, "missing_sources");
|
||||
});
|
||||
|
||||
it("returns missing_sources when no sources field", () => {
|
||||
const result = evaluateSourceSignalEligibility({
|
||||
pageType: "entity",
|
||||
frontmatter: "title: Test"
|
||||
});
|
||||
assert.equal(result.eligible, false);
|
||||
assert.equal(result.reason, "missing_sources");
|
||||
});
|
||||
|
||||
it("returns empty_sources for empty array", () => {
|
||||
const result = evaluateSourceSignalEligibility({
|
||||
pageType: "entity",
|
||||
frontmatter: "sources: []"
|
||||
});
|
||||
assert.equal(result.eligible, false);
|
||||
assert.equal(result.reason, "empty_sources");
|
||||
});
|
||||
|
||||
it("returns invalid_sources for broken syntax", () => {
|
||||
const result = evaluateSourceSignalEligibility({
|
||||
pageType: "entity",
|
||||
frontmatter: "sources: ["
|
||||
});
|
||||
assert.equal(result.eligible, false);
|
||||
assert.equal(result.reason, "invalid_sources");
|
||||
});
|
||||
|
||||
it("treats comparison as applicable", () => {
|
||||
const result = evaluateSourceSignalEligibility({
|
||||
pageType: "comparison",
|
||||
frontmatter: 'sources: "a.md"'
|
||||
});
|
||||
assert.equal(result.eligible, true);
|
||||
assert.equal(result.reason, "ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SCAN_KINDS", () => {
|
||||
it("includes all 6 page types", () => {
|
||||
const types = SCAN_KINDS.map((k) => k.pageType).sort();
|
||||
assert.deepEqual(types, ["comparison", "entity", "query", "source", "synthesis", "topic"]);
|
||||
});
|
||||
|
||||
it("marks entity/topic/source/comparison as applicable", () => {
|
||||
const applicable = SCAN_KINDS.filter((k) => k.applicable).map((k) => k.pageType).sort();
|
||||
assert.deepEqual(applicable, ["comparison", "entity", "source", "topic"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user