| |
| |
| 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); |
| 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" }), |
| mk({ timestamp: "2026-05-20T00:00:00Z" }), |
| mk({ timestamp: "2026-04-01T00:00:00Z" }), |
| mk({ timestamp: "2026-01-01T00:00:00Z" }), |
| ], 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); |
| 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(`<script>"&'`)).toBe("<script>"&'")); |
| it("null/undefined → 空字串", () => { |
| expect(Pure.escapeHtml(null)).toBe(""); expect(Pure.escapeHtml(undefined)).toBe(""); |
| }); |
| it("一般文字不變", () => expect(Pure.escapeHtml("正常內容 abc 123")).toBe("正常內容 abc 123")); |
| }); |
|
|
| describe("diff(LCS 字詞級)", () => { |
| it("相同 → 兩側皆等,無底色", () => { |
| const d = Pure.diffSides("甲乙丙", "甲乙丙"); |
| expect(d.left).toBe("甲乙丙"); expect(d.right).toBe("甲乙丙"); |
| expect(d.left).not.toContain("background"); |
| }); |
| it("新增 → 右側綠底", () => { |
| const d = Pure.diffSides("甲乙", "甲乙丙"); |
| expect(d.right).toContain("#bbf7d0"); |
| expect(d.right).toContain("丙"); |
| }); |
| it("刪除 → 左側紅底", () => { |
| const d = Pure.diffSides("甲乙丙", "甲乙"); |
| expect(d.left).toContain("#fecaca"); |
| }); |
| it("diff 內容也經 escapeHtml(防 XSS)", () => { |
| const d = Pure.diffSides("", "<b>x</b>"); |
| expect(d.right).toContain("<"); |
| expect(d.right).toContain(">"); |
| expect(d.right).not.toContain("<b>x</b>"); |
| }); |
| it("lcsOps 基本正確", () => { |
| const ops = Pure.lcsOps(["a", "b", "c"], ["a", "x", "c"]); |
| expect(ops.filter((o) => o.t === "eq").map((o) => o.v)).toEqual(["a", "c"]); |
| expect(ops.some((o) => o.t === "del" && o.v === "b")).toBe(true); |
| expect(ops.some((o) => o.t === "ins" && o.v === "x")).toBe(true); |
| }); |
| }); |
|
|
| describe("renderMarkdown(極簡 markdown + XSS 安全)", () => { |
| it("標題 # → h2", () => |
| expect(Pure.renderMarkdown("# 標題")).toContain("<h2>標題</h2>")); |
| it("粗體 / 斜體 / code", () => { |
| expect(Pure.renderMarkdown("**粗**")).toContain("<strong>粗</strong>"); |
| expect(Pure.renderMarkdown("`x`")).toContain("<code"); |
| }); |
| it("清單 - → li", () => |
| expect(Pure.renderMarkdown("- 項目")).toContain("<li>項目</li>")); |
| it("合法連結 https:// → a", () => { |
| const h = Pure.renderMarkdown("[連結](https://example.com)"); |
| expect(h).toContain('href="https://example.com"'); |
| expect(h).toContain('rel="noopener noreferrer"'); |
| }); |
| it("XSS:javascript: 連結不被轉成 a(擋危險協定)", () => { |
| const h = Pure.renderMarkdown("[x](javascript:alert(1))"); |
| expect(h).not.toContain("<a "); |
| expect(h).not.toContain('href='); |
| }); |
| it("XSS:原始 <script> 被 escape", () => { |
| const h = Pure.renderMarkdown("<script>alert(1)</script>"); |
| expect(h).toContain("<script>"); |
| expect(h).not.toContain("<script>"); |
| }); |
| }); |
|
|
| describe("diff 大型輸入退化行級(>2600 token 分支)", () => { |
| it("超長內容仍正確 diff(走行級 LCS 退化,不卡頓)", () => { |
| |
| const base = Array.from({ length: 3000 }, (_, i) => "行" + i).join("\n"); |
| const merged = base + "\n新增的最後一行"; |
| const d = Pure.diffSides(base, merged); |
| expect(d.right).toContain("#bbf7d0"); |
| expect(d.right).toContain("新增的最後一行"); |
| expect(d.left).not.toContain("新增的最後一行"); |
| }); |
| it("wordDiffOps 直接驗:長輸入回 ops 陣列且含 ins", () => { |
| const a = Array.from({ length: 2700 }, (_, i) => "x" + i).join("\n"); |
| const b = a + "\nZZZ"; |
| const ops = Pure.wordDiffOps(a, b); |
| expect(Array.isArray(ops)).toBe(true); |
| expect(ops.some((o) => o.t === "ins" && /ZZZ/.test(o.v))).toBe(true); |
| }); |
| }); |
|
|