This commit is contained in:
2026-07-12 21:26:08 +08:00
commit 9dd41afd48
502 changed files with 129901 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
splitLabelGraphemes,
labelCharWidth,
measureLabelWidth,
truncateLabel,
cardDims,
createSafeStorage
} from "../src/model";
const LABEL_CJK_WIDTH = 15;
const LABEL_LATIN_WIDTH = 8.5;
const LABEL_MIN_WIDTH = 72;
const LABEL_MAX_WIDTH = 180;
const LABEL_ELLIPSIS = "…";
async function loadHelpersWithoutSegmenter() {
const originalIntl = globalThis.Intl;
Object.defineProperty(globalThis, "Intl", { value: {}, configurable: true });
try {
return await import(`../src/model/legacy-helpers.ts?fallback=${Date.now()}`);
} finally {
Object.defineProperty(globalThis, "Intl", { value: originalIntl, configurable: true });
}
}
// --- 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", async () => {
const fallbackHelpers = await loadHelpersWithoutSegmenter();
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);
});
});
// --- module export ---
describe("module export", () => {
it("exports helpers from the model entry", async () => {
const model = await import("../src/model");
assert.equal(typeof model.truncateLabel, "function");
assert.equal(typeof model.cardDims, "function");
assert.equal(typeof model.createSafeStorage, "function");
});
it("keeps legacy renderer exports available from the package entry", async () => {
const entry = await import("../src");
const render = await import("../src/render");
assert.equal(typeof entry.createGraphRenderer, "function");
assert.equal(entry.createStaticGraphRenderer, entry.createGraphRenderer);
assert.equal(typeof entry.createStaticGraphRenderer, "function");
assert.equal(typeof render.createGraphRenderer, "function");
assert.equal(render.createStaticGraphRenderer, render.createGraphRenderer);
assert.equal(typeof render.createStaticGraphRenderer, "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");
});
});