Skip to content

Commit b5c464a

Browse files
committed
refactor(shell): use primitives in shell fallback
1 parent 0148b5e commit b5c464a

2 files changed

Lines changed: 195 additions & 23 deletions

File tree

‎app/src/shell/ShellFallback.tsx‎

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,35 @@
11
/**
22
* @ai-context 壳层的两个失败/等待态(批 2 §瓶颈清单转交批 3 的三条:Task 6 评审 M-1 · Task 7 风险 1 · 未做 #6)。
33
*
4-
* Why 自足内联、**不** import L1 原语层(`Loading` / `ErrorState`):批 3 非目标 2 明令不许 import
5-
* 原语层 —— 那会把 `motion.css` 与整层 CSS 拉回首屏,吃掉批 2 挣来的余量。
6-
* ⇒ 🔴 **本文件是批 4 的迁移点**:`ShellFallback` → `Loading`、失败卡片 → `StatusLine`(批 4 计划
7-
* 必须收编这两个组件,否则永远漏在这里)。
4+
* 批 4 T9:两个态**改为 L1 原语** —— 首访加载态 → `Loading`(`role="status"` + 探针 + 文案)、
5+
* 叶级失败卡片 → `StatusLine kind="error"`(`role="alert"`,`--ed-stamp` 文字色由原语给)。
6+
* 原先那段自足内联 `<div style=…>` **整块删除**:它是批 3 非目标 2(不许 import 原语层)的产物,
7+
* 批 4 起 barrel 是唯一公共入口(ADR-033 §1)⇒ `motion.css` 与原语层 CSS 随之进首屏,
8+
* 代价与机理见 `task-9-report.md` §首屏。本文件因此**不再自带任何排版与色值**(也不写行内 `style`)。
9+
* ⚠️ 文案**一字未改**:那句失败卡是**四处共用**的(PageSlot / overlay / float / dock),批 3 T13
10+
* 评审 Minor-3 刚把它从「可切换页面」改成现在这句(**假陈述修正**的成果),迁移不得回退。
811
*
912
* 覆盖的三条:
1013
* ① `SlotErrorBoundary` 是**叶级**错误边界:它包**一页 / 一个面板**,不是整壳。此前 `PageSlot`
1114
* 之上只有全局 `AppErrorBoundary` ⇒ 懒 chunk 加载失败会卸载**整个 MainShell**
1215
* (已访问页状态、`mountedPages` 保活集合一起丢)。兄弟槽位与壳层状态在本边界之外。
1316
* ② 两个窗口变体(`?float=1` / `?overlay=1`)此前无边界 ⇒ chunk 失败 = 全窗口空白。
14-
* ③ 首访加载态:`Suspense fallback={null}` ⇒ 改用 `ShellFallback`(**静态**、无动效 —— 动效属批 6)。
17+
* ③ 首访加载态:`Suspense fallback={null}` ⇒ 改用 `ShellFallback`。
1518
*
16-
* 副作用:无(`SlotErrorBoundary` 只读 `getDerivedStateFromError`;不发日志、不上报 —— 本地优先)。
17-
* 边界:**不做重试按钮**(重试语义要与 Tauri 的 chunk 缓存一起设计,登记给批 4/8);失败卡片
19+
* 副作用:仅 `Loading` 自带的循环 CSS 动效(`ed-probe-swing`,纯 CSS、不占 JS 主线程)——
20+
* 批 4 的动效纲领(三档强度 / 双基调 / GSAP)**未交付**,此处只是原语自带的接缝:
21+
* **只有接缝、没有纲领**(B9)。
22+
* 边界:**不做重试按钮**(重试语义要与 Tauri 的 chunk 缓存一起设计,登记给批 8);失败卡片
1823
* 只给静态文案,不回显 error 细节(细节仍由最外层 `AppErrorBoundary` 承担)。
24+
* 等价性锚点(T9 逐条核对):`role="status"` / `role="alert"` 由两个原语分别提供;`data-testid`
25+
* `shell-fallback` / `slot-error` 逐字保留;文案逐字保留;失败卡片仍**不是**加载态。
1926
*/
2027
import { Component, type ReactNode } from "react";
28+
import { Loading, StatusLine } from "../ui/primitives";
2129

22-
/** 首访加载态:懒 chunk 到达前的一行静态占位(无动效、无图标 —— 批 3 非目标 4)。 */
30+
/** 首访加载态:懒 chunk 到达前的一行静态占位(文案 + 探针;语义与排版全在原语里)。 */
2331
export function ShellFallback() {
24-
return (
25-
<div
26-
role="status"
27-
data-testid="shell-fallback"
28-
style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 80, padding: 24, fontSize: 12, color: "#6b7280" }}
29-
>
30-
正在载入…
31-
</div>
32-
);
32+
return <Loading label="正在载入…" testId="shell-fallback" />;
3333
}
3434

3535
/**
@@ -46,13 +46,9 @@ export class SlotErrorBoundary extends Component<{ children: ReactNode }, { fail
4646
render() {
4747
if (this.state.failed) {
4848
return (
49-
<div
50-
role="alert"
51-
data-testid="slot-error"
52-
style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 80, padding: 24, fontSize: 12, color: "#b91c1c" }}
53-
>
49+
<StatusLine kind="error" testId="slot-error">
5450
此处加载失败——其余区域仍可使用;重启应用可恢复。
55-
</div>
51+
</StatusLine>
5652
);
5753
}
5854
return this.props.children;
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context `shell/ShellFallback.tsx` 迁入 L1 原语(批 4 T9)的**迁移判据**(五条)。
4+
*
5+
* Why 单独立文件、而不动 `ShellFallback.test.tsx`:那个文件是批 3 的交付面,T9 的纪律是
6+
* **既有断言一字不改**(它 5 条用例在迁移后原样全绿 —— 这是等价性的主证,见 `task-9-report.md`);
7+
* 本文件只补「迁移这件事本身」的判据:自足内联 `<div style=…>` 换成 `Loading` / `StatusLine` 之后,
8+
* **盘上的形态**与**渲染出的形态**各自都要有能失败的判据,否则「迁完了」只是一句话。
9+
*
10+
* 判据(每条各带**自己的**变异体,且每个变异**新解一棵导出树**;实测见 `task-9-report.md` §变异体):
11+
* ① 经 **barrel** 导入原语、无深导入(ADR-033 §1:深导入不带 `motion.css` ⇒ reduced-motion 静默失效);
12+
* ② 文件**零行内 `style`、零颜色字面量**(ADR-033 §4 的调用点侧:迁移后不该再自带排版与色值);
13+
* ③ 渲染级:加载态根节点**就是 `Loading` 的形态**(`.ed-loading` + `.ed-probe` + `role="status"`);
14+
* ④ 渲染级:失败卡片**就是 `StatusLine kind="error"` 的形态**(`.ed-status--error` + `data-kind="error"` + `role="alert"`);
15+
* ⑤ 语义色链:**从渲染出的修饰类反查 `StatusLine.css`** ⇒ 失败卡最终必须是 `var(--ed-stamp)` 文字色。
16+
* `kind` 与色值**都不在测试里硬写**(修饰符取自 DOM、色值取自 CSS,两端各只有一处真源);
17+
* CSS 侧 `.ed-status--error → var(--ed-stamp)` 的既有判据在 `StatusLine.test.tsx:213`,此处不复制。
18+
*
19+
* ⚠️ 底座(照抄勿改):`vitest.config.ts` 全局 `environment: "node"` ⇒ 首行必须 jsdom 指令;
20+
* **未装** `jest-dom` / `user-event` ⇒ 原生 DOM API;无 `globals` ⇒ 显式 `afterEach(cleanup)`。
21+
* 仪器纪律:两条源文本判据**一律先剥注释** —— 本文件头与 `ShellFallback.tsx` 的文件头都**逐字引用**
22+
* 了迁移前的形态(`ShellFallback.tsx` 的头注释里就有 `<div style=…>`),不剥注释时 ② 在自家文件上
23+
* 就是红的;「剥注释承重」由 ② 的自证与 M6 变异体(注释里塞深导入)双向证明。
24+
*/
25+
import { readFileSync } from "node:fs";
26+
import { dirname, join } from "node:path";
27+
import { fileURLToPath } from "node:url";
28+
import { cleanup, render, screen } from "@testing-library/react";
29+
import { afterEach, describe, expect, it, vi } from "vitest";
30+
import { ShellFallback, SlotErrorBoundary } from "./ShellFallback";
31+
32+
const HERE = dirname(fileURLToPath(import.meta.url));
33+
/** 迁移面源码 —— ① ② 的唯一对象 */
34+
const TSX = readFileSync(join(HERE, "ShellFallback.tsx"), "utf8");
35+
/** 语义色的真源(⑤ 反查它;本文件不硬写任何色值) */
36+
const STATUS_CSS = readFileSync(join(HERE, "..", "ui", "primitives", "StatusLine.css"), "utf8");
37+
38+
/** 剥块注释与整行 `//` 注释(与 `ui/primitives/dialogMigration.a1.test.ts:67` 同一口径) */
39+
const strip = (text: string): string => text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, "");
40+
const CODE = strip(TSX);
41+
42+
/** 深导入 = `ui/primitives/<X>`(barrel 之外的入口,ADR-033 §1 禁止) */
43+
const RE_DEEP = /from\s*"(?:\.\.\/)+ui\/primitives\/[A-Za-z]+"/;
44+
/** barrel 导入(名字表在捕获组里,供 ① 断言导入的是哪几个) */
45+
const RE_BARREL = /import\s*\{([^}]*)\}\s*from\s*"(?:\.\.\/)+ui\/primitives"/;
46+
/** 行内 `style=` —— 只认紧跟 `{` 的形态(`style={` / `style={{`) */
47+
const RE_INLINE_STYLE = /\bstyle\s*=\s*\{/;
48+
/** 颜色字面量:#hex / rgb() / rgba() / hsl() / hsla() */
49+
const RE_COLOR = /#[0-9a-fA-F]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\(/;
50+
51+
afterEach(cleanup);
52+
53+
/** 模拟懒 chunk 加载失败(渲染期抛错 ⇒ React 落到最近的上层边界) */
54+
function Boom(): never {
55+
throw new Error("模拟懒 chunk 加载失败");
56+
}
57+
58+
/**
59+
* 触发叶级边界并返回失败卡片根节点。
60+
* 抛错时 React 仍会 `console.error` ⇒ 抑制后还原(与 `ShellFallback.test.tsx:51` 同款)。
61+
*/
62+
function mountFailedCard(): HTMLElement {
63+
function Wrap({ bad }: { bad: boolean }) {
64+
return (
65+
<SlotErrorBoundary>
66+
<span data-testid="child-marker">子内容</span>
67+
{bad ? <Boom /> : null}
68+
</SlotErrorBoundary>
69+
);
70+
}
71+
const { rerender } = render(<Wrap bad={false} />);
72+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
73+
try {
74+
rerender(<Wrap bad />);
75+
} finally {
76+
spy.mockRestore();
77+
}
78+
return screen.getByTestId("slot-error");
79+
}
80+
81+
/** 从类名里取出 `<base>--<kind>` 的 `<kind>`(档名只有 DOM 一处真源,测试不硬写) */
82+
function modifierOf(el: Element, base: string): string | null {
83+
for (const cls of Array.from(el.classList)) {
84+
if (cls.startsWith(`${base}--`)) return cls.slice(base.length + 2);
85+
}
86+
return null;
87+
}
88+
89+
/** 取一条 CSS 规则的原文(到第一个 `}` 为止;本仓规则体内没有花括号) */
90+
function cssRule(css: string, selector: string): string | null {
91+
const at = css.indexOf(selector);
92+
if (at < 0) return null;
93+
return css.slice(at, css.indexOf("}", at) + 1);
94+
}
95+
96+
describe("① 迁移面经 barrel 导入原语(ADR-033 §1)", () => {
97+
it("barrel 名字表含 Loading 与 StatusLine,且无深导入", () => {
98+
expect(RE_BARREL.test(CODE), "ShellFallback 不再从 barrel 导入原语").toBe(true);
99+
const names = (RE_BARREL.exec(CODE)?.[1] ?? "").split(",").map((s) => s.trim());
100+
expect(names, "barrel 名字表里没有 Loading").toContain("Loading");
101+
expect(names, "barrel 名字表里没有 StatusLine").toContain("StatusLine");
102+
expect(CODE.match(RE_DEEP), "出现深导入(ADR-033 §1 禁止:深导入不带 motion.css)").toBeNull();
103+
});
104+
105+
it("仪器自证:RE_BARREL / RE_DEEP 能区分 barrel 与深导入,且对无意义串报 0", () => {
106+
expect(RE_BARREL.test('import { Loading } from "../ui/primitives";')).toBe(true);
107+
expect(RE_DEEP.test('import { Loading } from "../ui/primitives";'), "barrel 被误判成深导入").toBe(false);
108+
expect(RE_DEEP.test('import { Loading } from "../ui/primitives/Loading";')).toBe(true);
109+
expect(RE_DEEP.test('import { Loading } from "../ui/primitivesX";'), "同前缀的另一目录被误判").toBe(false);
110+
expect(RE_BARREL.test("const k = 1;"), "无意义串命中").toBe(false);
111+
});
112+
});
113+
114+
describe("② 迁移面零行内 style / 零颜色字面量(ADR-033 §4 调用点侧)", () => {
115+
it("剥注释后既无行内 style,也无任何颜色字面量", () => {
116+
expect(RE_INLINE_STYLE.test(CODE), "ShellFallback 又出现行内 style").toBe(false);
117+
expect(RE_COLOR.test(CODE), "ShellFallback 又写死了颜色(色值只许在 tokens 侧)").toBe(false);
118+
});
119+
120+
it("仪器自证:剥注释承重 —— 合成样本剥离前必须命中、剥离后必须归零", () => {
121+
const sample = '/* <div style={{ color: "#b91c1c" }} /> */\nconst k = 1;\n';
122+
expect(RE_INLINE_STYLE.test(sample), "剥离前没命中 ⇒ 本判据测不到注释里的形态").toBe(true);
123+
expect(RE_COLOR.test(sample), "剥离前没命中颜色字面量").toBe(true);
124+
expect(RE_INLINE_STYLE.test(strip(sample)), "剥注释没生效(注释里的 style 仍被当代码)").toBe(false);
125+
expect(RE_COLOR.test(strip(sample)), "剥注释没生效(注释里的色值仍被当代码)").toBe(false);
126+
});
127+
});
128+
129+
describe("③ 加载态 = Loading 原语(渲染级)", () => {
130+
it("根节点带 .ed-loading 与探针 .ed-probe,role=status,文案逐字未改", () => {
131+
render(<ShellFallback />);
132+
const el = screen.getByTestId("shell-fallback");
133+
expect(el.classList.contains("ed-loading"), "加载态不是 Loading 原语渲染的(缺 .ed-loading)").toBe(true);
134+
expect(el.querySelector(".ed-probe"), "Loading 的探针没渲染出来 ⇒ 不是原语形态").toBeTruthy();
135+
expect(el.getAttribute("role")).toBe("status");
136+
expect(el.textContent, "加载文案被改动").toBe("正在载入…");
137+
expect(screen.queryByTestId("slot-error"), "加载态不是错误态").toBeNull();
138+
});
139+
});
140+
141+
describe("④ 失败卡片 = StatusLine kind=error(渲染级)", () => {
142+
it("根节点带 .ed-status--error、data-kind=error、role=alert,文案逐字未改", () => {
143+
const card = mountFailedCard();
144+
expect(card.classList.contains("ed-status"), "失败卡片不是 StatusLine 渲染的(缺 .ed-status)").toBe(true);
145+
expect(card.classList.contains("ed-status--error"), "kind 不是 error(危险档被降级)").toBe(true);
146+
expect(card.getAttribute("data-kind"), "机器可读的语义锚点不是 error").toBe("error");
147+
expect(card.getAttribute("role"), "错误档必须即时播报(alert)").toBe("alert");
148+
expect(card.textContent, "失败卡文案被改动(四处共用的措辞已冻结)").toBe(
149+
"此处加载失败——其余区域仍可使用;重启应用可恢复。",
150+
);
151+
expect(screen.queryByTestId("shell-fallback"), "失败态误用了加载态").toBeNull();
152+
expect(screen.queryByTestId("child-marker"), "children 仍挂着 ⇒ 没被边界卸载").toBeNull();
153+
});
154+
});
155+
156+
describe("⑤ 语义色链:渲染出的修饰类 → StatusLine.css 的 var(--ed-stamp)", () => {
157+
it("失败卡的 ed-status--<kind> 在 CSS 里映射到 var(--ed-stamp) 文字色", () => {
158+
const kind = modifierOf(mountFailedCard(), "ed-status") ?? "";
159+
expect(kind, "失败卡片没有 ed-status--<kind> 修饰类(语义色无处落地)").not.toBe("");
160+
const rule = cssRule(STATUS_CSS, `.ed-status--${kind}`);
161+
expect(rule, `StatusLine.css 里没有 .ed-status--${kind} 规则`).not.toBeNull();
162+
expect(rule, `失败卡的语义色不是 var(--ed-stamp)(实得 ${String(rule)})`).toContain("var(--ed-stamp)");
163+
});
164+
165+
it("仪器自证:cssRule / modifierOf 读法能区分四档、对无意义串必须读不到", () => {
166+
expect(cssRule(STATUS_CSS, ".ed-status--warn")).toContain("var(--ed-due)");
167+
expect(cssRule(STATUS_CSS, ".ed-status--ok")).toContain("var(--ed-ok)");
168+
expect(cssRule(STATUS_CSS, ".ed-status--nope"), "无意义修饰符必须读不到(否则 ⑤ 是空真)").toBeNull();
169+
// 修饰类读法:`ed-status` 基类本身**不是**修饰符(前缀要求 `--`),空 classList 读到 null
170+
const probe = document.createElement("div");
171+
probe.className = "ed-status";
172+
expect(modifierOf(probe, "ed-status"), "基类被误当成修饰符").toBeNull();
173+
probe.className = "ed-status ed-status--warn";
174+
expect(modifierOf(probe, "ed-status")).toBe("warn");
175+
});
176+
});

0 commit comments

Comments
 (0)