Files
Endoleap/tests/canvas-icons.test.mjs

101 lines
6.6 KiB
JavaScript

/**
* 本地图标目录/分片和共享 ResourceLoader 测试。直接读仓库生成资产,校验本地哈希和引用,不访问图标 CDN。
* loader 的活跃并发与缓存容量受限,不能据此推断待处理队列本身有长度上限。
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import catalog from "../src/components/canvas/icon-catalog-data.json" with { type: "json" };
import manifest from "../src/lib/canvas-icon-assets.json" with { type: "json" };
import { iconAsset, validateIconShard } from "../src/lib/canvas-icon-loader.ts";
import { decodeCanvas, emptyCanvas, makeCanvasNode } from "../src/lib/canvas.ts";
import { createResourceLoader } from "../src/lib/resource-loader.ts";
// 核对目录数量、ID 唯一性、分片文件哈希/体积/合法 SVG 数据、每个图标和分类存在;选取三个 ID 再验证旧快照往返。
test("all Lucide and experimental Tabler IDs map to valid local shards and survive snapshots", async () => {
assert.equal(catalog.filter((icon) => icon.source === "lucide").length, 2313);
assert.equal(catalog.filter((icon) => icon.source === "tabler").length, 24);
assert.equal(new Set(catalog.map((icon) => icon.name)).size, catalog.length);
// 按 manifest 逐分片读取本地 JSON;文件名哈希取 trimEnd 后文本的 SHA-256 前 12 位,单片小于 20000 字节。
const loaded = new Map();
for (const asset of Object.values(manifest.assets)) {
const content = await readFile(`public${manifest.root}/${asset}`, "utf8");
assert.ok(asset.includes(createHash("sha256").update(content.trimEnd()).digest("hex").slice(0, 12)));
loaded.set(`${manifest.root}/${asset}`, validateIconShard(JSON.parse(content)));
assert.ok(Buffer.byteLength(content) < 20_000, asset);
}
for (const icon of catalog) {
assert.ok(loaded.get(iconAsset(icon.name))[icon.name]);
assert.ok(icon.categories.every((category) => ["arrows", "people", "office", "media", "tech", "business", "nature", "travel", "shapes"].includes(category)));
}
for (const name of ["Star", "TablerAlien", "TablerCube3dSphere"]) {
assert.ok(catalog.some((icon) => icon.name === name));
const node = makeCanvasNode("icon", { x: 0, y: 0 }); node.data.icon = name;
assert.equal(decodeCanvas(JSON.stringify({ ...emptyCanvas(), nodes: [node] })).nodes[0].data.icon, name);
}
});
// 对 script、onload、外部 paint URL 和 use/href 的样例逐个拒绝;这是数据校验例,不是完整浏览器 SVG 安全审计。
test("icon data rejects executable shapes, events and external paint references", () => {
for (const node of [["script", {}], ["path", { onload: "alert(1)" }], ["path", { fill: "url(https://example.com)" }], ["use", { href: "bad" }]]) {
assert.throws(() => validateIconShard({ Star: [node] }));
}
});
// 让 setImmediate 推进到调度任务阶段;用于观察尚未开始、排队及已释放容量的加载。
const tick = () => new Promise((resolve) => setImmediate(resolve));
// request 后立即 abort,尚未进入调度时不能启动网络加载,也不能把未完成值留入缓存。
test("an immediately unmounted icon does not start a network request", async () => {
let calls = 0;
const loader = createResourceLoader(async () => { calls++; return "unused"; });
const controller = new AbortController();
const pending = loader.request("unused", controller.signal);
const rejected = assert.rejects(pending, { name: "AbortError" });
controller.abort(); await rejected; await tick();
assert.equal(calls, 0); assert.equal(loader.peek("unused"), undefined);
});
// 同 key a 两个订阅只加载一次,容量二时 c 等待;完成后缓存命中不新增调用,峰值为二。
test("loader bounds concurrency, deduplicates shared shards and reuses cache", async () => {
let active = 0, peak = 0, calls = 0;
// 手动完成 active 加载,用于在容量为二时观察第三项等待及共享 key 去重。
const finish = [];
const loader = createResourceLoader(async (key) => {
active++; peak = Math.max(peak, active); calls++;
await new Promise((resolve) => finish.push(resolve)); active--; return key;
}, 2, 3);
const a = loader.request("a"), duplicate = loader.request("a"), b = loader.request("b"), c = loader.request("c");
await tick(); assert.equal(calls, 2);
finish.splice(0).forEach((resolve) => resolve()); await tick();
assert.equal(calls, 3); finish.splice(0).forEach((resolve) => resolve());
assert.deepEqual(await Promise.all([a, duplicate, b, c]), ["a", "a", "b", "c"]);
assert.equal(await loader.request("a"), "a"); assert.equal(calls, 3); assert.equal(peak, 2);
});
// 取消排队 b 后它永不启动;取消 a 的一个订阅不能中止另一个订阅共享的 active a。
test("unused queued loads never start; a remaining subscriber keeps a shared load alive", async () => {
const seen = []; let release;
const loader = createResourceLoader(async (key) => { seen.push(key); if (key === "a") await new Promise((resolve) => release = resolve); return key; }, 1);
const first = new AbortController(), queued = new AbortController();
const a = loader.request("a", first.signal), shared = loader.request("a");
const b = loader.request("b", queued.signal);
const rejected = Promise.all([assert.rejects(a, { name: "AbortError" }), assert.rejects(b, { name: "AbortError" })]);
await tick(); first.abort(); queued.abort(); release();
await rejected; assert.equal(await shared, "a"); await tick(); assert.deepEqual(seen, ["a"]);
});
// active abort 后释放容量,失败 key 可重新加载;容量二的 LRU 在加入 a/b 后淘汰更早 fail,保留仍在缓存的 a。
test("active aborts and failed requests can retry; least recently used cache is bounded", async () => {
let fail = true;
const loader = createResourceLoader(async (key, signal) => {
if (key === "abort") return new Promise((_, reject) => signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }));
if (key === "fail" && fail) { fail = false; throw new Error("offline"); } return key;
}, 1, 2);
const controller = new AbortController(); const pending = loader.request("abort", controller.signal);
const rejected = assert.rejects(pending, { name: "AbortError" }); await tick(); controller.abort(); await rejected; await tick();
await assert.rejects(loader.request("fail"), /offline/); await tick(); assert.equal(await loader.request("fail"), "fail");
await loader.request("a"); await loader.request("b");
assert.equal(loader.peek("fail"), undefined); assert.equal(loader.peek("a"), "a");
});