Skip to content

Commit 8a2dd5e

Browse files
committed
test(ui): harden loading ratchet teeth
1 parent ed07d49 commit 8a2dd5e

3 files changed

Lines changed: 147 additions & 144 deletions

File tree

‎app/src/ui/primitives/loadingBaseline.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,26 @@
3333
*/
3434
export const FROZEN_LOADING_TEXT_TOTAL = 8;
3535

36+
/**
37+
* ★ **迁移面只许增 / 残留面只许减**的锚(收口评审 I-3 的结清;照 `statusLineRatchet` ① 的下界范式)。
38+
*
39+
* Why 需要这两条:③ 的「整表钉死」(`expected = MIGRATED(0) ∪ RESIDUAL(1)` + `TOTAL == RESIDUAL.length`)
40+
* 只堵住了**单点篡改** —— 把某文件从迁移面**挪进**残留面时 `expected` 会跟着变,
41+
* 判据照样绿(实测变异体 `m2b`:常数 8→9 + 挪 1 个文件 + 真加一处 `加载中…` ⇒ **全绿 8/8**)。
42+
* 等于给了「写新手写加载文案 + 声明为 `backlog`」一条自助豁免通道。
43+
*
44+
* 语义(**只许朝一个方向动**,与棘轮同向):
45+
* · `FROZEN_MIGRATED_FILES_COUNT` = 迁移面**下界** —— 实测 `MIGRATED_FILES.length` 必须 **≥** 它。
46+
* 真迁走一个文件时**只增不减**;想「挪出去放行新文案」必红。
47+
* · `FROZEN_LOADING_RESIDUAL_COUNT` = 残留面**上界** —— 实测 `RESIDUAL.length` 必须 **≤** 它。
48+
* 真收敛(把残留迁干净)时把它**手工收紧**;想多登一条豁免必红。
49+
* ⇒ 两者与 ③ 的「表自洽」互为独立抓手:一个管**面的大小**,一个管**表的形状**。
50+
*/
51+
export const FROZEN_MIGRATED_FILES_COUNT = 10;
52+
53+
/** 残留面**上界**(只许减;见上条说明)。 */
54+
export const FROZEN_LOADING_RESIDUAL_COUNT = 8;
55+
3656
/**
3757
* 相对 `app/src` 的路径 → 允许残留的可见加载文案**行数上限**(只许降、不许升;键不许删)。
3858
*

‎app/src/ui/primitives/loadingRatchet.test.ts‎

Lines changed: 26 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -10,166 +10,31 @@
1010
* ★ 牙齿(评审 M-1 的结清):③ 用「迁移面(0) ∪ 残留面(1)」钉死冻结表每一格 + `TOTAL == RESIDUAL.length`
1111
* 独立校验和 ⇒「总数 + 某一残留格同时抬高」不再自洽。
1212
*
13-
* ★ 扫描口径(与 `tmp/scan-callsites.mjs` / T1 的 loading 类**同源**)
14-
* ① 域 = `app/src/**` 的 `.ts`/`.tsx` 减 `*.test.ts(x)` 减 `ui/primitives/**` 减 `ui/icons/**`;
15-
* ② 先剥注释(抹等长空白保行号);字符串/模板只跳过不抹内容 —— 判据读的正是可见文案;
16-
* ③ 行口径 `/加载[^"'`\n]{0,8}(中|…)/`,同行多次命中算 1 行(与 T1 的 18 行读数可比);
17-
* **唯一例外**:文案由原语的 `label` 承载时**不计**(`label="…"` / `label={…}` —— 那是迁移的
18-
* 终点形态,不是手写占位)。冻结时域内 `label=` 载体 0 处 ⇒ 与 T1 读数逐字可比;跨行 label 会
19-
* 被误计,实测本仓 0 例(迁移面 5 处 label 全在同行),已由 ① 的仪器对照钉住。
13+
* ★ 仪器已析出成 `./loadingScan`(收口评审 I-3:本文件贴 300 行硬限,加锚必须先析出)——
14+
* 域 / 剥注释 / 行口径 / 五事实一律在那里,本文件只留**断言**。
2015
*
21-
* 副作用:只读磁盘(递归遍历 `app/src`),不修改任何文件。边界:只管可见加载文案(不含布尔门控本身、
22-
* 不含 `busy` 状态机、不含已声明的 4 处例外);基线随迁移**只降不升**,余量去向见 `loadingBaseline.ts`。
16+
* 副作用:只读磁盘(`loadingScan` 模块加载时递归遍历 `app/src` 一次),不修改任何文件。边界:只管可见
17+
* 加载文案(不含布尔门控本身、不含 `busy` 状态机、不含已声明的 4 处例外);基线随迁移**只降不升**。
2318
*/
24-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
25-
import { dirname, join, relative, sep } from "node:path";
19+
import { existsSync } from "node:fs";
20+
import { dirname, join } from "node:path";
2621
import { fileURLToPath } from "node:url";
2722
import { describe, expect, it } from "vitest";
2823
import {
24+
FROZEN_LOADING_RESIDUAL_COUNT,
2925
FROZEN_LOADING_TEXT_BY_FILE,
3026
FROZEN_LOADING_TEXT_TOTAL,
27+
FROZEN_MIGRATED_FILES_COUNT,
3128
MIGRATED_FILES,
3229
RESIDUAL,
3330
type ResidualKind,
3431
} from "./loadingBaseline";
32+
import { HITTING, RE_LOADING_TEXT, SCAN, TOTAL, isHandwritten, isIcons, isPrim, isTest, stripComments } from "./loadingScan";
3533

3634
const HERE = dirname(fileURLToPath(import.meta.url));
3735
/** `app/src` —— 基线的键就是相对这个目录的正斜杠路径。 */
3836
const SRC = join(HERE, "..", "..");
39-
40-
/** 可见加载文案(与 T1 的 loading 类同源;`g` 只用于统计,逐行判断用 `test`)。 */
41-
const RE_LOADING_TEXT = /加载[^"'`\n]{0,8}(中|…)/;
42-
/** 原语 `label` 载体:`label="…"` / `label={'…'}` / `label={…}`(该行不计入手写占位)。 */
43-
const RE_PRIMITIVE_LABEL = /\blabel\s*=(?:\{|["'`])/;
44-
/** 原语用法:`<Loading …` / `<Skeleton …`(`</Loading>` 不算、深导入不算 —— 只认标签)。 */
45-
const RE_PRIMITIVE_USE = /<(?:Loading|Skeleton)[\s/>]/;
46-
const RE_SKELETON_USE = /<Skeleton[\s/>]/;
47-
const RE_LOADING_USE = /<Loading[\s/>]/;
48-
/** `busy` 承载:`busy={loading}` / `busy` 简写。 */
49-
const RE_BUSY = /\bbusy(?:=\{|[>\s])/;
5037
const KINDS: readonly ResidualKind[] = ["exception", "button-busy", "backlog"];
51-
52-
/**
53-
* 剥注释:**与 `tmp/scan-callsites.mjs` 的 `stripComments()` 同一状态机**(重写一份而非 import ——
54-
* 那个文件在 gitignored 的 `tmp/` 下,测试不能依赖它存在)。抹等长空白保行号,字面量只跳过不抹内容。
55-
*/
56-
function stripComments(src: string): string {
57-
const out = src.split("");
58-
const blank = (a: number, b: number): void => {
59-
for (let i = a; i < b; i++) if (out[i] !== "\n") out[i] = " ";
60-
};
61-
const isRegexStart = (k: number): boolean => {
62-
let j = k - 1;
63-
while (j >= 0 && /\s/.test(src.charAt(j))) j--;
64-
return j < 0 || "(,=:[!&|?{};+-*%~^<>".includes(src.charAt(j));
65-
};
66-
const n = src.length;
67-
let i = 0;
68-
while (i < n) {
69-
const c = src.charAt(i);
70-
if (c === "/" && src.charAt(i + 1) === "/") {
71-
const end = src.indexOf("\n", i);
72-
blank(i, end < 0 ? n : end);
73-
i = end < 0 ? n : end;
74-
continue;
75-
}
76-
if (c === "/" && src.charAt(i + 1) === "*") {
77-
const end = src.indexOf("*/", i + 2);
78-
blank(i, end < 0 ? n : end + 2);
79-
i = end < 0 ? n : end + 2;
80-
continue;
81-
}
82-
if (c === '"' || c === "'") {
83-
let j = i + 1;
84-
while (j < n && src.charAt(j) !== c) {
85-
if (src.charAt(j) === "\\") j++;
86-
if (src.charAt(j) === "\n") break;
87-
j++;
88-
}
89-
i = j + 1;
90-
continue;
91-
}
92-
if (c === "`") {
93-
let j = i + 1;
94-
while (j < n && src.charAt(j) !== "`") {
95-
if (src.charAt(j) === "\\") j++;
96-
j++;
97-
}
98-
i = j + 1;
99-
continue;
100-
}
101-
if (c === "/" && isRegexStart(i)) {
102-
let j = i + 1;
103-
let cls = false;
104-
let ok = false;
105-
while (j < n) {
106-
const d = src.charAt(j);
107-
if (d === "\\") { j += 2; continue; }
108-
if (d === "\n") break;
109-
if (d === "[") cls = true;
110-
else if (d === "]") cls = false;
111-
else if (d === "/" && !cls) { ok = true; break; }
112-
j++;
113-
}
114-
if (ok) { blank(i + 1, j); i = j + 1; continue; }
115-
}
116-
i++;
117-
}
118-
return out.join("");
119-
}
120-
121-
function walk(dir: string, out: string[] = []): string[] {
122-
for (const name of readdirSync(dir)) {
123-
const p = join(dir, name);
124-
if (statSync(p).isDirectory()) walk(p, out);
125-
else if (/\.(ts|tsx)$/.test(name)) out.push(p);
126-
}
127-
return out;
128-
}
129-
130-
const isTest = (f: string): boolean => /\.test\.(ts|tsx)$/.test(f);
131-
const isPrim = (f: string): boolean => f.includes(`${sep}ui${sep}primitives${sep}`);
132-
const isIcons = (f: string): boolean => f.includes(`${sep}ui${sep}icons${sep}`);
133-
134-
interface FileFacts {
135-
/** 剥注释后文本里可见加载文案的**行数** */
136-
hits: number;
137-
hasPrimitive: boolean;
138-
hasSkeleton: boolean;
139-
hasLoading: boolean;
140-
hasBusy: boolean;
141-
}
142-
143-
/** 单行的"手写占位"判定(仪器自证与扫描共用同一实现,防"两套口径") */
144-
function isHandwritten(line: string): boolean {
145-
return RE_LOADING_TEXT.test(line) && !RE_PRIMITIVE_LABEL.test(line);
146-
}
147-
148-
/** 扫单个文件的五个事实(供扫描与仪器自证共用) */
149-
function factsOf(abs: string): FileFacts {
150-
const stripped = stripComments(readFileSync(abs, "utf8"));
151-
const lines = stripped.split("\n");
152-
return {
153-
hits: lines.filter(isHandwritten).length,
154-
hasPrimitive: RE_PRIMITIVE_USE.test(stripped),
155-
hasSkeleton: RE_SKELETON_USE.test(stripped),
156-
hasLoading: RE_LOADING_USE.test(stripped),
157-
hasBusy: RE_BUSY.test(stripped),
158-
};
159-
}
160-
161-
function scan(): Map<string, FileFacts> {
162-
const map = new Map<string, FileFacts>();
163-
for (const f of walk(SRC)) {
164-
if (isTest(f) || isPrim(f) || isIcons(f)) continue;
165-
map.set(relative(SRC, f).split(sep).join("/"), factsOf(f));
166-
}
167-
return map;
168-
}
169-
170-
const SCAN = scan();
171-
const HITTING = [...SCAN.entries()].filter(([, v]) => v.hits > 0);
172-
const TOTAL = HITTING.reduce((n, [, v]) => n + v.hits, 0);
17338
const DECLARED = new Set(RESIDUAL.map((r) => r.file));
17439
const FROZEN_HOT = new Set(Object.entries(FROZEN_LOADING_TEXT_BY_FILE).filter(([, n]) => n > 0).map(([k]) => k));
17540

@@ -181,13 +46,21 @@ describe("加载态棘轮(B11):基线随迁移只降不升(冻结 18 →
18146
// 阴性①:文案由**原语的 label** 承载 ⇒ 不算手写占位(迁移的终点形态)
18247
expect(isHandwritten('const a = <Loading label="加载中…" />;'), "原语 label 被误算成手写占位").toBe(false);
18348
expect(isHandwritten('const a = <Loading label={status || "加载预览中…"} />;'), "表达式 label 被误算").toBe(false);
49+
// 阴性①b(**判定收紧的夹具**,收口评审 I-2):`aria-label=` 不是原语的 `label` ⇒ 仍算手写占位
50+
// (`\b` 在 `-` 后成立,原写法会把它当载体 ⇒ 一条 `aria-label` 就能让棘轮对手写文案失明)
51+
expect(isHandwritten('const a = <div aria-label="加载中…">加载中…</div>;'), "`aria-label=` 被误当成原语 label").toBe(true);
18452
// 阴性②:不是加载态的"加载"不算(错误文案 / 加载完成 / 超窗)
18553
expect(isHandwritten("setErr(`加载失败: ${e}`)"), "错误文案被误算成加载态").toBe(false);
18654
expect(isHandwritten("const done = 已加载;")).toBe(false);
18755
expect(isHandwritten("加载一二三四五六七八九十中"), "超窗文案被误算").toBe(false);
18856
// 剥注释:注释里的加载文案不算命中(仓内多处注释含"加载中")
18957
expect(isHandwritten(stripComments("// 加载中…\n")), "行注释里的文案被当成了代码").toBe(false);
19058
expect(isHandwritten(stripComments("/* 加载中… */\n")), "块注释里的文案被当成了代码").toBe(false);
59+
// ★ 剥注释器**判定收紧**的回归夹具(收口评审 I-A1):闭合标签 `</span>` 的 `/` 曾被 `regexStart`
60+
// 判成正则起点 ⇒ 夹在两个闭合标签之间的内容被抹掉(本文件原来逐字重写的那份状态机同样中招)。
61+
// 正侧:闭合标签**之后**的加载文案必须还在;反侧:正则体**含**该文案时必须被抹掉(只剩第二行)。
62+
expect(RE_LOADING_TEXT.test(stripComments('<span>a</span>加载中…<i /></span>\n')), "闭合标签后面的加载文案被剥注释器抹掉了").toBe(true);
63+
expect(RE_LOADING_TEXT.test(stripComments('const r = x < /加载中…/.test(y);\nconst s = "ok";\n')), "真正的正则体没有跳过(`<` 后带空白)").toBe(false);
19164
// 域过滤双侧自证:包含域样本(**余量文件** —— 不在任何并行单元的切片里,读数稳定)+
19265
// 排除域样本(原语/测试文件确实不在扫描面里,且该样本文件真实存在 ⇒ 自证不空转)
19366
expect(HITTING.length, "扫描面里一个命中都没有 ⇒ 下面所有计数判据都会空真").toBeGreaterThan(0);
@@ -229,6 +102,15 @@ describe("加载态棘轮(B11):基线随迁移只降不升(冻结 18 →
229102
for (const r of RESIDUAL) expected[r.file] = 1;
230103
expect(FROZEN_LOADING_TEXT_BY_FILE, "冻结表 ≠ 迁移面(0) ∪ 残留面(1):多余键 / 漏键 / 某格上限被手改").toEqual(expected);
231104
expect(FROZEN_LOADING_TEXT_TOTAL, "冻结总数 ≠ 残留条目数(残留面每条恰 1 处 ⇒ 两者必须相等)").toBe(RESIDUAL.length);
105+
// ★ **迁移面只许增 / 残留面只许减**(收口评审 I-3):上面那条「整表钉死」只挡单点篡改 ——
106+
// 把文件从迁移面**挪进**残留面时 `expected` 会跟着变(实测 `m2b` 全绿)⇒ 要靠这两条独立锚。
107+
expect(MIGRATED_FILES.length, "迁移面被缩小(有人把文件挪进残留面来放行新写的加载文案)")
108+
.toBeGreaterThanOrEqual(FROZEN_MIGRATED_FILES_COUNT);
109+
expect(RESIDUAL.length, "残留条目变多(自助豁免:新写文案 + 自登记一条 backlog 即可通过)")
110+
.toBeLessThanOrEqual(FROZEN_LOADING_RESIDUAL_COUNT);
111+
// 锚不许是僵尸:两条锚各自与实测贴住的方向自证(迁移面非空 ∧ 残留面非空 ⇒ 上面两条不空真)
112+
expect(FROZEN_MIGRATED_FILES_COUNT, "迁移面锚 ≤ 0 ⇒ 上一条空真").toBeGreaterThan(0);
113+
expect(FROZEN_LOADING_RESIDUAL_COUNT, "残留面锚 ≤ 0 ⇒ 残留面被清空(或锚失效)").toBeGreaterThan(0);
232114
});
233115

234116
it("④ 残留声明自洽:每条带合法分类与非空理由,且声明键集 == 冻结表里值 > 0 的键集", () => {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* @ai-context **加载态棘轮的扫描仪器**(收口评审 I-3 的落点:`loadingRatchet.test.ts` 已贴 300 行硬限,
3+
* 加两条锚必须先把仪器析出 —— 与 T13 把仪器析出成 `sliceScan.ts` 同一处置)。
4+
*
5+
* Why 单独成件:本件**不含任何断言、不读基线**,只做「把 `app/src` 变成 `文件 → 五个事实`」。
6+
* 判定逻辑仍只有一份(`isHandwritten` / `factsOf`),测试与仪器自证共用同一实现 ⇒ 不会出现
7+
* 「两套口径各自漂移」。
8+
*
9+
* ★ 剥注释**改用共享仪器** `sliceScan.stripComments`(原来在 `loadingRatchet.test.ts` 里逐字重写
10+
* 了一份):两份状态机各自漂移的风险是真的 —— `sliceScan` 那份刚修掉「`</x>` 的 `/` 被当成
11+
* 正则起点」的假阴(收口评审 **I-1**),重写的那份**同样中招**(本仓 `Loading` 族的扫描域里
12+
* 恰好没有「两个闭合标签之间夹加载文案」的形态,所以没暴露 ⇒ 更该并成一份)。
13+
*
14+
* ★ 扫描口径(与 `tmp/scan-callsites.mjs` / T1 的 loading 类**同源**;口径本身是判据的一部分)
15+
* ① 域 = `app/src/**` 的 `.ts`/`.tsx` 减 `*.test.ts(x)` 减 `ui/primitives/**` 减 `ui/icons/**`;
16+
* ② 先剥注释(`sliceScan` 抹等长空白保行号);字符串/模板只跳过不抹内容 —— 判据读的正是可见文案;
17+
* ③ 行口径 `/加载[^"'`\n]{0,8}(中|…)/`,同行多次命中算 1 行(与 T1 的 18 行读数可比);
18+
* **唯一例外**:文案由原语的 `label` 承载时**不计**(`label="…"` / `label={…}` —— 那是迁移的
19+
* 终点形态,不是手写占位)。冻结时域内 `label=` 载体 0 处 ⇒ 与 T1 读数逐字可比;跨行 label 会
20+
* 被误计,实测本仓 0 例(迁移面 5 处 label 全在同行),已由 ① 的仪器对照钉住。
21+
*
22+
* 副作用:模块加载时**只读**磁盘一次(递归遍历 `app/src`)。边界:文本级仪器、不做 AST。
23+
*/
24+
import { readdirSync, readFileSync, statSync } from "node:fs";
25+
import { dirname, join, relative, sep } from "node:path";
26+
import { fileURLToPath } from "node:url";
27+
import { stripComments } from "./sliceScan";
28+
29+
/** `app/src` —— 基线的键就是相对这个目录的正斜杠路径。 */
30+
const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
31+
32+
/** 可见加载文案(与 T1 的 loading 类同源;`g` 只用于统计,逐行判断用 `test`)。 */
33+
export const RE_LOADING_TEXT = /加载[^"'`\n]{0,8}(中|…)/;
34+
/** 原语 `label` 载体:`label="…"` / `label={'…'}` / `label={…}`(该行不计入手写占位)。 */
35+
export const RE_PRIMITIVE_LABEL = /(?<![\w-])\blabel\s*=(?:\{|["'`])/;
36+
/** 原语用法:`<Loading …` / `<Skeleton …`(`</Loading>` 不算、深导入不算 —— 只认标签)。 */
37+
export const RE_PRIMITIVE_USE = /<(?:Loading|Skeleton)[\s/>]/;
38+
export const RE_SKELETON_USE = /<Skeleton[\s/>]/;
39+
export const RE_LOADING_USE = /<Loading[\s/>]/;
40+
/** `busy` 承载:`busy={loading}` / `busy` 简写。 */
41+
export const RE_BUSY = /\bbusy(?:=\{|[>\s])/;
42+
43+
/** 单行的「手写占位」判定(仪器自证与扫描共用同一实现,防「两套口径」) */
44+
export function isHandwritten(line: string): boolean {
45+
return RE_LOADING_TEXT.test(line) && !RE_PRIMITIVE_LABEL.test(line);
46+
}
47+
48+
/** 剥注释(转发共享仪器;本文件与测试都不再自带一份) */
49+
export { stripComments };
50+
51+
function walk(dir: string, out: string[] = []): string[] {
52+
for (const name of readdirSync(dir)) {
53+
const p = join(dir, name);
54+
if (statSync(p).isDirectory()) walk(p, out);
55+
else if (/\.(ts|tsx)$/.test(name)) out.push(p);
56+
}
57+
return out;
58+
}
59+
60+
export const isTest = (f: string): boolean => /\.test\.(ts|tsx)$/.test(f);
61+
export const isPrim = (f: string): boolean => f.includes(`${sep}ui${sep}primitives${sep}`);
62+
export const isIcons = (f: string): boolean => f.includes(`${sep}ui${sep}icons${sep}`);
63+
64+
export interface FileFacts {
65+
/** 剥注释后文本里可见加载文案的**行数** */
66+
hits: number;
67+
hasPrimitive: boolean;
68+
hasSkeleton: boolean;
69+
hasLoading: boolean;
70+
hasBusy: boolean;
71+
}
72+
73+
/** 扫单个文件的五个事实(供扫描与仪器自证共用) */
74+
export function factsOf(abs: string): FileFacts {
75+
const stripped = stripComments(readFileSync(abs, "utf8"));
76+
const lines = stripped.split("\n");
77+
return {
78+
hits: lines.filter(isHandwritten).length,
79+
hasPrimitive: RE_PRIMITIVE_USE.test(stripped),
80+
hasSkeleton: RE_SKELETON_USE.test(stripped),
81+
hasLoading: RE_LOADING_USE.test(stripped),
82+
hasBusy: RE_BUSY.test(stripped),
83+
};
84+
}
85+
86+
/** 域内逐文件事实(键 = 相对 `app/src` 的正斜杠路径) */
87+
export function scan(): Map<string, FileFacts> {
88+
const map = new Map<string, FileFacts>();
89+
for (const f of walk(SRC)) {
90+
if (isTest(f) || isPrim(f) || isIcons(f)) continue;
91+
map.set(relative(SRC, f).split(sep).join("/"), factsOf(f));
92+
}
93+
return map;
94+
}
95+
96+
/** 扫描结果(模块加载时算一次;`HITTING` = 命中 > 0 的文件,`TOTAL` = 命中行总数) */
97+
export const SCAN: Map<string, FileFacts> = scan();
98+
99+
export const HITTING: [string, FileFacts][] = [...SCAN.entries()].filter(([, v]) => v.hits > 0);
100+
101+
export const TOTAL: number = HITTING.reduce((n, [, v]) => n + v.hits, 0);

0 commit comments

Comments
 (0)