";
+ assert_eq!(
+ html.matches(from).count(),
+ 1,
+ "找不到 `#chat-modal` 的类属性(名册的锚点漂了)"
+ );
+ html.replace(from, to)
+}
+
+/// 空名册的极端形状:把浮层全部删掉 —— R1 的射程变空,必须**响亮地红**(坑 68)。
+fn r94_variant_no_overlays(html: &str) -> String {
+ let kept: Vec<&str> = html
+ .lines()
+ .filter(|l| {
+ !l.starts_with("
R94Reading {
+ r94_read(INDEX_HTML, app, STYLE_CSS)
+ }
+
+ /// 本轴的派生名册(`ui/index.html` 中 `#app` 之后、带 `hidden` 类的顶行元素)。
+ fn r94_roster() -> BTreeSet {
+ r94_overlays(INDEX_HTML).into_iter().map(|o| o.id).collect()
+ }
+
+ /// 轴:切语言时被刷新的名册必须覆盖**用户此刻可能正开着的每个非模态浮层**(R94)。
+ ///
+ /// 三条规则各自的含义见文件头。本测试只断言「三条同时成立」;每条规则的**牙**由
+ /// [`the_r94_rules_have_teeth`] 逐条测量,与竞争修法的**关系**由
+ /// [`the_r94_rules_separate_the_variants`] 逐腿声明。
+ #[test]
+ fn the_language_switch_refreshes_every_overlay_it_can_show() {
+ let read = r94_live(APP_JS);
+ assert!(
+ read.handler_found,
+ "取不到 `{R94_HANDLER_START}` 的处理器体(开头/收尾定界不唯一)—— 三条规则的射程会静默变空:{}",
+ read.report()
+ );
+ assert!(
+ read.scope.len() >= 2,
+ "射程里的非模态浮层少于两个 —— R1 退化成空集上的关系式:{}",
+ read.report()
+ );
+ assert!(
+ !read.modal.is_empty() && !read.non_modal.is_empty(),
+ "派生名册里模态/非模态有一边是空的 —— R3 的分界恒真,规则失去意义:{}",
+ read.report()
+ );
+ assert!(
+ read.refreshes.len() >= R94_MIN_REFRESHES,
+ "处理器派生出来的 `render*` 刷新名少于 {R94_MIN_REFRESHES} 个 —— R2 的下界不成立:{}",
+ read.report()
+ );
+ assert!(
+ read.verdicts() == (true, true, true),
+ "切语言必须刷新用户可能正开着的每个非模态浮层(R94):{}",
+ read.report()
+ );
+ }
+
+ /// 阳性对照:派生器/写者名册/读取器在**已知为绿**的树上读到东西,在「什么都没有」的树上读到空。
+ ///
+ /// 这是三条规则的射程保险:读取器若只会返回常量,规则就成了恒真断言(空集上的关系式会静默
+ /// 通过)。⚠️ 绝对判词只打在自己拼出来的树上 —— 真树在两条腿上形状不同(#314)。
+ #[test]
+ fn the_r94_roster_is_real() {
+ // ① 名册的阳性对照(解析器变了 / index.html 结构变了都会在这里响)。
+ let roster = r94_roster();
+ assert!(
+ roster.contains("help-panel")
+ && roster.contains("chat-modal")
+ && roster.contains("tour-overlay"),
+ "派生名册少了浮层(解析器坏了,还是 index.html 结构变了?):{roster:?}"
+ );
+ assert!(
+ !roster.contains("app") && !roster.contains("login-view") && !roster.contains("toast-wrap"),
+ "派生名册混进了射程外的元素(`#app` 是起点,实例由 JS 即时生成的 `#toast-wrap` 不是浮层):{roster:?}"
+ );
+
+ // ② 模态判据的阳性对照(R3 的分界真的能分开两类,而不是恒真/恒假)。
+ let modal: BTreeSet = roster
+ .iter()
+ .filter(|id| {
+ let classes = r94_overlays(INDEX_HTML)
+ .into_iter()
+ .find(|o| o.id == **id)
+ .map(|o| o.classes)
+ .unwrap_or_default();
+ r94_is_modal(STYLE_CSS, &classes)
+ })
+ .cloned()
+ .collect();
+ assert!(
+ modal.contains("chat-modal") && !modal.contains("help-panel"),
+ "模态判据没有把 `#chat-modal`(`.modal-overlay` 有 `inset: 0`)与 `#help-panel`(无)分开:{modal:?}"
+ );
+
+ // ③ 写者名册的阳性对照:`#help-panel` 的 JS 写者**恰好**是 `renderHelp`(派生出来的)。
+ let live = r94_live(APP_JS);
+ assert_eq!(
+ live.writers.get("help-panel"),
+ Some(
+ &[String::from("renderHelp")]
+ .into_iter()
+ .collect::>()
+ ),
+ "`#help-panel` 的写者名册不是恰好 `renderHelp` —— 写者判别式变了:{}",
+ live.report()
+ );
+
+ // ④ 修复树上三条规则全绿,且证据全都读到。
+ let fixed_tree = r94_variant_fix(APP_JS);
+ let fix = r94_live(&fixed_tree);
+ assert!(
+ fix.verdicts() == (true, true, true),
+ "修复树上三条规则不是全绿 —— 变体构造器坏了:{}",
+ fix.report()
+ );
+ assert!(
+ fix.missing.is_empty() && !fix.refreshes.is_empty(),
+ "修复树上 R1 的输入或证据是空的 —— 那条规则会恒真:{}",
+ fix.report()
+ );
+
+ // ⑤ 反向:未修形状上缺的必须**恰好**是帮助面板(派生出来的,不是抄的)。
+ let gone = r94_live(&r94_variant_unfixed(APP_JS));
+ assert_eq!(
+ gone.missing,
+ [String::from("help-panel/renderHelp")]
+ .into_iter()
+ .collect::>(),
+ "未修形状上「没被刷新的浮层」不是恰好帮助面板 —— R1 的判别式变了:{}",
+ gone.report()
+ );
+ assert!(
+ !gone.r1 && gone.r2 && gone.r3,
+ "未修形状只该翻 R1 一条:{}",
+ gone.report()
+ );
+
+ // ⑥ 空名册的极端形状:射程变空时必须**响亮地红**,不许在空集上通过(坑 68)。
+ let empty = r94_read(&r94_variant_no_overlays(INDEX_HTML), APP_JS, STYLE_CSS);
+ assert!(
+ empty.overlays.is_empty() && !empty.r1,
+ "浮层全删掉之后 R1 仍然绿 —— 覆盖率规则在空集上会「通过」:{}",
+ empty.report()
+ );
+ }
+
+ /// 三条规则**各有独立的牙**:合成变异体逐个喂给规则自己的判别式,每个恰好打翻一条。
+ ///
+ /// 判据是「恰好一条翻转」而不是「至少一条红」—— 否则一条从别处借来红的规则也能自称有牙
+ /// (#454:牙齿必须长在该规则的判别式上)。基线是**已知为绿的**修复树(#458)。
+ #[test]
+ fn the_r94_rules_have_teeth() {
+ let fixed_tree = r94_variant_fix(APP_JS);
+ let base = r94_live(&fixed_tree);
+ assert_eq!(
+ base.verdicts(),
+ (true, true, true),
+ "自证基线不绿,牙齿测试没有意义:{}",
+ base.report()
+ );
+
+ // 每个变异体只动一处,期望**恰好一条**翻转(#454)。顺序 = (r1, r2, r3)。
+ let unfixed = r94_variant_unfixed(&fixed_tree);
+ let trimmed = r94_variant_minimal_roster(&fixed_tree);
+ let new_overlay_tree = r94_variant_new_writer(&r94_variant_fix(APP_JS));
+ let mutants: [(&str, R94Reading, (bool, bool, bool)); 4] = [
+ ("guard gone", r94_live(&unfixed), (false, true, true)),
+ (
+ "the refresh roster is trimmed to the writers only",
+ r94_live(&trimmed),
+ (true, false, true),
+ ),
+ (
+ "a future overlay's writer is not registered",
+ r94_read(
+ &r94_variant_new_overlay(INDEX_HTML),
+ &new_overlay_tree,
+ STYLE_CSS,
+ ),
+ (false, true, true),
+ ),
+ (
+ "no modal overlay left in the roster",
+ r94_read(&r94_variant_no_modal(INDEX_HTML), &fixed_tree, STYLE_CSS),
+ (true, true, false),
+ ),
+ ];
+ for (label, read, expected) in mutants {
+ assert_eq!(
+ read.verdicts(),
+ expected,
+ "规则 `{label}` 的牙不成立(期望 {expected:?}):{}",
+ read.report()
+ );
+ }
+
+ // R1 的第二半:新浮层的缺项必须是**它自己**(证明规则不是只认 `help-panel` 一个元素)。
+ let new_overlay = r94_read(
+ &r94_variant_new_overlay(INDEX_HTML),
+ &new_overlay_tree,
+ STYLE_CSS,
+ );
+ assert_eq!(
+ new_overlay.missing,
+ [String::from("new-panel/renderNewPanel")]
+ .into_iter()
+ .collect::>(),
+ "新浮层的缺项不是它自己 —— 覆盖率的判别式变了:{}",
+ new_overlay.report()
+ );
+
+ // R2 的第二半:**刷新的名字少于下界**也必须响亮失败(不许把「名册越删越干净」当成功)。
+ let numpty = r94_live(&r94_variant_minimal_roster(&fixed_tree));
+ assert!(
+ !numpty.r2 && numpty.refreshes.len() < R94_MIN_REFRESHES,
+ "刷新名册被削到最小集后 R2 仍绿 —— 那条规则可以静默失明:{}",
+ numpty.report()
+ );
+ assert!(
+ numpty.r1 && numpty.r3,
+ "削名册只该翻 R2 一条:{}",
+ numpty.report()
+ );
+
+ // 读取器本身的牙(合成输入:不碰真文件)—— 「提不到 id」和「提到了但没写」必须都不算写者。
+ assert!(r94_mentions(
+ "$(\"#help-body\").innerHTML = T(\"a\");",
+ "help-body"
+ ));
+ assert!(!r94_mentions(
+ "$(\"#help-bodyx\").innerHTML = \"x\";",
+ "help-body"
+ ));
+ assert!(!r94_mentions(
+ "$(\"#help-body\").innerHTML = \"x\";",
+ "help-context"
+ ));
+ assert!(r94_calls("if (a) renderHelp();").contains("renderHelp"));
+ assert!(!r94_calls("const x = o.renderHelp;").contains("renderHelp"));
+ }
+
+ /// 规则与**竞争修法**的关系,逐腿声明(#339/#341:声明的期望与实际各印一列)。
+ ///
+ /// 竞争修法来自 jsdom 探针 `r94_probe.js`(它按 DOM 实况逐条裁定):
+ /// - `m_close`:切线时把用户自己开的面板**关掉**(「屏幕上没有面板,就没有陈旧的行」)。
+ /// 本门禁**也**拒它 —— 但理由是「写者 `renderHelp` 没被调用」(R1 问的是刷新了没),而探针的
+ /// `A4` 腿拒它的理由是「用户的面板被夺走」。**同判不同因**:这条边界实测在这里,不是只写在注释里
+ /// (C2148 的分工:两个仪器各自能看见对方看不见的东西)。
+ /// - `m_minimal_roster`:把刷新名册削到只留浮层写者(R1 仍绿、R2 收口)。
+ ///
+ /// 探针另有一条 `m_datai18n`(把四行快捷键改写成静态 `data-i18n`),它要同时改写 `index.html` 与
+ /// `app.js` 两侧,本门禁的构造器不做它 —— 它的判官是探针的 `A2`/`Z1` 腿。
+ #[test]
+ fn the_r94_rules_separate_the_variants() {
+ // ⚠️ 本测试必须在**两腿**都绿(编译门禁分别把真树与 E1 修复树当作 `APP_JS` 来编译)
+ // ⇒ 绝对判词只能打在**它自己拼出来的树**上(#314),而每棵变体树都必须从**同一个**起点拼
+ // (`r94_variant_fix(APP_JS)` 幂等 ⇒ 两条腿上逐字相同,#612);从 `APP_JS` 直接拼会在 base 腿上
+ // 拼出另一棵树(那条腿的 `APP_JS` 就是未修形状),声明的判词当场失真。
+ let all_green = (true, true, true);
+
+ let tree_fix = r94_variant_fix(APP_JS);
+ let tree_unfixed = r94_variant_unfixed(&tree_fix);
+ let tree_close = r94_variant_close(&tree_unfixed);
+ let tree_trimmed = r94_variant_minimal_roster(&tree_fix);
+
+ // 先证明这些树互不相同,否则「判词不同」可能只是同一棵树的两张脸。
+ for (label, other) in [
+ ("unfixed", &tree_unfixed),
+ ("m_close", &tree_close),
+ ("m_minimal_roster", &tree_trimmed),
+ ] {
+ assert_ne!(tree_fix, *other, "`{label}` 与修复树逐字相同 —— 变体没落地");
+ }
+
+ let declared = [
+ ("fix (spliced)", tree_fix.as_str(), all_green),
+ (
+ "unfixed (the defect)",
+ tree_unfixed.as_str(),
+ (false, true, true),
+ ),
+ (
+ "m_close (probe rejects on UX, gate rejects on the writer)",
+ tree_close.as_str(),
+ (false, true, true),
+ ),
+ (
+ "m_minimal_roster",
+ tree_trimmed.as_str(),
+ (true, false, true),
+ ),
+ ];
+ for (name, app, expected) in declared {
+ let read = r94_live(app);
+ assert_eq!(
+ read.verdicts(),
+ expected,
+ "变体 `{name}` 的判词与声明不符(声明 {expected:?}):{}",
+ read.report()
+ );
+ }
+
+ // `m_close` 那条边界要可测量:它**不是**修复体(逐字不同),却与**未修形状**缺同一项
+ // (`help-panel/renderHelp`)—— 因为「把面板关掉」并没有让写者被调用。两个仪器在这一点上
+ // 同判(都拒)、不同因,这里钉的就是那个「因」。
+ assert_ne!(
+ tree_close, tree_fix,
+ "`m_close` 与修复体逐字相同 —— 竞争修法没落地"
+ );
+ assert_eq!(
+ r94_live(&tree_close).missing,
+ r94_live(&tree_unfixed).missing,
+ "`m_close` 的缺项与未修形状不同 —— 「关掉面板 ≠ 刷新面板」这条判据变了"
+ );
+ }
+
+ /// 修复体常量与插入锚点的自洽(跨制品那一腿在 `r94_compile_gate.py` 里,真值取自编辑表)。
+ #[test]
+ fn the_r94_fixed_guard_is_the_edit_sheet_text() {
+ assert!(
+ !R94_FIXED_GUARD.contains(R94_MODAL_RULE)
+ && R94_FIXED_GUARD.contains("classList.contains(\"hidden\")"),
+ "修复体不是「读 class 机制」那一行:{R94_FIXED_GUARD:?}"
+ );
+ assert!(
+ R94_FIXED_GUARD.contains("renderHelp();") && R94_FIXED_GUARD.contains("#help-panel"),
+ "修复体不是「帮助面板开着就重画」那一行:{R94_FIXED_GUARD:?}"
+ );
+ assert!(
+ R94_TOUR_GUARD.contains("renderTourStep();") && !R94_TOUR_GUARD.contains("help-panel"),
+ "插入锚点不是引导守卫行:{R94_TOUR_GUARD:?}"
+ );
+ // 幂等:两腿拿到的必须是**同一棵**修复树(否则声明表在另一条腿上就不成立了,#612)。
+ let once = r94_variant_fix(APP_JS);
+ let twice = r94_variant_fix(&once);
+ assert_eq!(once, twice, "修复体构造器不幂等 —— 两腿会得到不同的树");
+ assert_eq!(
+ once.matches(R94_FIXED_GUARD).count(),
+ 1,
+ "修复树里那一行不是恰好一个"
+ );
+ assert_ne!(
+ r94_variant_unfixed(APP_JS),
+ once,
+ "未修形状与修复树逐字相同 —— 变体构造器失效了"
+ );
+ assert_ne!(
+ R94_CLOSE_GUARD, R94_FIXED_GUARD,
+ "关面板式竞争修法与修复体逐字相同 —— 竞争修法没落地"
+ );
+ }
+
/// R93 的**基线树** = 位点的未修形状。
///
/// 两条腿(编译门禁的**真树**与 **E1 修复树**)都先归一到它,变体测试才是在**同一棵树**上做的
diff --git a/ui/README.md b/ui/README.md
index d19a068..66b4035 100644
--- a/ui/README.md
+++ b/ui/README.md
@@ -345,7 +345,8 @@ ui/
## 界面国际化 i18n 约定(v1.21.1,rant 2026-08-18T20:49:22 + 21:40:10 去中英混排)
- **语言包**:`ui/js/i18n.js` 零依赖 IIFE,`I18N = { zh, en }` 双词典(806 键 ×2,覆盖导航/登录/视图标题/通用/仪表盘/市场/共享/钱包/交易/设置/管理/运营/聊天/游客/相对时间/帮助/tour/主题/错误映射);`window.t(key, vars)` 查当前语言,**缺失回退 zh,再缺回退 key 本身**;`{var}` 占位符插值;
-- **切换机制**:设置页「偏好 → 界面语言」下拉(`#prefs-lang`,zh/en)→ `I18n.setLang()`:写 `localStorage('atp_lang')` + `document.documentElement.lang` 同步(zh→`zh-CN` / en→`en`)+ 派发 `atp:langchange` → app.js 重渲染 `renderNav()` + `renderView(activeView)` + `document.title`(引导中额外 `renderTourStep()`);**首载**:localStorage → `navigator.language` 前缀(`zh*`→zh,否则 en)→ 默认 zh;切换即时生效无需刷新;
+- **切换机制**:设置页「偏好 → 界面语言」下拉(`#prefs-lang`,zh/en)→ `I18n.setLang()`:写 `localStorage('atp_lang')` + `document.documentElement.lang` 同步(zh→`zh-CN` / en→`en`)+ 派发 `atp:langchange` → app.js 重渲染 `renderNav()` + `renderView(activeView)` + `document.title`(引导中额外 `renderTourStep()`;帮助面板开着时 `renderHelp()`);**首载**:localStorage → `navigator.language` 前缀(`zh*`→zh,否则 en)→ 默认 zh;切换即时生效无需刷新;
+- **开着的东西都要跟着走(R94)**:切语言时刷新的是**用户此刻可能正开着的**每一块 —— 导航、数据表表头、当前视图、标题、引导、以及**非模态的帮助面板**。`#help-panel` 的标题与关闭按钮是 `[data-i18n]`(`applyStatic()` 换掉),四行快捷键与 `#help-context` 却是 `renderHelp()` 用 JS 建的、**没有钩子**,而它全仓唯一调用点是 `toggleHelp` 的**打开**分支 ⇒ 漏刷新就是「同一块面板两种语言」,且整会话不自愈。**门禁** `state_gate::the_language_switch_refreshes_every_overlay_it_can_show` 从两处**推导**:浮层名册取自 `ui/index.html`(`#app` 之后的顶行元素带 `hidden` 类);模态与否取自 `ui/css/style.css` 的类规则里有没有 `inset: 0`(模态浮层的全屏遮罩挡住了设置页的语言下拉 ⇒ 用户不可能在它开着时切语言,故不入射程);写者名册取自「函数体里同时出现该浮层的某个 id 与 `T(`」⇒ **新增浮层或新增写者忘了登记会变红**。**射程**:门禁是**词法**的 —— 它证明刷新名册覆盖了派生出来的每个非模态浮层的每个写者,**不**证明屏幕上那一刻的文案真的是当前语言(那一半归 jsdom 探针),也**看不见**「切语言时干脆把面板关掉」这种竞争修法(探针 `A4` 腿把它拒掉)。
- **静态文案**:`index.html` 内静态中文用 `data-i18n` / `data-i18n-ph`(placeholder)/ `data-i18n-title`(title)标记,`applyStatic()` 启动时与每次切换时批量替换;**容器含表单控件的 `