|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * DueScale.test.tsx — 到期刻度承载面的判据(批 6 波 B · T21;R5.4 / §8.6 #4 的承载面)。 |
| 4 | + * |
| 5 | + * @ai-context: 覆盖五条 —— ① 计数真源唯一(段数 == `due`,**逐序数组相等** + `data-due` 双断言); |
| 6 | + * ② 缺 `intervals` 时**不假装精确**(零 `[data-interval]`,只有计数刻度); |
| 7 | + * ③ 双精度域如实区分(`day` ⇒ 标签带「约…整天粒度」,`exact` ⇒ 精确值); |
| 8 | + * ④ **零自造间隔**(剥注释后扫两个生产文件:`stateJson` 解析 / `dueAt` 差分 / |
| 9 | + * `86400000` 三个正则 0 命中,含仪器双侧自证);⑤ **零新增 IPC**(两个生产文件里 |
| 10 | + * `count_due_cards` 的调用点**逐序**等于既有那两处 · 本件不 import `@tauri-apps`)。 |
| 11 | + * 外加一条形态接线判据:底轨带 `ed-surface--due-glow`(环境层 ④ 的真实落点) |
| 12 | + * 且本件零琥珀字面量(R4.5:琥珀唯一来源 = `tokens.gen.ts` 的 `due` 族)。 |
| 13 | + * @ai-context: 边界:本件**只判静态形态**(段数 / 属性 / 标签 / 源码面)—— |
| 14 | + * 「生长 / 回缩」动效不在本任务(归 T30 波 C),故这里**没有**任何时间轴断言。 |
| 15 | + * 无 jest-dom / 无 user-event ⇒ 只用原生 DOM API 与 `===` 断言(仓内既有范式)。 |
| 16 | + */ |
| 17 | +import { readFileSync } from "node:fs"; |
| 18 | +import { dirname, join } from "node:path"; |
| 19 | +import { fileURLToPath } from "node:url"; |
| 20 | +import { afterEach, describe, expect, it } from "vitest"; |
| 21 | +import { cleanup, render } from "@testing-library/react"; |
| 22 | +import { readLines, stripComments } from "../../ui/primitives/sliceScan"; |
| 23 | +import DueScale from "./DueScale"; |
| 24 | + |
| 25 | +const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); |
| 26 | +const PROD = ["components/review/DueScale.tsx", "pages/ReviewPage.tsx"] as const; |
| 27 | +const readProd = (rel: string): { raw: string[]; stripped: string[] } => readLines(join(SRC, ...rel.split("/"))); |
| 28 | + |
| 29 | +/** 段序号(逐序数组——「恰 N 个、按序」一律用数组相等,不用 `.length` 单断言) */ |
| 30 | +const ticks = (c: HTMLElement): (string | null)[] => |
| 31 | + [...c.querySelectorAll("[data-tick]")].map((el) => el.getAttribute("data-tick")); |
| 32 | +/** 段上的某个 `data-*`(逐序) */ |
| 33 | +const attr = (c: HTMLElement, name: string): (string | null)[] => |
| 34 | + [...c.querySelectorAll("[data-tick]")].map((el) => el.getAttribute(name)); |
| 35 | +/** 段的 `title`(逐序) */ |
| 36 | +const titles = (c: HTMLElement): (string | null)[] => |
| 37 | + [...c.querySelectorAll("[data-tick]")].map((el) => el.getAttribute("title")); |
| 38 | +const rootOf = (c: HTMLElement): HTMLElement => c.querySelector('[data-testid="scale"]') as HTMLElement; |
| 39 | +const styleOf = (c: HTMLElement, i: number): CSSStyleDeclaration => |
| 40 | + (c.querySelectorAll("[data-tick]")[i] as HTMLElement).style; |
| 41 | + |
| 42 | +afterEach(() => cleanup()); |
| 43 | + |
| 44 | +describe("DueScale · 计数真源与刻度形态", () => { |
| 45 | + it("V1 · 段数 == 传入的 due(两个读数逐序相等 ⇒ 段数不是任何常量):7 ⇒ 7 段 · 0 ⇒ 0 段", () => { |
| 46 | + const { container, rerender } = render(<DueScale due={7} testId="scale" />); |
| 47 | + expect(rootOf(container).getAttribute("data-due")).toBe("7"); |
| 48 | + expect(ticks(container)).toEqual(["0", "1", "2", "3", "4", "5", "6"]); |
| 49 | + rerender(<DueScale due={9} testId="scale" />); |
| 50 | + expect(rootOf(container).getAttribute("data-due")).toBe("9"); |
| 51 | + expect(ticks(container)).toEqual(["0", "1", "2", "3", "4", "5", "6", "7", "8"]); |
| 52 | + rerender(<DueScale due={0} testId="scale" />); |
| 53 | + expect(rootOf(container).getAttribute("data-due")).toBe("0"); |
| 54 | + expect(ticks(container)).toEqual([]); |
| 55 | + }); |
| 56 | + |
| 57 | + it("边界 · due=0 渲染空刻度(**不是**空态文案 —— 空态归调用点),不落任何刻度底轨", () => { |
| 58 | + const { container } = render(<DueScale due={0} testId="scale" />); |
| 59 | + expect(container.textContent).toBe(""); |
| 60 | + expect(container.querySelector(".ed-surface--due-glow")).toBeNull(); |
| 61 | + // 防御:非有限值 / 负数 / 小数不造幽灵段 |
| 62 | + const bad = render(<DueScale due={Number.NaN} testId="scale" />); |
| 63 | + expect(ticks(bad.container)).toEqual([]); |
| 64 | + bad.rerender(<DueScale due={-3} testId="scale" />); |
| 65 | + expect(ticks(bad.container)).toEqual([]); |
| 66 | + bad.rerender(<DueScale due={2.9} testId="scale" />); |
| 67 | + expect(ticks(bad.container)).toEqual(["0", "1"]); |
| 68 | + }); |
| 69 | + |
| 70 | + it("V3 · 缺 intervals ⇒ 不假装精确:零 [data-interval]、每段都是「无间隔」档、granularity 缺省 day", () => { |
| 71 | + const { container } = render(<DueScale due={5} testId="scale" />); |
| 72 | + expect(container.querySelectorAll("[data-interval]").length).toBe(0); |
| 73 | + expect(attr(container, "data-tier")).toEqual(["none", "none", "none", "none", "none"]); |
| 74 | + expect(rootOf(container).getAttribute("data-granularity")).toBe("day"); |
| 75 | + }); |
| 76 | + |
| 77 | + it("刻度长度来自 intervalDays(单调不减):0.0(新卡)⇒ 「无间隔」档且**不是** 0 长度", () => { |
| 78 | + const { container } = render(<DueScale due={3} testId="scale" intervals={[0, 1, 30]} />); |
| 79 | + expect(attr(container, "data-interval")).toEqual(["0", "1", "30"]); |
| 80 | + expect(attr(container, "data-tier")).toEqual(["none", "day", "day"]); |
| 81 | + const [newCard, oneDay, cap] = [styleOf(container, 0).height, styleOf(container, 1).height, styleOf(container, 2).height]; |
| 82 | + expect(parseFloat(newCard)).toBeGreaterThan(0); |
| 83 | + expect(parseFloat(oneDay)).toBeGreaterThanOrEqual(parseFloat(newCard)); |
| 84 | + expect(parseFloat(cap)).toBeGreaterThanOrEqual(parseFloat(oneDay)); |
| 85 | + }); |
| 86 | + |
| 87 | + it("P1 · 双精度域不许混为一谈:day 标签带「约…整天粒度」· exact 标签是精确值", () => { |
| 88 | + const { container, rerender } = render(<DueScale due={2} intervals={[3.5, 30]} testId="scale" />); |
| 89 | + expect(rootOf(container).getAttribute("data-granularity")).toBe("day"); |
| 90 | + expect(titles(container)).toEqual(["第 1 段 · 约 3.5 天(整天粒度)", "第 2 段 · 约 30 天(整天粒度)"]); |
| 91 | + rerender(<DueScale due={2} intervals={[3.5, 30]} granularity="exact" testId="scale" />); |
| 92 | + expect(rootOf(container).getAttribute("data-granularity")).toBe("exact"); |
| 93 | + expect(titles(container)).toEqual(["第 1 段 · 3.5 天", "第 2 段 · 30 天"]); |
| 94 | + }); |
| 95 | + |
| 96 | + it("P2 · 环境层 ④ 的真实落点:底轨带 ed-surface--due-glow,且本件零琥珀字面量(R4.5)", () => { |
| 97 | + const { container } = render(<DueScale due={2} testId="scale" />); |
| 98 | + const lane = container.querySelector(".ed-surface--due-glow") as HTMLElement; |
| 99 | + expect(lane.className).toBe("ed-surface ed-surface--sunken ed-surface--due-glow"); |
| 100 | + expect(readFileSync(join(SRC, "components", "review", "DueScale.tsx"), "utf8")).not.toMatch( |
| 101 | + /#9F5E10|#E0A44B|#B26A12|#A05F10/i, |
| 102 | + ); |
| 103 | + }); |
| 104 | +}); |
| 105 | + |
| 106 | +describe("DueScale · 源码面判据(R5.4 的明文禁止 → 守卫)", () => { |
| 107 | + /** 三个正则 = 计划 V2 逐字:`stateJson` 解析 / `dueAt` 差分 / 毫秒常量折算 */ |
| 108 | + const FORBIDDEN = [/stateJson\s*\./, /dueAt\s*[-+]/, /86_?400_?000/] as const; |
| 109 | + |
| 110 | + it("V2 · 剥注释后两个生产文件三个正则**0 命中**(仪器双侧自证:正样本必命中 · 注释里的不算)", () => { |
| 111 | + const hits = PROD.flatMap((rel) => |
| 112 | + readProd(rel).stripped.flatMap((line, i) => |
| 113 | + FORBIDDEN.filter((re) => re.test(line)).map((re) => `${rel}:${i + 1}: ${String(re)}`), |
| 114 | + ), |
| 115 | + ); |
| 116 | + expect(hits).toEqual([]); |
| 117 | + // 正样本(防「仪器静默失效 ⇒ 上面的 0 命中是空真」):计划 V2 的 M2 那一行必须被抓到 |
| 118 | + const mutant = "const days = (c.dueAt - c.createdAt) / 86400000;"; |
| 119 | + expect(FORBIDDEN.some((re) => re.test(mutant))).toBe(true); |
| 120 | + expect(FORBIDDEN.some((re) => re.test("const s = c.stateJson.stability;"))).toBe(true); |
| 121 | + // 负样本:同一批字面量落在注释里 ⇒ 剥注释后不再命中(否则本判据会被注释骗过) |
| 122 | + expect(stripComments(`// ${mutant}\n/* c.stateJson.stability */`).includes("dueAt")).toBe(false); |
| 123 | + }); |
| 124 | + |
| 125 | + it("V5 · 零新增 IPC:两个生产文件里 count_due_cards 的调用点**逐序**仍只有既有那两处", () => { |
| 126 | + const sites = PROD.flatMap((rel) => |
| 127 | + readProd(rel).stripped |
| 128 | + .map((line) => line.trim()) |
| 129 | + .filter((line) => line.includes('"count_due_cards"')), |
| 130 | + ); |
| 131 | + expect(sites).toEqual([ |
| 132 | + 'invoke<number>("count_due_cards", { groupId: null }),', |
| 133 | + '...list.map((g) => invoke<number>("count_due_cards", { groupId: g.id })),', |
| 134 | + ]); |
| 135 | + // 承载面是纯展示:不 import `@tauri-apps`(M5 的第二种表现;剥注释后扫 —— 头注里提到它不算) |
| 136 | + expect(readProd(PROD[0]).stripped.join("\n").includes("@tauri-apps")).toBe(false); |
| 137 | + }); |
| 138 | +}); |
0 commit comments