Files
MailUI4Agents/client/electron/test/harmony-admin.test.mjs
JianFeeeee 4af083e8b8 test(判据): 4 个判据读的是**另一棵树** —— 修掉硬编码的仓库根,并让 stripComments 保持行号
pi 2026-09-15 实测出来的,**这次长在判据自己身上** —— 正是我们前几轮一直在消的那个形状。

## 一、`const ROOT = '/home/program/agentmail'`:规则进来了,对象没进来

`harmony-arkts.test.mjs` 把仓库根写成了绝对路径。后果我按 pi 的步骤亲手复现了:

```
$ git worktree add --detach /tmp/wt-verify 7f4fa26      # 那个检出里 import 顺序**确实**违规
  (核对:最后 import 在第 80 行,而第 63 行已是 `const NAV_MATERIAL_OF…`)
$ cd /tmp/wt-verify/client/electron && node --test test/harmony-arkts.test.mjs
ok 1 / ok 2 / ok 3   # pass 3  # fail 0      ← **在一个明显违规的检出上 3/3 全绿**
```

因为它读的不是 `/tmp/wt-verify`,是 `/home/program/agentmail`(那份早已修好)。
两层后果,第二层最糟:
① 它**永远无法验证任何别的 checkout / CI / 镜像** —— 换目录不是"红",是 `readdirSync` 直接抛;
② 在本机做 worktree 复核时,它**静默读另一棵树并报绿**。
**判据的逻辑是对的、对象是错的** —— 这比"判据写错了"更难发现,因为它在原地永远是绿的。

同一个毛病在 4 个文件里,**恰好全是最近这几笔新写的**(另 10 个鸿蒙判据写法是对的):

```
harmony-admin / harmony-imageprep / harmony-presets / harmony-arkts  →  const ROOT = '/home/program/agentmail';
其余 10 个                                                          →  const ROOT = join(HERE, '..', '..', '..');
```

已全部照邻居改掉。**修好之后在同一个违规检出上:`# fail 1`** —— 它终于会红了。

## 二、修这条时又牵出一个:`stripComments` **改变了行号**

修好路径后,判据报出"最后一个 import 在第 64 行、第 47 行已是语句",
而**真实文件里是第 80 / 63 行**。成因:`stripComments` 把块注释整块抹成 `''`,
而块注释**自带换行** ⇒ 它之后所有行号整体前移。

这不是小节:全仓判据都用 `文件:行号` 定位(`grep -n`、编辑器跳转、`git show` 核对),
**报出来的行号必须能直接用**,否则读者第一步得先猜"这是剥过的还是没剥的"。
改成"块注释里的每个换行换成等量空行"。修完报的就是 **80 / 63**,与文件逐字对上。

## 三、新增两条判据,让这两个形状不能再回来

1. **`★ 判据不许把仓库根硬编码成绝对路径`** —— 扫判据目录里**真代码**
   (`code()` 剥注释,否则本文件自己的说明文字就会误报),找
   `const X = '/绝对路径'` 且**看着像仓库内**的声明。
   **例外按名字放行**(含 `TOOLCHAIN`/`SDK`/`HDC` 的常量)—— 工具链本来就不在仓库里、推不出来;
   按**值**做白名单会逼着下一个人为了过判据去改那个路径的写法。
2. **`★ stripComments 必须保持行号`** —— 造含多行块注释的样本,断言剥完
   **行数不变**、且第 N 行仍是原来的第 N 行;**同时**断言注释内容确实被去掉了
   (别为了保行号把注释留下)。

两条都做了**变异验证**:
- 把 `harmony-admin` 的 ROOT 改回硬编码 ⇒ 新判据**红**,并点名那个文件;还原后绿。
- 在 `MainPage.ets` **import 之前**插一条语句 ⇒ `harmony-arkts` **红**
  (第 79 行 vs 第 1 行);还原后绿。**这条同时证明了"读的是自己那棵树"** ——
  同样这个变异,在修路径**之前**是绿的。

## 四、未做 / 未验

- 到期闸门那 7 条**没动**(要真装真点,是另一件活)。
- **"把 build 做成一条判据"我探了,两个硬障碍**(详见给 pi 的回信):
  ① `client/harmony/oh_modules` 被 `.gitignore` 排除且未入库 ⇒ **全新检出没有它**,
     构建会先死在装依赖上;② 本沙箱**拒写 `/root/.hvigor`**(`mkdir` Permission denied),
     `hvigorw` 在 worktree 里直接 `EACCES: mkdir '/root/.hvigor/project_caches/…'`。
  所以它在本仓能编过、在干净检出编不过 —— 作为判据它现在会**假红**。
2026-09-15 12:06:15 +08:00

323 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 管理页(用户管理)的判据 —— **行为**判据(纯逻辑真跑)+ **形态**判据(`.ets` 只读源码)。
*
* ── 为什么值得有 ──
*
* 这一页是本次「功能做全」新加的那一块,而它**本机跑不起来**(无设备/无模拟器)。
* 所以能钉住的每一条都要钉住,并且**分清哪一条是哪种**:
*
* · **真跑**(`model/AdminUsers.ts`,无 `@ohos` 依赖 ⇒ node strip-types 直接执行):
* 勾选、最后登录文案、角色判定、受限徽标、异常兜底文案。
* · **读源码**(`pages/AdminUsersPage.ets` / `pages/SettingsPage.ets` /
* `api/AdminApi.ets` / `main_pages.json`):路由注册、服务端调用都对应上、
* 没有写死色值、列表项每项一张卡、服务端文案不被吞。
* 这类**只能证明"代码里是这么写的"**,证不了"真机上长这样" —— 见文件末的未验清单。
*
* ── 判代码一律用 `code()`(剥注释)──
*
* 本文件里判的标识符(`listUsers`/`#` 色值/`radiusCard`…)在解释性注释里大量出现,
* 用原文读会产生假绿。这是 `lib/read.mjs` 存在的理由,也是我踩过两次的坑。
*/
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { code, prose } from './lib/read.mjs';
/*
* ★ 仓库根必须**从本文件的位置推**,不许硬编码绝对路径。
*
* 原来这里写的是 `const ROOT = '/home/program/agentmail';` —— pi 2026-09-15 实测出后果:
* 把带违规的提交检出到**别的目录**再跑,判据**读的仍是 `/home/program/agentmail`**,
* 于是"在一个 import 顺序明显违规的检出上 3/3 全绿"。
* 两层后果,第二层最糟:
* ① 它**永远无法验证任何别的 checkout / CI / 镜像**(换目录不是"红",是 readdirSync 直接抛);
* ② 在本机做 worktree 复核时,它会**静默读另一棵树并报绿** —— 正是我们这几轮在消的形状,
* 这次长在判据自己身上。**"规则进来了,对象没进来"**。
* 修法照邻居(10 个鸿蒙判据都是 `join(HERE, '..', '..', '..')`)。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
const ADMIN_TS = join(ETS, 'model/AdminUsers.ts');
const ADMIN_PAGE = join(ETS, 'pages/AdminUsersPage.ets');
const SETTINGS_PAGE = join(ETS, 'pages/SettingsPage.ets');
const ADMIN_API = join(ETS, 'api/AdminApi.ets');
const PAGES_JSON = join(ROOT, 'client/harmony/entry/src/main/resources/base/profile/main_pages.json');
// 真跑纯逻辑(有 `@ohos` 依赖的文件不能这样跑;AdminUsers.ts 刻意零 import)
const A = await import(pathToFileURL(ADMIN_TS).href);
/* ────────────────────── ① 真跑:勾选白名单 ────────────────────── */
test('勾选:加进去、再去掉,而且**不就地改入参**', () => {
const before = ['a', 'b'];
const added = A.toggled(before, 'c');
assert.deepEqual(added, ['a', 'b', 'c'], '没勾上的项要加进去');
assert.deepEqual(before, ['a', 'b'],
'★ 入参被就地改了:@State 靠**引用变化**触发重渲染,就地 push/splice 改同一个数组不会刷新界面,' +
'表现是"点了没反应"而数据其实已改;而且入参可能是另一个 @State 的当前值,就地改会让两处互相污染');
const removed = A.toggled(added, 'a');
assert.deepEqual(removed, ['b', 'c'], '已勾上的项再点要取消');
assert.notEqual(added, removed, '★ 必须返回**新数组**(同一个引用不会被 @State 认成变化)');
});
test('勾选:边界(空表、重复项只当一次、原顺序不被打乱)', () => {
assert.deepEqual(A.toggled([], 'x'), ['x'], '空表勾第一个');
assert.deepEqual(A.toggled(['x'], 'x'), [], '唯一项取消后是空表(不是 null/undefined)');
assert.deepEqual(A.toggled(['b', 'a'], 'c'), ['b', 'a', 'c'],
'追加在末尾(不重排既有项:列表顺序变了会让用户以为选项跳了)');
});
/* ────────────────────── ② 真跑:最后登录文案 ────────────────────── */
test('最后登录:**缺席**与**空串**都显示"从未登录"(服务端是 omitempty)', () => {
assert.equal(A.lastLoginLabel({ last_login: undefined }), '从未登录', '字段缺席 = 从未登录');
assert.equal(A.lastLoginLabel({}), '从未登录', '整个字段不存在也一样');
assert.equal(A.lastLoginLabel({ last_login: '' }), '从未登录',
'★ 空串也要当"从未登录":只判 undefined 会让空串在界面上留一块空白,看起来像"读取失败"');
assert.equal(A.lastLoginLabel({ last_login: '2026-09-15 07:02' }), '2026-09-15 07:02',
'有值就原样显示(不在这里改格式:服务端给的就是要显示的那个串)');
});
/* ────────────────────── ③ 真跑:角色判定 ────────────────────── */
test('角色判定:**严格等于 admin**(口径与 WebUI 的 user?.role === "admin" 一致)', () => {
assert.equal(A.isAdminRole('admin'), true);
assert.equal(A.isAdminRole('user'), false);
assert.equal(A.isAdminRole(''), false);
assert.equal(A.isAdminRole(undefined), false,
'★ 读不到 role 一律 false:乐观放行会让任何一次 /me 失败都变成"对所有人显示管理入口",点进去一片 403');
for (const loose of ['Admin', 'ADMIN', 'admin ', ' admin', 'admin\n', 'superadmin', 'admin2']) {
assert.equal(A.isAdminRole(loose), false,
`★ ${JSON.stringify(loose)} 不该放行:只有严格相等才与 WebUI 同口径(宽松匹配会让某天服务端改大小写时两端行为分叉)`);
}
});
/* ────────────────────── ④ 真跑:受限徽标 ────────────────────── */
test('受限徽标:普通用户且白名单非空才显示(空 = 不限,不是"什么都不许")', () => {
assert.equal(A.isRestricted({ role: 'user', allowed_agents: ['pimail'], allowed_paths: [] }), true);
assert.equal(A.isRestricted({ role: 'user', allowed_agents: [], allowed_paths: ['/tmp'] }), true);
assert.equal(A.isRestricted({ role: 'user', allowed_agents: [], allowed_paths: [] }), false,
'★ 全空不叫受限:空 = 不限,打上「受限」会让用户以为自己什么都点不动');
assert.equal(A.isRestricted({ role: 'admin', allowed_agents: ['pimail'], allowed_paths: ['/tmp'] }), false,
'★ 管理员一律 false:服务端对管理员**忽略**这两项,给他打「受限」是误导');
});
/* ────────────────────── ⑤ 真跑:服务端文案不被吞 ────────────────────── */
test('服务端文案原样透出(400/409 的中文文案是唯一能让人立刻改的东西)', () => {
const cases = [
'该名称已被用户或 Agent 占用',
'密码至少 8 位',
'用户名只能包含小写字母、数字、点、下划线和连字符',
'系统至少需要保留一个可用管理员',
'用户不存在'
];
for (const msg of cases) {
const out = A.messageOfApiError(true, msg);
assert.ok(out.includes(msg),
`★ 服务端文案被改写了:「${msg}」→「${out}」。管理页的失败原因几乎都是"人能立刻改的东西",吞掉只剩反复试`);
assert.equal(out, msg, '不做任何包装:原样显示');
}
assert.ok(A.messageOfApiError(true, '').length > 0, '服务端没给文案时也要有话说');
assert.ok(A.messageOfApiError(false, 'Network unreachable').includes('Network unreachable'),
'本地异常(网络层)的 message 也要给用户看');
assert.ok(A.messageOfApiError(false, '').length > 0, '什么都没有时给兜底句');
});
test('兜底句要说清"服务端没给原因"(否则用户分不清"服务端说不行"和"客户端没收到")', () => {
/*
* ★ 这条判据第一版写的是 `/没(有)?给|未知|失败/`,**红不了** —— 变异测试抓出来的:
* 把兜底句改成光秃秃的「操作失败」,它照样匹配上了(`失败` 这个分支太宽)。
* ⇒ 收窄成"必须出现**原因缺席**这件事",而不是"出现了某个失败词"。
*/
const fallback = A.messageOfApiError(false, '');
assert.ok(/没(有)?(给|提供)原因|未(给|提供)原因|无原因/.test(fallback),
`★ 兜底句「${fallback}」只说了"失败",没说**原因缺席** —— 用户会把它当成服务端的拒绝理由,` +
'于是反复重试同一个注定失败的动作');
});
/* ────────────────────── ⑥ 形态:路由与入口 ────────────────────── */
test('管理页**注册成路由**(没注册的 @Entry 页 pushUrl 会失败)', () => {
const pages = JSON.parse(prose(PAGES_JSON));
assert.ok(Array.isArray(pages.src), 'main_pages.json 要有 src 数组');
assert.ok(pages.src.includes('pages/AdminUsersPage'),
`★ 管理页不在 main_pages.json 里(实际 ${JSON.stringify(pages.src)})⇒ pushUrl({url:'pages/AdminUsersPage'}) 起不来`);
assert.equal(new Set(pages.src).size, pages.src.length, '清单里不该有重复项');
});
test('设置页的管理入口:仅管理员可见、点进管理页', () => {
const src = code(SETTINGS_PAGE);
assert.ok(/if \(this\.isAdmin\)/.test(src),
'★ 管理入口没有 isAdmin 门禁 ⇒ 每个普通用户都会看到一个点进去 403 的入口');
assert.ok(/pushUrl\(\{ url: 'pages\/AdminUsersPage' \}\)/.test(src),
'入口要真的推到管理页(推一个别的地方 = 用户点了看不到管理面)');
assert.ok(/isAdminRole\(/.test(src), '★ 门禁要用共用的 isAdminRole,而不是就地写 === "admin"(两处口径会漂移)');
assert.ok(/loadRole\(\)/.test(src), '要真去读一次身份');
});
test('身份读不到时**不**乐观显示入口(isAdmin 初值 false,失败也保持 false)', () => {
const src = code(SETTINGS_PAGE);
assert.ok(/@State isAdmin: boolean = false/.test(src), 'isAdmin 初值必须是 false');
const catchBlock = src.slice(src.indexOf('async loadRole()'));
const body = catchBlock.slice(catchBlock.indexOf('catch'), catchBlock.indexOf('catch') + 200);
assert.ok(/this\.isAdmin = false/.test(body),
`★ loadRole 的 catch 里没有把 isAdmin 置 false;读一次片段:${body.slice(0, 120)}`);
});
/* ────────────────────── ⑦ 形态:动作 ↔ 服务端调用 ────────────────────── */
test('管理页的每个动作都落到真实的服务端调用(不是只画了个按钮)', () => {
const page = code(ADMIN_PAGE);
// 「用户真正会点的那一层」:按钮的 onClick 要能走到这些调用
const required = ['listUsers', 'listScopes', 'createUser', 'updateUser', 'disableUser', 'resetPassword'];
for (const fn of required) {
assert.ok(new RegExp(`\\.${fn}\\(`).test(page),
`★ 页面上没有调用 api.${fn}() ⇒ 那个动作是死的(点下去什么都不发生)`);
assert.ok(new RegExp(`async ${fn}\\(`).test(code(ADMIN_API)),
`★ AdminApi 里没有 ${fn} ⇒ 页面调的是不存在的东西,编不过`);
}
});
test('禁用只发 status 一个字段(服务端是**部分更新**,多发的字段会被当成"改成这个值")', () => {
const page = code(ADMIN_PAGE);
const idx = page.indexOf('async setStatus(');
assert.ok(idx > 0, '要有 setStatus');
const body = page.slice(idx, page.indexOf('async resetPassword', idx));
assert.ok(/disableUser\(/.test(body), '禁用要调 disableUser(DEL)');
assert.ok(/onlyStatus\.status = 'active'/.test(body),
'★ 启用路径要显式只设 status');
assert.ok(!/display_name/.test(body) && !/allowed_agents/.test(body),
'★ 启停路径里出现了 display_name/allowed_agents ⇒ 服务端会把显示名与白名单一起改成这些值(部分更新的经典踩法)');
});
test('服务端拦"最后一个管理员"的文案有落点(409 要能显示出来)', () => {
const page = code(ADMIN_PAGE);
assert.ok(/this\.messageOf\(e\)/.test(page),
'★ 捕获到的异常没走 messageOf ⇒ 服务端文案被吞,用户不知道"最后一个管理员不能禁用"');
assert.ok(/this\.errorText = this\.messageOf\(e\)/.test(page), '文案要落到可见的 errorText');
});
/* ────────────────────── ⑧ 形态:交付判据(pi 那几条) ────────────────────── */
test('不许新写死颜色(色一律走 Theme;品牌色只写在 Theme 那一个文件里)', () => {
for (const f of [ADMIN_PAGE, SETTINGS_PAGE]) {
const src = code(f);
const hex = src.match(/#[0-9a-fA-F]{3,8}\b/g) || [];
assert.deepEqual(hex, [],
`★ ${f.replace(ROOT + '/', '')} 里出现了写死的色值 ${JSON.stringify(hex)} ⇒ 深浅两套会从这一处分叉`);
const rgb = src.match(/\brgba?\(/g) || [];
assert.deepEqual(rgb, [], `★ ${f.replace(ROOT + '/', '')} 里出现了手写 rgba/rgb`);
}
});
test('列表项每项一张卡(不是整列共用一张底)', () => {
const src = code(ADMIN_PAGE);
assert.ok(/ListItem\(\)/.test(src), '用户列表要用 List + ListItem');
assert.ok(/for \(const|ForEach\(this\.users/.test(src), '要真的遍历用户列表');
// 卡片样式必须落在被 ForEach 调用的那个 @Builder 里(`UserCard`),而不是外层容器上
const cardIdx = src.indexOf('UserCard(user: AdminUser)');
assert.ok(cardIdx > 0, '要有 UserCard @Builder');
const card = src.slice(cardIdx, cardIdx + 2000);
assert.ok(/backgroundColor\(Theme\.surface\)/.test(card) && /borderRadius\(Theme\.radiusCard\)/.test(card),
'★ 每项那张卡的底色/圆角要在 UserCard 里 ⇒ 否则是一整列共用一张底');
});
test('页面**真的用上**了那几个纯函数(否则纯逻辑全绿、界面却是死的)', () => {
/*
* ★ 这条是变异测试抓出来的缺口:把页面里的 `if (isRestricted(user))` 改成 `if (false)`,
* 删掉「受限」徽标 —— **纯逻辑那几条判据全绿**(`isRestricted` 本身没错),
* 而用户再也看不到徽标。这正是"判据覆盖了模块、没覆盖接线"的经典形状。
* ⇒ 每个纯函数都要在**页面代码**里出现一次(断言调用,不是断言注释里提过)。
*/
const src = code(ADMIN_PAGE);
const wired = [
['isRestricted(user)', '「受限」徽标'],
['isAdminRole(', '管理员门禁'],
['lastLoginLabel(user)', '最后登录文案'],
['messageOfApiError(', '异常兜底文案'],
['toggled(', '白名单勾选']
];
for (const [call, what] of wired) {
assert.ok(src.includes(call),
`★ 页面里没有 ${call} ⇒ ${what} 是死的(纯逻辑判据会全绿,而界面上什么都不会发生)`);
}
});
test('管理页不碰背景/模糊(同一张底只允许被模糊一次,那是 MainPage 外观层的事)', () => {
const src = code(ADMIN_PAGE);
assert.ok(!/blur\(|BackdropBlur|backgroundBlurStyle/.test(src),
'★ 管理页里出现了模糊 ⇒ 与 MainPage 的壁纸层叠起来就是"一张底被模糊两次"');
});
/* ────────────────────── ⑨ 形态:ArkTS 编译坑 ────────────────────── */
test('ArkTS 硬坑:本页不出现解构 / any / unknown / 函数表达式', () => {
const src = code(ADMIN_PAGE);
assert.ok(!/\bany\b/.test(src), '不许 any');
assert.ok(!/\bunknown\b/.test(src), '不许 unknown');
assert.ok(!/\bfunction\s*\(/.test(src), '不许函数表达式(ArkTS 只认箭头函数)');
assert.ok(!/const\s*\{[^}]*\}\s*=/.test(src) && !/const\s*\[[^\]]*\]\s*=/.test(src),
'不许解构赋值');
});
test('ArkTS 硬坑:页面文件只导出那个 struct(工具函数放 model/ 里)', () => {
const src = prose(ADMIN_PAGE);
const exports = src.match(/^export\s+(function|const|class|interface|enum)/gm) || [];
assert.deepEqual(exports, [],
`★ 页面里出现了 ${JSON.stringify(exports)} ⇒ 本仓库页面清一色只导出 struct(7 个页面 0 个 export function);` +
'而且 .ets 里的函数判据跑不了,纯逻辑必须放 model/*.ts');
/*
* ★ 这条原来写的是"页面要 `export struct`",**是错的**,被它自己抓出来了:
* 本仓库 5 个 `@Entry` 页(LoginPage/MainPage/SettingsPage/SessionsPage/MailDetailPage)
* 清一色 `struct Xxx {`(**不带** export),而 `export struct` 只出现在
* 被当子组件用的那些(`CalendarPage`/`BackgroundPicker`)。
* 路由页由 `main_pages.json` 指名加载,不需要导出。
* ⇒ 判据改成钉**这个**形态(两件事分别断言,不混在一句里)。
*/
assert.ok(/^@Entry$/m.test(src) && /^@Component$/m.test(src), '页面要有 @Entry + @Component');
assert.ok(/^struct AdminUsersPage \{/m.test(src),
'★ @Entry 页要写成不带 export 的 struct(与另外 5 个路由页同形)');
assert.ok(!/^export struct AdminUsersPage/m.test(src),
'★ @Entry 路由页带 export 与本仓库既有 5 个路由页不一致(路由页由 main_pages.json 指名加载,不需要导出)');
});
test('页面里不 import `.ets` 进纯逻辑层(否则那个文件从"能真跑"退化成"只读源码")', () => {
const src = prose(ADMIN_TS);
const imports = src.match(/^import\s/gm) || [];
assert.deepEqual(imports, [],
`★ model/AdminUsers.ts 有 ${imports.length} 个 import。本目录下 Wallpaper/Calendar/Appearance 全是零 import,` +
'那正是它们能被 node --experimental-strip-types 直接跑的原因;import 了 .ets 就再也跑不了,判据只能读源码');
});
/* ────────────────────── ⑩ 形态:ArkUI 状态绑定 ────────────────────── */
test('背景选择器:@Link 不许给初值(ArkTS 会报 "forbidden to specify default value for @Link")', () => {
const src = code(join(ETS, 'common/BackgroundPicker.ets'));
const links = src.match(/@Link\s+\w+\s*:\s*[^;]+;/g) || [];
assert.ok(links.length > 0, '选择器要用 @Link 双向绑');
for (const l of links) {
assert.ok(!/=/.test(l),
`★ 「${l.trim()}」给 @Link 写了初值 ⇒ 编不过(V1 家规:@Link 不许有 initializer)`);
}
});
test('背景选择器:父组件用 $ 传 @Link(传 this.xxx 会变成单向 @Prop,改不动父状态)', () => {
const src = code(SETTINGS_PAGE);
const idx = src.indexOf('BackgroundPicker({');
assert.ok(idx > 0, '设置页要挂上 BackgroundPicker');
const mount = src.slice(idx, idx + 600);
for (const name of ['bgKind', 'bgPresetId', 'bgDim', 'bgBlur']) {
assert.ok(new RegExp(`${name}: \\$${name}`).test(mount),
`★ ${name} 没用 $${name} 传 ⇒ 双向绑失效`);
assert.ok(!new RegExp(`${name}: this\\.${name}`).test(mount),
`★ ${name} 用了 this.${name} ⇒ 那是单向传值,选择器改不动页面的 @State`);
}
});