// 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("