// KBV5 前端純函式單元測試(vitest)。測的是 app.js 實際引用的 static/lib/pure.js 同一份。
// pure.js 是 UMD:ESM 載入時 module 未定義 → 落到 globalThis.KBV5Pure 分支。
import { describe, it, expect } from "vitest";
import "./pure.js";
const Pure = globalThis.KBV5Pure;
describe("commentAuthorName(B2 顯示名退化鏈)", () => {
it("優先 display_name", () => {
expect(Pure.commentAuthorName({ author_display_name: "王小明", author_username: "wang", author_id: 5 }))
.toBe("王小明");
});
it("無 display_name → username", () => {
expect(Pure.commentAuthorName({ author_display_name: null, author_username: "wang", author_id: 5 }))
.toBe("wang");
});
it("都沒有 → #id(不顯示空白)", () => {
expect(Pure.commentAuthorName({ author_id: 42 })).toBe("#42");
});
it("null 輸入安全", () => {
expect(Pure.commentAuthorName(null)).toBe("");
});
});
describe("mediaThumbKind(媒體卡片分類)", () => {
it("image/* → image", () => expect(Pure.mediaThumbKind("image/png")).toBe("image"));
it("video/* → video", () => expect(Pure.mediaThumbKind("video/mp4")).toBe("video"));
it("application/pdf → pdf", () => expect(Pure.mediaThumbKind("application/pdf")).toBe("pdf"));
it("其餘 → doc", () => expect(Pure.mediaThumbKind("application/zip")).toBe("doc"));
it("空/undefined → doc", () => {
expect(Pure.mediaThumbKind("")).toBe("doc");
expect(Pure.mediaThumbKind(undefined)).toBe("doc");
});
});
describe("libFilterHeaderText(B1 篩選後 header)", () => {
it("未篩選(filtered==loaded)→ 共 N 篇文件", () => {
expect(Pure.libFilterHeaderText({ baseTotal: 91, baseQuery: "", filteredLen: 91, loadedLen: 91 }))
.toBe("共 91 篇文件");
});
it("篩選後縮窄 → 篩選後顯示 X 篇", () => {
expect(Pure.libFilterHeaderText({ baseTotal: 91, baseQuery: "", filteredLen: 78, loadedLen: 91 }))
.toBe("篩選後顯示 78 篇");
});
it("帶搜尋字 + 未縮窄 → 搜尋「q」· 找到 N 篇", () => {
expect(Pure.libFilterHeaderText({ baseTotal: 12, baseQuery: "五軸", filteredLen: 12, loadedLen: 12 }))
.toBe("搜尋「五軸」· 找到 12 篇");
});
it("帶搜尋字 + 縮窄 → 搜尋「q」· 篩選後顯示 X 篇", () => {
expect(Pure.libFilterHeaderText({ baseTotal: 12, baseQuery: "五軸", filteredLen: 3, loadedLen: 12 }))
.toBe("搜尋「五軸」· 篩選後顯示 3 篇");
});
});
describe("selectAllLabel(#8 全選 N 篇)", () => {
it("動態筆數", () => expect(Pure.selectAllLabel(91)).toBe("全選 91 篇"));
it("0/undefined 安全", () => {
expect(Pure.selectAllLabel(0)).toBe("全選 0 篇");
expect(Pure.selectAllLabel(undefined)).toBe("全選 0 篇");
});
});
describe("tallyAuditFacets(#9 稽核 facet 真實統計)", () => {
const NOW = Date.parse("2026-06-06T12:00:00Z");
const mk = (over) => Object.assign(
{ category: "read", risk: "low", result: "success",
timestamp: "2026-06-06T10:00:00Z", display_name: "甲", username: "a" }, over);
it("分類 / 風險 / 結果 計數正確", () => {
const r = Pure.tallyAuditFacets([
mk({ category: "auth" }), mk({ category: "auth" }), mk({ category: "edit" }),
mk({ risk: "high" }), mk({ result: "fail" }),
], NOW);
expect(r.cat.auth).toBe(2);
expect(r.cat.edit).toBe(1);
expect(r.cat.read).toBe(2); // 預設 read(risk/result 那兩筆)
expect(r.risk.high).toBe(1);
expect(r.res.fail).toBe(1);
expect(r.res.success).toBe(4);
});
it("時間窗 d7/d30/d90 依 timestamp 分桶", () => {
const r = Pure.tallyAuditFacets([
mk({ timestamp: "2026-06-05T00:00:00Z" }), // 1 天前 → 全中
mk({ timestamp: "2026-05-20T00:00:00Z" }), // ~17 天前 → d30/d90
mk({ timestamp: "2026-04-01T00:00:00Z" }), // ~66 天前 → 只 d90
mk({ timestamp: "2026-01-01T00:00:00Z" }), // ~5 月前 → 都不中
], NOW);
expect(r.d7).toBe(1);
expect(r.d30).toBe(2);
expect(r.d90).toBe(3);
});
it("使用者 top 排序(多→少)取前 6", () => {
const items = [];
for (let i = 0; i < 3; i++) items.push(mk({ display_name: "多", username: "m" }));
items.push(mk({ display_name: "少", username: "f" }));
const r = Pure.tallyAuditFacets(items, NOW);
expect(r.topUsers[0]).toEqual(["多", 3]);
expect(r.topUsers[1]).toEqual(["少", 1]);
});
it("無 display_name → username → (未知)", () => {
const r = Pure.tallyAuditFacets([
mk({ display_name: null, username: "u1" }),
mk({ display_name: null, username: null }),
], NOW);
const names = r.topUsers.map((t) => t[0]);
expect(names).toContain("u1");
expect(names).toContain("(未知)");
});
it("達 500 筆 → capped=true", () => {
const items = Array.from({ length: 500 }, () => mk({}));
expect(Pure.tallyAuditFacets(items, NOW).capped).toBe(true);
expect(Pure.tallyAuditFacets([mk({})], NOW).capped).toBe(false);
});
it("空陣列安全", () => {
const r = Pure.tallyAuditFacets([], NOW);
expect(r.topUsers).toEqual([]);
expect(r.d7).toBe(0);
});
});
describe("visibilityLabel(提交頁可見度標籤)", () => {
it("基本層級無附加", () => {
expect(Pure.visibilityLabel({ visibility: "department" }, "部門內部"))
.toBe("可見度:部門內部");
});
it("selected_departments 顯示部門數", () => {
expect(Pure.visibilityLabel(
{ visibility: "selected_departments", allowed_departments: ["A", "B"] }, "指定部門"))
.toBe("可見度:指定部門(2 部門)");
});
it("混合 access_list 顯示人數", () => {
expect(Pure.visibilityLabel(
{ visibility: "public", access_list: [1, 2, 3] }, "公開"))
.toBe("可見度:公開(3 人)");
});
it("部門 + 人員都有", () => {
expect(Pure.visibilityLabel(
{ visibility: "selected_departments", allowed_departments: ["A"], access_list: [1] }, "指定"))
.toBe("可見度:指定(1 部門・1 人)");
});
});
describe("scanKeywords(PII 關鍵字/片語比對)", () => {
const rules = [{ rule: "tw_name", pattern: "王小明", variants: ["小明"] },
{ rule: "secret", pattern: "機密", variants: [] }];
it("命中 pattern", () => {
const h = Pure.scanKeywords("這是王小明的資料", rules);
expect(h.length).toBe(1);
expect(h[0].name).toBe("tw_name");
});
it("命中 variant", () => {
expect(Pure.scanKeywords("小明你好", rules)[0].name).toBe("tw_name");
});
it("大小寫不敏感", () => {
expect(Pure.scanKeywords("ABC機密DEF", rules).some((x) => x.name === "secret")).toBe(true);
});
it("無命中 / 空輸入安全", () => {
expect(Pure.scanKeywords("無關內容", rules)).toEqual([]);
expect(Pure.scanKeywords("", rules)).toEqual([]);
expect(Pure.scanKeywords("x", [])).toEqual([]);
});
});
describe("matchRegexRules(scanPii 純核心)", () => {
it("全域正則抓多筆", () => {
const rules = [{ name: "digits", re: /\d+/g }];
const h = Pure.matchRegexRules("a12 b345", rules);
expect(h.map((x) => x.match)).toEqual(["12", "345"]);
});
it("非 global 不無限迴圈(只取一筆)", () => {
const h = Pure.matchRegexRules("a12 b345", [{ name: "d", re: /\d+/ }]);
expect(h.length).toBe(1);
});
it("空輸入安全", () => {
expect(Pure.matchRegexRules("", [{ name: "d", re: /\d+/g }])).toEqual([]);
});
});
describe("submitGate(送出/草稿閘門)", () => {
it("正常內容 → 可送出可草稿", () => {
const g = Pure.submitGate({ len: 100, max: 50000, piiHitCount: 0 });
expect(g.canSubmit).toBe(true); expect(g.canDraft).toBe(true);
});
it("PII 命中 → 不可送出但可草稿(§9.1 前端把關)", () => {
const g = Pure.submitGate({ len: 100, max: 50000, piiHitCount: 2 });
expect(g.canSubmit).toBe(false); expect(g.piiBlocked).toBe(true);
expect(g.canDraft).toBe(true);
});
it("空內容 → 都不可", () => {
const g = Pure.submitGate({ len: 0, max: 50000, piiHitCount: 0 });
expect(g.canSubmit).toBe(false); expect(g.canDraft).toBe(false);
});
it("超字數上限 → 都不可", () => {
const g = Pure.submitGate({ len: 60000, max: 50000, piiHitCount: 0 });
expect(g.canSubmit).toBe(false); expect(g.canDraft).toBe(false);
});
});
describe("relativeTime(相對時間標籤)", () => {
const NOW = Date.parse("2026-06-06T12:00:00Z");
it("空 → 空字串", () => expect(Pure.relativeTime("", NOW)).toBe(""));
it("剛剛(<60s)", () => expect(Pure.relativeTime("2026-06-06T11:59:30Z", NOW)).toBe("剛剛"));
it("分鐘前", () => expect(Pure.relativeTime("2026-06-06T11:30:00Z", NOW)).toBe("30 分鐘前"));
it("小時前", () => expect(Pure.relativeTime("2026-06-06T09:00:00Z", NOW)).toBe("3 小時前"));
it("天前", () => expect(Pure.relativeTime("2026-06-03T12:00:00Z", NOW)).toBe("3 天前"));
it("無效字串 → 原樣回", () => expect(Pure.relativeTime("not-a-date", NOW)).toBe("not-a-date"));
it("空格分隔 + 無 Z 也能解析", () =>
expect(Pure.relativeTime("2026-06-06 11:59:30", NOW)).toBe("剛剛"));
});
describe("parseCsvList(標籤/清單解析)", () => {
it("逗號分隔 + 去空白", () =>
expect(Pure.parseCsvList("a, b ,c")).toEqual(["a", "b", "c"]));
it("中文逗號/頓號/換行也分隔", () =>
expect(Pure.parseCsvList("甲,乙、丙\n丁")).toEqual(["甲", "乙", "丙", "丁"]));
it("去重 + 去空項", () =>
expect(Pure.parseCsvList("a, ,a,b,")).toEqual(["a", "b"]));
it("空/null 安全", () => {
expect(Pure.parseCsvList("")).toEqual([]);
expect(Pure.parseCsvList(null)).toEqual([]);
});
});
describe("gradePassword(密碼強度,§10.5)", () => {
it("空 → score 0", () => expect(Pure.gradePassword("").score).toBe(0));
it("弱(短)", () => {
const g = Pure.gradePassword("abc");
expect(g.label).toBe("弱");
expect(g.missing).toContain("≥ 8 字");
expect(g.missing).toContain("1 大寫");
});
it("良好(8+大小寫+數字)", () => {
const g = Pure.gradePassword("Abcdefg1");
expect(g.score).toBe(4); // len8 + 大 + 小 + 數字
expect(g.label).toBe("強");
});
it("強(12+數字+特殊)", () => {
const g = Pure.gradePassword("Abcdefgh123!");
expect(g.score).toBe(5);
expect(g.label).toBe("強");
});
it("缺大寫 → missing 提示", () => {
expect(Pure.gradePassword("abcdefg1").missing).toContain("1 大寫");
});
});
describe("fmtTok(token 簡寫)", () => {
it("小數字原樣", () => expect(Pure.fmtTok(500)).toBe("500"));
it("千 → K", () => expect(Pure.fmtTok(1500)).toBe("1.5K"));
it("百萬 → M", () => expect(Pure.fmtTok(2_300_000)).toBe("2.3M"));
it("0/null 安全", () => { expect(Pure.fmtTok(0)).toBe("0"); expect(Pure.fmtTok(null)).toBe("0"); });
});
describe("isAllowedEmailDomain(公司網域白名單)", () => {
const domains = ["demo.local", "example.com"];
it("白名單內 → true", () =>
expect(Pure.isAllowedEmailDomain("a@demo.local", domains)).toBe(true));
it("大小寫不敏感", () =>
expect(Pure.isAllowedEmailDomain("A@Example.COM", domains)).toBe(true));
it("不在白名單 → false", () =>
expect(Pure.isAllowedEmailDomain("a@gmail.com", domains)).toBe(false));
it("非 email / 空 → false", () => {
expect(Pure.isAllowedEmailDomain("notanemail", domains)).toBe(false);
expect(Pure.isAllowedEmailDomain("", domains)).toBe(false);
});
});
describe("fmtKB / fmtMB(檔案大小)", () => {
it("KB 一位小數", () => expect(Pure.fmtKB(1536)).toBe("1.5"));
it("MB 兩位(預設)", () => expect(Pure.fmtMB(2 * 1024 * 1024)).toBe("2.00"));
it("MB 指定位數", () => {
expect(Pure.fmtMB(1.5 * 1024 * 1024, 1)).toBe("1.5");
expect(Pure.fmtMB(2.6 * 1024 * 1024, 0)).toBe("3");
});
it("0/null 安全", () => { expect(Pure.fmtKB(0)).toBe("0.0"); expect(Pure.fmtMB(null)).toBe("0.00"); });
});
describe("escapeHtml(XSS 跳脫)", () => {
it("跳脫五個危險字元", () =>
expect(Pure.escapeHtml(`");
expect(h).toContain("<script>");
expect(h).not.toContain("