Skip to content

Commit 56303c6

Browse files
committed
feat(review): 建到期刻度视觉承载面
1 parent 9508c5d commit 56303c6

10 files changed

Lines changed: 330 additions & 6 deletions

‎app/src/components/ModelCardCreateDialog.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import ModelCardCreateDialog from "./ModelCardCreateDialog";
1919
const createdFlashcard: Flashcard = {
2020
id: 10, groupId: 3, noteId: null, fragmentId: null,
2121
front: "安全边际", back: "本质:\n边界:\n联系:",
22-
kind: "model", stateJson: "{}", dueAt: 0, createdAt: 0,
22+
kind: "model", stateJson: "{}", dueAt: 0, createdAt: 0, intervalDays: 0,
2323
};
2424

2525
beforeEach(() => {

‎app/src/components/PromoteCardButton.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import PromoteCardButton from "./PromoteCardButton";
1919
const modelCard: Flashcard = {
2020
id: 5, groupId: 3, noteId: null, fragmentId: null,
2121
front: "安全边际", back: "本质:\n边界:\n联系:",
22-
kind: "model", stateJson: "{}", dueAt: 0, createdAt: 0,
22+
kind: "model", stateJson: "{}", dueAt: 0, createdAt: 0, intervalDays: 0,
2323
};
2424

2525
const systems: KnowledgeSystem[] = [

‎app/src/components/RouteInfoPopover.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ describe("RouteInfoPopover ⓘ 弹层", () => {
143143
switch (cmd) {
144144
case "list_group_cards":
145145
return [
146-
{ id: 1, groupId: 3, kind: "model", front: "曝光三角", back: "q", noteId: null, fragmentId: null, stateJson: "", createdAt: 0, updatedAt: 0, dueAt: 0 },
146+
{ id: 1, groupId: 3, kind: "model", front: "曝光三角", back: "q", noteId: null, fragmentId: null, stateJson: "", createdAt: 0, updatedAt: 0, dueAt: 0, intervalDays: 0 },
147147
];
148148
case "list_knowledge_systems":
149149
return [{ id: 2, parentSystemId: null, name: "摄影", kind: "domain", coreQuestion: null, status: "active", createdAt: 0, updatedAt: 0 }];
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* DueScale — 到期刻度的**视觉承载面**(批 6 波 B · R5.4;§8.6 签名动效 #4「刻度生长」的静态形态)。
3+
*
4+
* @ai-context: Why 本件存在 —— 复习面今天只有纯文字「共 N 张到期」,「到期刻度」作为**视觉形态**
5+
* 0 命中(`recon-b` B4 双侧自证:命中 5 行全是 token 用途说明与棘轮台账文字);
6+
* 而签名动效 #4 要「生长 / 回缩」,前提是先有一个**可补间的几何量**(刻度长度)。
7+
* 本件就是那个承载面:长度 = 后端计数;逐段长度 = 单卡间隔。形态是 **div 条阵列**
8+
* (不是内联 svg —— `ui/icons/no-inline-svg.test.ts` 明禁新增内联 svg)。
9+
* 🔴 交付边界:本件**只建静态形态**(承载面 + 数据接线 + 判据)。生长 / 回缩动效归
10+
* **T30**(波 C)—— 本件交付时刻度是**静止的**,不许把 #4 说成已交付。
11+
* @ai-context: 数据真源(各一,且只此两处):① 段数 = `count_due_cards` 的计数(`due` prop,由调用点
12+
* 透传 —— 本件**不发 IPC、不二次查询**,全件零 `@tauri-apps` 依赖);② 逐段间隔 =
13+
* `Flashcard.intervalDays`(`intervals` prop)。🔴 **禁止自造间隔**:本件不解析
14+
* `stateJson`、不用 `dueAt` 做差分、不按字符数折算(R5.4 明文禁止 + AGENTS.md
15+
* 「不发明数字」)。缺 `intervals` ⇒ 只画计数刻度,**不猜、不插值、不假装精确**。
16+
* @ai-context: 双精度域(PB2 裁决 —— 两个域**不许混为一谈**):`intervalDays` 只有两个合法来路 ——
17+
* ① 行派生路径(`list_due_cards` 等 5 个查询方法,`row_to_card` 由 `due_at − lastReviewMs`
18+
* 反推)= **整天粒度**(`granularity="day"`,本件缺省;标签一律带「约…整天粒度」);
19+
* ② `review_card` 返回体 = **精确值**(`granularity="exact"`,由 T30 接线)。
20+
* 把整天值当精确值展示 = 标签说谎(批 3 A5 先例)。
21+
* @ai-context: 环境层第 ④ 件「到期刻度微光」的**真实落点**:刻度底轨带
22+
* `ed-surface--due-glow`(T13 落的 seam,`motion.css:184`;它的 `color: var(--ed-due)`
23+
* 是 R4.5 琥珀族的**唯一**来源 —— 本件不写任何琥珀字面量,段色取 `currentColor` 继承)。
24+
* 基类 `ed-surface` 与它同元素(同 `ed-text--low-confidence` 的用法);本件**不**用
25+
* `<Surface>` 原语,因为新调用点要登记进 `surfaceResidual.ts`(本任务禁碰棘轮表)。
26+
* 副作用:**无**(纯展示:不读 store、不发 IPC、不写盘、无定时器、无 effect、无 GSAP)。
27+
* 边界:① `due === 0` ⇒ 渲染**空刻度**(不落空态文案 —— 空态归调用点 `ReviewPage`);
28+
* ② `intervals[i] <= 0`(新卡 / 无复习记录)⇒ 画「无间隔」档,**不是** 0 长度(0 长度会让
29+
* 「新卡」与「这段不存在」不可分);③ `intervals` 短于 `due`(后端队列上限 200 < 全量到期数)
30+
* ⇒ 缺位段同样走「无间隔」档:「不在本轮队列里」≠「间隔为 0」,本件不替后端猜;
31+
* ④ 段数多时靠 flex 压缩与裁切,**不省略 DOM 段** —— 段数恒等于 `due`(那正是判据 V1)。
32+
* ⑤ 零裸 `<button>`(纯展示;`components/**` 在 `nativeButton` 棘轮域内)。
33+
*/
34+
import type { CSSProperties, ReactElement } from "react";
35+
36+
export interface DueScaleProps {
37+
/** 到期卡张数(来自 count_due_cards;**唯一**的"有多少"真源) */
38+
readonly due: number;
39+
/** 本轮队列的间隔(可选;来自 list_due_cards 的 intervalDays,整天粒度) */
40+
readonly intervals?: readonly number[];
41+
readonly testId?: string;
42+
/**
43+
* 间隔值的精度域。缺省 `day` = 行派生路径的**整天粒度**(PB2 ②域);`exact` 只许接
44+
* `review_card` 返回体的**精确值**(PB2 ①域)。两个域**不得**互相冒充。
45+
*/
46+
readonly granularity?: "day" | "exact";
47+
}
48+
49+
/** 「无间隔」档的段高(px)—— 新卡 / 间隔 0 / 不在本轮队列内。**视觉常量,不是数据真源**。 */
50+
const TICK_NONE_PX = 6;
51+
/** 按天刻度的段高区间:1 天 ⇒ 8px,`TICK_DAY_CAP` 天及以上 ⇒ 28px(单调不减 ⇒ 间隔越长刻度越长)。 */
52+
const TICK_DAY_MIN_PX = 8;
53+
const TICK_DAY_MAX_PX = 28;
54+
/** 封顶天数:再长的间隔不再变高 —— 刻度要的是「可感觉」,不是「可测量」(不发明数字)。 */
55+
const TICK_DAY_CAP = 30;
56+
57+
/** 段高的档位名(DOM 锚点:判据与后续动效读它,不读内联样式的像素值)。 */
58+
function tickTier(days: number | undefined): "none" | "day" {
59+
return days === undefined || !Number.isFinite(days) || days <= 0 ? "none" : "day";
60+
}
61+
62+
/** 一段刻度的**视觉长度**(px)。`days <= 0` 或未知 ⇒ 「无间隔」档的定长(**不是** 0 长度)。 */
63+
function tickHeight(days: number | undefined): number {
64+
if (tickTier(days) === "none") return TICK_NONE_PX;
65+
const capped = Math.min(days as number, TICK_DAY_CAP);
66+
return Math.round(TICK_DAY_MIN_PX + ((capped - 1) / (TICK_DAY_CAP - 1)) * (TICK_DAY_MAX_PX - TICK_DAY_MIN_PX));
67+
}
68+
69+
/** 段的悬停读数。精度域在这里也必须如实(`day` ⇒ 带「约」与「整天粒度」)。 */
70+
function tickLabel(index: number, days: number | undefined, granularity: "day" | "exact"): string {
71+
const head = `第 ${index + 1} 段`;
72+
if (tickTier(days) === "none") return `${head} · 无间隔记录(新卡或不在本轮队列)`;
73+
return granularity === "exact" ? `${head} · ${days} 天` : `${head} · 约 ${days} 天(整天粒度)`;
74+
}
75+
76+
/**
77+
* 渲染到期刻度尺。返回 `ReactElement`(React 19 下不写全局 `JSX.Element`)。
78+
*
79+
* 刻度底轨的琥珀来自 `ed-surface--due-glow` 的 `color: var(--ed-due)`(R4.5 的唯一合法来源)。
80+
*/
81+
export default function DueScale({ due, intervals, testId, granularity = "day" }: DueScaleProps): ReactElement {
82+
// 段数 = 计数(防御:非有限值 / 负数 / 小数一律按 0 段或截断处理 —— 不 panic、不造幽灵段)
83+
const count = Number.isFinite(due) ? Math.max(0, Math.trunc(due)) : 0;
84+
const queueMarks = Math.min(intervals?.length ?? 0, count);
85+
const label = count === 0
86+
? "到期刻度:当前范围 0 张到期(空刻度)"
87+
: `${count} 张到期刻度的刻度尺 · 队列 ${queueMarks} 张带间隔(${granularity === "exact" ? "精确值" : "整天粒度"})`;
88+
const lane: CSSProperties = {
89+
display: "flex", alignItems: "flex-end", gap: 2, height: TICK_DAY_MAX_PX + 8,
90+
padding: "4px 6px", overflow: "hidden",
91+
};
92+
const tick: CSSProperties = {
93+
flex: "1 1 0", minWidth: 1, maxWidth: 5, background: "currentColor", borderRadius: 1,
94+
};
95+
return (
96+
<div data-testid={testId} data-due={count} data-granularity={granularity} title={label} role="img" aria-label={label}>
97+
{count > 0 && (
98+
<div className="ed-surface ed-surface--sunken ed-surface--due-glow" style={lane}>
99+
{Array.from({ length: count }, (_, i) => {
100+
const days = intervals?.[i];
101+
return (
102+
<span
103+
key={i}
104+
data-tick={i}
105+
data-tier={tickTier(days)}
106+
data-interval={days === undefined ? undefined : String(days)}
107+
title={tickLabel(i, days, granularity)}
108+
style={{ ...tick, height: tickHeight(days) }}
109+
/>
110+
);
111+
})}
112+
</div>
113+
)}
114+
</div>
115+
);
116+
}

‎app/src/components/review/ReviewSessionPanel.test.tsx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import ReviewSessionPanel from "./ReviewSessionPanel";
1818
function card(id: number, front: string, back: string, kind = "fact"): Flashcard {
1919
return {
2020
id, groupId: 1, noteId: null, fragmentId: null, front, back,
21-
kind, stateJson: "", dueAt: 0, createdAt: 0,
21+
kind, stateJson: "", dueAt: 0, createdAt: 0, intervalDays: 0,
2222
};
2323
}
2424

@@ -96,6 +96,23 @@ describe("ReviewSessionPanel 复习流", () => {
9696
expect(onExit).toHaveBeenCalledTimes(2);
9797
});
9898

99+
it("评分返回值被接住:review_card 的精确 intervalDays 落到回执读数上(PB2 ①域——今天被整份丢弃)", async () => {
100+
invokeMock.mockImplementation(async (cmd: string) => {
101+
if (cmd === "list_due_cards") return [card(21, "隔离霜作用", "打底", "fact")];
102+
// 精确域:非整数天(整天粒度的行派生路径不可能给出这个值 —— 两个域必须可区分)
103+
if (cmd === "review_card") return { ...card(21, "隔离霜作用", "打底", "fact"), intervalDays: 12.5 };
104+
throw new Error(`unexpected: ${cmd}`);
105+
});
106+
renderPanel();
107+
// 评分前无回执(缺失 ≠ 0:不猜、不预先显示 0 天)
108+
expect(screen.queryByTestId("session-last-interval")).toBeNull();
109+
fireEvent.click(await screen.findByText("回忆完成 · 查看答案"));
110+
fireEvent.click(screen.getByText("记得"));
111+
await waitFor(() =>
112+
expect(screen.getByTestId("session-last-interval").textContent).toBe("上一张下次间隔 12.5 天"),
113+
);
114+
});
115+
99116
it("active=false(页面隐藏保活期)→ ESC 不退出会话;active=true 后 ESC 恢复生效(审查 P2-11)", async () => {
100117
const { onExit, rerender } = renderPanel({ active: false });
101118
await screen.findByText("隔离霜作用");

0 commit comments

Comments
 (0)