Files
MailUI4Agents/client/electron/test/harmony-admin.test.mjs
JianFeeeee 6ef079365a 跨端: 修「两个动作球不能重叠」那条判据自身(它的结束边界把几十行外的东西也吃进来了)
上一步扩 Unicode 图标扫描面时,这条判据立刻报:
    MailDetailPage.ets:664(Stack 里有 2 个圆角元素且没有 Row 包住)
而 L664 那个 `Stack` 是**新加的返回键**,里面**只有一个子元素**(chevron 图标)。

查清根因(判据自身两个缺陷):

① **结束边界靠猜缩进**:原来写 `\n\s{8}\}` —— "8 空格 + 右括号"。
   而那个 Stack 的 `}` 缩进是 10 空格、它下面还有一整段同缩进的兄弟节点
   ⇒ 非贪婪匹配一路吃到**下一个** 8 空格的 `}`,
   把几十行外两行的**小徽标**(权限/未读,`.borderRadius(4)`)也算了进来。
   ⇒ 改成**按大括号配平**取块。缩进是可变的,配平是语法事实。

② **"圆角"被当成"圆球"**:原判据数的是所有 `.borderRadius(\d+)`。
   而 `borderRadius(4)` 是**圆角方**(徽标),根本不是球。
   本判据要防的是"两个**球**叠在同一个角",不是"任何带圆角的东西"。
   ⇒ 只认 `borderRadius(N)` 且 **N ≥ 16** 的(动作球是 24/28 那一档)。

★ 改完做了**变异验证**(判据自己的要求:必须"在变异下咬得住"):
  把包着两个球的 `Row({ space: 12 })` 拆掉、让它们直接待在 `Stack` 里 ——
  判据**立刻转红**(30→29 pass/1 fail);还原后 30 pass。
  这说明它测的还是原来那件事,只是不再误伤。
2026-09-20 13:42:51 +08:00

865 lines
50 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 ACCOUNT_MGR = join(ETS, 'api/AccountManager.ets');
/** 退出登录的唯一实现(两个入口共用)—— 2026-09-19 抽出来后才有的文件 */
const LOGOUT_MOD = join(ETS, 'api/Logout.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`);
}
});
/* ────────── ⑪ 「我的」页对齐 WebUI AccountPage(2026-09-17)────────── */
test('★ `/me` 不是路由:MeApi 必须调 `/auth/me`(写错 ⇒ isAdmin 恒 false、管理入口永不显示)', () => {
/*
* 这是一个**线上真实存在过**的 bug,而且症状极隐蔽:
*
* `MeApi.get()` 原先写的是 `GET /me`。服务端**根本没有**这个路由 ——
* `server/cmd/server/main.go` 只注册了 `r.Get("/auth/me", handler.Me)`,
* `/me/*` 下挂的是 mail / sessions / keys / appearance 这些**子资源**。
*
* 后果链:这把调用恒 404 → `loadRole()` 恒走 catch → `isAdmin` 恒 false
* → 「管理」入口对**包括管理员在内**的所有人永远不显示。
* 而 catch 里是静默的(刻意不弹错,免得用户以为设置页坏了),所以界面上
* 只表现为"这个功能好像没做",没人会想到是路径写错。
*
* 实证(2026-09-17,对着线上服务端):
* GET /api/v1/me → 404
* GET /api/v1/auth/me → 200 {"user":{"role":"admin",...}}
*
* 这条同时钉三件事:客户端调的路径、服务端真的注册了这个路径、
* 以及响应形状(`{user:{…}}`)与客户端解析的一致。
*/
const adminApi = code(ADMIN_API);
assert.match(adminApi, /client\.get<AdminUserResponse>\('\/auth\/me'\)/,
'★ MeApi.get() 必须调 `/auth/me`(`/me` 不存在 ⇒ 恒 404 ⇒ 管理入口永不显示)');
assert.ok(!/get<AdminUserResponse>\('\/me'\)/.test(adminApi),
'★ 不得再调 `/me` —— 服务端没这个路由');
// 服务端真的注册了 /auth/me(否则两边都在猜)
const mainGo = code(join(ROOT, 'server/cmd/server/main.go'));
assert.match(mainGo, /r\.Get\("\/auth\/me",\s*handler\.Me\)/,
'服务端要注册 GET /auth/me');
// 响应形状 {user:{…}} —— 与 AdminUserResponse 的字段名一致
const authGo = code(join(ROOT, 'server/internal/handler/auth.go'));
assert.match(authGo, /type userOut struct/, '服务端要有 userOut');
/*
* `Me` handler 用的是**内联 map** 而不是具名 struct,所以这里判的是
* 「它把 toUserOut 包在 `"user"` 键下」而不是一个不存在的 `meResponse`。
* 客户端 `AdminUserResponse` 的字段名就叫 `user` —— 两边必须对上,
* 对不上就是“反序列化后全是空”(界面摆好了但没数据)。
*/
const meFn = authGo.slice(authGo.indexOf('func Me('));
assert.match(meFn.slice(0, 400), /map\[string\]interface\{\}\{"user":\s*toUserOut\(u\)\}/,
'Me 的响应形状必须是 {"user": toUserOut(u)}(客户端按 `user` 这个键解析)');
const models2 = code(join(ETS, 'model/Models.ets'));
const resp = models2.slice(models2.indexOf('export class AdminUserResponse'));
assert.match(resp.slice(0, 200), /user:\s*AdminUser\s*=/, 'AdminUserResponse 的字段名要是 user');
});
test('★ 「我的」页要有 WebUI AccountPage 的八个 section(缺一块就是没对齐)', () => {
/*
* WebUI `AccountPage.tsx` 的 section 清单(按源码顺序):
* ① 基本资料 ② 权限范围 ③ 修改密码 ④ 多账号(AccountList)
* ⑤ 客户端连接密钥(KeyPanel) ⑥ 外观(ThemePicker+BackgroundPicker)
* ⑦ 登录状态(退出) ⑧ 管理(仅管理员)
*
* 鸿蒙侧原来只有 ④⑥⑧ 加上推送与添加弹层 —— 差 ①②③⑤⑦ 五块。
* 而 `AuthApi` 里的 `listKeys`/`createKey`/`revokeKey`/`logout`
* **一直写好但没人调**,`/auth/password` 客户端根本没包 —— 全是"半成品"。
*/
const src = code(SETTINGS_PAGE);
// ① 基本资料:六个字段都要有(服务端一直在返回,客户端原来只取 role)
for (const label of ['用户名', '显示名', '角色', '状态', '创建时间', '最后登录']) {
assert.ok(src.includes(`'${label}'`), `基本资料要有「${label}」行`);
}
// ② 权限范围:空数组 = 不限(与 WebUI 同义,不是"一个都不能用")
assert.ok(src.includes('可调用 Agent') && src.includes('可访问目录'),
'权限范围要有「可调用 Agent」「可访问目录」两行');
assert.match(src, /'不限(全部可用)'/,
'空清单要显示「不限(全部可用)」—— 空数组的语义是不限,不是"一个都不能用"');
// ③ 修改密码:三个输入框 + 提交
for (const ph of ['当前密码', '新密码(至少 8 位)', '确认新密码']) {
assert.ok(src.includes(`'${ph}'`), `修改密码要有「${ph}」输入框`);
}
assert.match(src, /changePassword\(/, '修改密码要真的调 AuthApi.changePassword');
assert.match(src, /两次密码不一致/, '两次不一致要就地提示(与 WebUI 同一行为)');
// ⑤ 客户端连接密钥
assert.match(src, /listKeys\(\)/, '密钥列表要调 AuthApi.listKeys');
assert.match(src, /createKey\(/, '新建密钥要调 AuthApi.createKey');
assert.match(src, /revokeKey\(/, '吊销密钥要调 AuthApi.revokeKey');
assert.match(src, /全文仅显示这一次/,
'新密钥的一次性提示与 WebUI NewKeyBanner 同口径(服务端之后只返回 token_hint)');
// ⑦ 退出登录
assert.match(src, /doLogout\(\)/, '「我的」页要有退出登录');
assert.match(src, /退出登录/, '退出按钮文案');
});
test('★ 退出登录必须清**全部**账号(否则被 LoginPage 快速登录路径立刻恢复)', () => {
/*
* `LoginPage.aboutToAppear()` 的快速路径是:
* `getActiveAccount() !== null` ⇒ 直接 `pushUrl MainPage`,不做任何网络认证。
*
* 所以退出时若只在内存里把当前 token 清掉、**库里还留着账号**,
* 下一次回到登录页会立刻被自动恢复 ⇒ 看起来"点了退出没反应"。
* 这是鸿蒙特有的路径(WebUI 没有"本机多账号库自动恢复",
* 所以它的 `logout()` 只 `set({phase:'anonymous'})` 就够)。
*/
/*
* ★★ 2026-09-19 修:退出逻辑被抽成了 `api/Logout.ets` 的 `performLogout()`。
*
* 为什么要抽(不是为了好看):退出有**两个入口** —— 「我的」页的按钮、
* 宽屏侧栏底部那一簇的退出键。复制一份实现的话迟早分叉,而最容易分叉的
* 恰恰是**最不能少的那一步**(注销推送 token —— 少了它,退出后服务器还会
* 往一台"已退出"的设备推通知)。所以两个入口都走同一个函数。
*
* 本判据相应改成**追到实际执行处**:断言「我的」页真的调了 performLogout,
* 且 performLogout 里真的清了全部账号。**两半都要断** ——
* 只断第一半会放过"performLogout 被改成空实现",
* 只断第二半会放过"按钮没接上"(那种情况页面里照样有 performLogout 这几个字)。
*
* ⚠️ 别退回"直接匹配 SETTINGS_PAGE 里的 clearAll()"的写法:
* 那会随重构假红(今天就是),于是下一个人只会去改判据、而不去看行为。
*/
const src = code(SETTINGS_PAGE);
assert.match(src, /performLogout\(/,
'★ 「我的」页的退出要调 performLogout()(与侧栏底部簇共用同一个实现)');
/* 第二半:那个函数真的清了**全部**账号,而不是只清当前 */
const logoutSrc = code(LOGOUT_MOD);
assert.match(logoutSrc, /clearAll\(\)/,
'★ 退出要调 AccountManager.clearAll()(清全部账号)');
assert.ok(!/clearActive\(\)/.test(logoutSrc),
'不得只清"当前"账号 —— 库里的其余账号会让快速登录路径把退出撤销掉');
// 被调的那个方法真的存在,且真的清空
const mgr = code(ACCOUNT_MGR);
assert.match(mgr, /async clearAll\(\): Promise<number>/, 'AccountManager 要有 clearAll()');
const body = mgr.slice(mgr.indexOf('async clearAll()'));
assert.match(body.slice(0, 400), /this\.accounts = \[\]/, 'clearAll 要清空账号数组');
assert.match(body.slice(0, 400), /this\.activeId = ''/, 'clearAll 要清空 activeId');
assert.match(body.slice(0, 400), /await this\.persist\(\)/, 'clearAll 要落盘(否则重启后又回来了)');
});
test('★ 「我的」页整页一个滚动容器(排在 flex-1 之后的 section 会被裁掉且滚不到)', () => {
/*
* WebUI `AccountPage` 把这个坑写在注释里:
* 「缺了它的后果:这个页的内容(资料 + 权限 + 改密码 + 密钥 + 退出)比视口高,
* 而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉,没有任何办法滚到。
* 实测 390px 下内容需 860px、容器只有 795px;1280x800 的桌面上同样看不到
* 最后的『退出登录』」。
*
* 鸿蒙侧原来的形状**正是那个错的**:账号列表 `layoutWeight(1)`(占满剩余高度),
* 后面几块 section 直接排它下面 ⇒ 被挤出可视区。账号越多 / 屏越矮越明显,
* 而「退出登录」正好在最下面 —— 等于退不出去。
*/
const src = code(SETTINGS_PAGE);
assert.match(src, /Scroll\(\)\s*\{/, '整页要有一个 Scroll 容器');
/*
* 关键:section 必须在**那个 Scroll 里面**,而不是排在 flex-1 的兄弟位置。
* 判法是取 Scroll 的花括号正文,看各个 section builder 在不在里面。
*/
const at = src.indexOf('Scroll()');
assert.ok(at > 0, '要能找到 Scroll');
let depth = 0;
let start = src.indexOf('{', at);
let end = -1;
for (let i = start; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
assert.ok(end > start, 'Scroll 的花括号要闭合');
const inside = src.slice(start, end);
for (const sec of ['ProfileSection', 'KeySection', 'SecuritySection', 'AdminSection']) {
assert.ok(inside.includes(`this.${sec}()`),
`★ ${sec} 必须在 Scroll **内部** —— 排在 flex-1 之后会被裁掉且滚不到(WebUI 的注释里就有这个坑)`);
}
assert.ok(inside.includes('退出登录') || /SecuritySection/.test(inside),
'退出登录要在可滚动区域内(否则退不出去)');
// 滚动容器要挂上下渐隐(与列表同一纪律)
assert.match(src, /\.fadingEdge\(true,/, '滚动容器要挂 fadingEdge(WebUI .overflow-y-auto 的 mask-image)');
});
test('★ 「我的」页的数据都来自服务端真实字段(不是写死/编造)', () => {
/*
* 「基本资料」那一块最容易变成"界面摆好了但字段是空的":
* `/auth/me` 返回 `status`/`created_at`/`last_login`/`allowed_agents`/`allowed_paths`,
* 而客户端 `Me` 原来只声明了 4 个字段 —— 反序列化时多出来的键被丢掉。
*/
const models = code(join(ETS, 'model/Models.ets'));
const me = models.slice(models.indexOf('export class Me {'), models.indexOf('export class Me {') + 900);
for (const f of ['status', 'created_at', 'last_login', 'allowed_agents', 'allowed_paths']) {
assert.ok(new RegExp(`${f}:`).test(me), `Me 类要有 ${f} 字段(服务端一直在返回它)`);
}
// 字段名要和服务端 JSON tag 一致,否则反序列化后是空
const authGo = code(join(ROOT, 'server/internal/handler/auth.go'));
/*
* 注意 `last_login` / `created_at` 服务端带 `omitempty`(值空时**整个键不出现**),
* 所以客户端那两处必须容忍 `undefined` —— 详情页用的是 `?? '-'` 与
* 显式 undefined 判断,不是假定一定有时。
*/
for (const tag of ['status', 'created_at', 'allowed_agents', 'allowed_paths', 'last_login']) {
assert.ok(new RegExp(`json:"${tag}(,omitempty)?"`).test(authGo),
`服务端 userOut 要有 json:"${tag}"`);
}
// 密钥的"可用/已使用/已过期"要靠这两个字段算(WebUI keyState 同口径)
const key = models.slice(models.indexOf('export class UserKey'), models.indexOf('export class UserKey') + 700);
assert.ok(key.includes('expires_at') && key.includes('used_at'),
'UserKey 要有 expires_at / used_at(否则算不出"可用/已使用/已过期")');
assert.ok(key.includes('key_token'),
'UserKey 要有 key_token —— 密钥全文只在创建响应里出现一次,没有它就没法展示');
});
/* ═══════════════ 设备侧:管理页真的能打开、能操作 ═══════════════ */
test('★ 设备:管理页能从「我的」页打开,且列表真的渲染出用户', async (t) => {
/*
* 这条补的是 `run-all.mjs` 的 `STATIC_ONLY` 登记里说的那个缺口:
* 「用户管理页:`.ets` 页面要 hvigorw 才能编译、要设备才能点(本机两者都没有)」
*
* 上面 27 条判的都是**逻辑与接线形态**。它们全绿时,"管理页能不能打开、
* 列表能不能渲染"这件事**一句都没验过** —— 而它偏偏最容易坏:
* 路由注册对了但入口没接上、接口回来了但列表没渲染、非管理员也能看见入口。
*
* ★★ 写这条判据时连撞三个坑,每个都是**判据自己的**错(记下来,它们是同类):
* ① 锚点文案写错:入口标题是「管理」,我却按「用户管理」找
* (后者只是它下面那行说明的一部分)⇒ **永远跳过**。
* 而那个跳过看起来像"非管理员、行为正确"—— 最危险的那种静默。
* ② 元素在**滚动下方**:dumpLayout 只报可见节点 ⇒ 不滚到底根本搜不到。
* ③ 文字节点不可点:`Text('管理')` 的 `clickable=false`,
* 点击挂在包住它的容器上(ArkUI 的常态)。
*
* ⇒ 结论:设备判据里"找不到元素"必须先分清是**功能缺失**还是**判据没摆好现场**。
* 前两个坑都是后者,而它们伪装成前者。
*/
const D = await import('./lib/harmony-device.mjs');
const hdc = D.findHdc();
if (!hdc || !D.hasTarget(hdc)) {
return t.skip('设备不在 —— 本条的设备半边本次不跑(上面静态层仍把住逻辑与接线)');
}
assert.ok(await D.launchOurApp(hdc), '要能拉起应用并等到它到前台');
/*
* ★★ 先回主界面:前面跑过的设备判据可能把前台留在 **push 出去的页面**上
* (详情页 / 管理页 / 写信页 —— 它们没有侧栏)。实测:设备停在邮件详情页时
* 本判据跑了 201 秒才失败,报"要能进「我的」页"—— 而那是**判据间干扰**,
* 不是功能坏了(`backToMain` 的注释里记着同一个根因)。
*/
assert.ok(await D.backToMain(hdc), '要能回到主界面(侧栏/底栏可见)');
await new Promise((r) => setTimeout(r, 1500));
const dims = () => {
let w = 0;
let h = 0;
for (const n of D.walk(D.dumpLayout(hdc))) {
const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || '');
if (m) {
w = Math.max(w, Number(m[1]));
h = Math.max(h, Number(m[2]));
}
}
return { w, h };
};
const hasText = (needle) => [...D.walk(D.dumpLayout(hdc))]
.some((n) => (n.attributes?.text || '').includes(needle));
/* 已经在管理页了?那直接用(判据不该强求"必须从某处点进来") */
if (!hasText('新建用户')) {
const { w: scrW, h: scrH } = dims();
const wide = scrW / scrH > 1.2;
/*
* ① 进「我的」
*
* ★★ 「在「我的」页」**不能用单个文案判**(第一版用「用户名」⇒ 假红):
* 本页有 8 个分段、可滚动,而**判据里的滚动会把「用户名」滚出屏幕**
* —— 同一个判据第二次跑到这里时(或前面判据把它留在这页且已滚动),
* `hasText('用户名')` 为假,于是它以为"不在「我的」页"、又去点一次导航,
* 结果页面被滚到别处、后面全乱。
*
* 改用**分段标记**判定:这些文案分布在本页的不同段落,
* 滚动到任何位置都至少能看见一个。
*/
const inMePane = () => ['用户名', '多账号', '外观', '客户端连接密钥', '修改密码']
.some((marker) => hasText(marker));
if (!inMePane()) {
if (wide) {
const avatar = [...D.walk(D.dumpLayout(hdc))].find((n) => {
const a = n.attributes || {};
if (a.clickable !== 'true') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, , y1, x2, y2] = m.map(Number);
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && (y2 - y1) > 80;
});
if (!avatar) return t.skip('宽屏侧栏头像找不到 —— 无法进「我的」');
const c = D.boundsCenter(avatar.attributes.bounds);
assert.ok(D.tap(hdc, c.cx, c.cy), '要能点到侧栏头像');
} else {
assert.ok(D.tapText(hdc, '我的'), '要能点到「我的」');
}
let landed = false;
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
if (inMePane()) { landed = true; break; }
}
assert.ok(landed, '要能进「我的」页');
}
/*
* ② 找「管理」入口(在 8 个分段之后,屏幕外)。
*
* ★★ 滚动必须**小步 + 每步检查**(我前两版都栽在这里):
* 大步滑(`0.8h → 0.25h`)会**跳过**那个入口 —— 实测:滚 1~2 次时它在屏外,
* 第 3 次才出现(`y=1994`,屏高 2232,贴着底)。而"滑到底再看"
* 会因为惯性/回弹停在别处,于是"找不到"——看起来像功能缺失。
*
* 现在每步只滑 ~1/4 屏,滚动后立刻查一次。
*/
/*
* ★★ 第四版(诊断逼出来的真相):小步滚也会**越过**它。
* 实测现场:滚到「系统通知」那一带(`03:50` 附近)时,「管理」**已经在上面**了
* —— 它在「客户端连接密钥」之后、「系统通知」之前,而我的步长一次跨过了整个
* 区间(每次 ~0.2 屏,而它只占 ~0.04 屏)。
*
* ⇒ 正确策略是**先滚到底,再从下往上找**(到底之后它一定在屏幕内或其上方,
* 再按小步回滚就必然扫过它)。"只管往前找"在不均匀列表上必然漏。
*/
const scrollDown = async (steps) => {
for (let i = 0; i < steps; i++) {
D.swipe(hdc, Math.round(scrW * 0.5), Math.round(scrH * 0.7),
Math.round(scrW * 0.5), Math.round(scrH * 0.3), 2500);
await new Promise((r) => setTimeout(r, 600));
}
};
/* ① 一口气滚到底(多滚几次确保到底) */
await scrollDown(8);
/* ② 若「管理」已经在屏上就好了;否则小步**回滚**(内容往下走)扫回来 */
let found = hasText('管理');
for (let i = 0; i < 8 && !found; i++) {
D.swipe(hdc, Math.round(scrW * 0.5), Math.round(scrH * 0.35),
Math.round(scrW * 0.5), Math.round(scrH * 0.6), 1500);
await new Promise((r) => setTimeout(r, 650));
found = hasText('管理');
}
if (!found) {
const seen = [...D.walk(D.dumpLayout(hdc))]
.map((n) => (n.attributes?.text || '').trim())
.filter(Boolean).slice(-20).join(' | ');
return t.skip(`滚到底后仍找不到「管理」入口(非管理员时这是正确行为)—— 末尾读到:${seen}`);
}
/*
* ③ 点它。文字节点不可点(`.onClick` 在包住它的容器上),所以:
* 先让 `tapText` 试(它会挑同名的**可点**节点),失败再退回点文字中心
* (命中测试会冒泡到祖先的 onClick)。
*
* ★ 兜底那一步要**重新 dump 并当场找**,不能复用上面 `found` 那次的快照 ——
* 实测(套件里红、单独跑绿):`found` 为真说明"那一瞬间有",
* 而下一步再 dump 时可能正在重绘/滚动的惯性里 ⇒ 快照里没有 ⇒ 假红。
* 设备上的两次 dump 之间窗口状态是可以变的,别假设它们一致。
*/
/*
* ★★ 第三版:**直接按文字节点的矩形中心点**,不走 `tapText`。
*
* 为什么 `tapText` 在这里不可靠(实测:单独跑绿、套件里红):
* 它先找"同名且**可点**"的节点;这个入口的 `.onClick` 挂在
* **外面那层 `Column`** 上(`SettingsPage.ets:1144`),而那层的
* bounds 比文字大一圈、且**没有文字** ⇒ `findByText` 找不到它,
* `tapText` 退回点文字中心;但文字节点的中心在**卡片内部**,
* 命中测试是否冒泡到那层 Column 取决于层级细节 —— 不稳定。
*
* 而实测(手动点 `(1592, 2017)`,即文字所在行的中心)**每次都进管理页**。
* ⇒ 用文字节点的**矩形中心**直接点,这条路径最接近"人点那一行"。
*/
/* 诊断入口:`AGENTMAIL_ADMIN_DEBUG=1` 时把现场打出来(排查设备判据专用) */
const dbg = process.env.AGENTMAIL_ADMIN_DEBUG
? (m) => console.log(`[admin] ${m}`) : () => {};
const snap = D.dumpLayout(hdc);
const node = [...D.walk(snap)]
.find((n) => (n.attributes?.text || '').trim() === '管理');
if (!node) {
const seen = [...D.walk(snap)]
.map((n) => (n.attributes?.text || '').trim()).filter(Boolean).slice(-18).join(' | ');
dbg(`找不到「管理」节点;末尾文本:${seen}`);
}
assert.ok(node, '滚到之后「管理」文字节点应在 dump 里');
const c = D.boundsCenter(node.attributes.bounds);
assert.ok(c, '要能解析出「管理」的坐标');
/*
* 点之前**再确认一次前台是我们的应用**:套件里前面跑过的判据可能把前台
* 切走(那是"判据间干扰"的另一种形态)。切走了就不是"点不到入口",
* 而是"点在了别人的界面上"—— 后者必须显式失败,不能算通过。
*/
assert.equal(D.foregroundBundle(hdc), D.ourBundle(),
'点之前前台必须还是我们的应用(前面判据若切走了前台,这里点的是别人的界面)');
const ok = D.tap(hdc, c.cx, c.cy);
dbg(`点 (${c.cx},${c.cy}) ⇒ ${ok}`);
assert.ok(ok, `要能点到「管理」入口(坐标 ${c.cx},${c.cy})`);
}
/* ④ 等页面真的切过去 */
let onAdmin = false;
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
if (hasText('新建用户')) { onAdmin = true; break; }
}
assert.ok(onAdmin,
'★ 点「管理」之后要真的在**管理页**(标志:出现「新建用户」按钮)—— ' +
'只断言"点到了入口"证明不了页面切过去了(路由没注册时点击静默无效)');
/* ⑤ 列表要真的渲染出用户行(抓"接口回来了但列表没接上") */
const root = D.dumpLayout(hdc);
const texts = [...D.walk(root)].map((n) => (n.attributes?.text || '').trim()).filter(Boolean);
const userRows = texts.filter((x) => /用户管理|启用|管理员|用户$/.test(x));
assert.ok(userRows.length > 0,
'★ 管理页要真的渲染出**用户行**(带角色/状态的那种)—— ' +
`只有标题而没有行,说明数据没渲染。实际读到:${texts.slice(0, 25).join(' | ')}`);
});
test('★ 详情页的两个动作球必须**分开摆**(Stack 的同一个角 + 各自 margin = 重叠)', () => {
/*
* ★★ 2026-09-19 真 bug,设备实测撞出来的:
*
* 详情页右下角有两个动作球(转发 + 回复)。第一版把它们**各自**写在
* `Stack({ alignContent: Alignment.BottomEnd })` 里、各带一个 margin ——
* 于是它们**几乎完全重叠**。实测 dump 的 bounds(密度 2.875):
*
* 转发 [2984,2010][3122,2148]
* 回复 [2949,1998][3110,2159]
* 重叠区 x ∈ [2984, 3110]
*
* 屏幕上只看得到一个球,**转发入口等于不存在**。
* 根因是 ArkUI 的 `Stack.alignContent` 把每个子元素都摆到**同一个角**,
* margin 只是各自微调 —— 想并排就得用**一个容器**把子元素排起来。
*
* 判据形状:找"同一容器下的多个圆形 Button,各自只带 margin"这种写法。
* 正确写法是外面包一层 `Row({ space })`。
*/
const src = code(join(ETS, 'pages/MailDetailPage.ets'));
/*
* 找 `Stack({ alignContent: ... })` 块里**直接**出现的两个以上圆形按钮
* (`.borderRadius(N)` 且 N 使它是圆:宽高等于 2N)。
* 简化判定:在同一个 Stack 块内出现 ≥2 处 `.borderRadius(` + 各自 `.margin(`
* 而没有包在 Row 里。
*/
/*
* ★★ 2026-09-20 修(判据自身的缺陷,扩展扫描面时暴露出来的)。
*
* 原来的结束边界写的是 `\n\s{8}\}` —— "8 个空格 + 右括号"。
* 这个边界**远早于** Stack 真正的收尾(那个 Stack 的 `}` 只有 10 空格缩进,
* 但它下面还有一整段同缩进的兄弟节点)⇒ 非贪婪匹配会一路吃到**下一个**
* 缩进 8 空格的 `}`,把几十行不相干的内容(权限/未读**徽标**的
* `.borderRadius(4)`)也算成"Stack 里的圆形按钮"。
*
* 实测后果:我在返回键那里新加了一个 `Stack({ alignContent: Center })`
* (**只有一个子元素**:chevron 图标),判据立刻报
* 「Stack 里有 2 个圆角元素且没有 Row 包住」—— 那 2 个是页面下方两行的
* 小徽标(`borderRadius(4)`,圆角方,根本不是圆球)。
*
* 两处修:
* ① **按大括号配平**取块,而不是猜缩进 —— 缩进是可变的,配平是语法事实。
* ② 只在**直接子元素**层找圆球(`borderRadius(N)` 且 N 足够大、是圆角)。
* 徽标那种 `borderRadius(4)` 的圆角方不该算进来:本判据要防的是
* "两个**圆球**叠在同一个角",不是"任何带圆角的东西"。
*/
const blockOf = (from) => {
let depth = 0, started = false;
for (let i = from; i < src.length; i++) {
const ch = src[i];
if (ch === '{') { depth++; started = true; }
else if (ch === '}') {
depth--;
if (started && depth === 0) return src.slice(from, i + 1);
}
}
return '';
};
const bad = [];
for (const m of src.matchAll(/Stack\(\{[^}]*alignContent[^}]*\}\)\s*\{/g)) {
const body = blockOf(m.index + m[0].length - 1);
/*
* 只认**圆**:`.borderRadius(N)` 的 N ≥ 16(动作球是 24/28 这一档)。
* 小徽标的 `borderRadius(4)` 是圆角方,不是"球",不能算。
*/
const radii = [...body.matchAll(/\.borderRadius\((\d+)\)/g)].map(x => Number(x[1]));
const balls = radii.filter(r => r >= 16).length;
if (balls >= 2 && !/Row\(\{\s*space:/.test(body)) {
const line = src.slice(0, m.index).split('\n').length;
bad.push(`MailDetailPage.ets:${line}(Stack 里有 ${balls} 个圆角球且没有 Row 包住)`);
}
}
assert.deepEqual(bad, [],
'★ `Stack.alignContent` 会把**每个**子元素都摆到同一个角 —— ' +
'多个圆形按钮各自带 margin 时会**重叠**(实测:屏幕上只看得到一个球)。\n' +
`命中:\n ${bad.join('\n ')}\n` +
'正确写法:外面包一层 `Row({ space: N })`,由 Row 负责把它们排开、' +
'Row 自己带 margin 到角落。');
});
test('★ 底部弹层必须有明确高度(否则键盘一弹,按钮全被顶出屏幕)', () => {
/*
* ★★ 2026-09-19 真 bug(与上面那条同一次实测):
*
* 详情页的转发弹层第一版**没有高度**(只有 `padding(16)`)⇒ 高度完全由内容决定。
* 而 ArkUI 默认的键盘避让是 `KeyboardAvoidMode.OFFSET`(整体上移)——
* 上移之后,弹层里的 `TextArea` 与「取消 / 转发」按钮**跑到键盘下面**去了。
* 实测截图:只看得见收件人输入框 + 键盘,「转发」按钮**点不到**。
*
* 修法两半(缺一不可):
* ① 弹层要有**明确高度**(与回复弹层一致,`height('60%')`)——
* 内容才能在里面重新分配空间;
* ② 会撑高的那个子元素(说明框)要用 `layoutWeight(1)` 而不是固定高度 ——
* 键盘顶上来时它自己缩短,把按钮留在屏内。
*/
const src = code(join(ETS, 'pages/MailDetailPage.ets'));
/* 找所有"底部弹层"(`borderRadius({ topLeft: ... })` 是这类弹层的标志) */
const sheets = [...src.matchAll(/\.borderRadius\(\{\s*topLeft:\s*\d+/g)];
assert.ok(sheets.length >= 2,
`详情页应有多个底部弹层(回复 / 转发)—— 实际找到 ${sheets.length} 个`);
/*
* 逐个弹层:从 borderRadius 往回找它的高度声明。
* 在 `.borderRadius({ topLeft` 之前的一段里找 `.height(`。
*/
const noHeight = [];
for (const m of sheets) {
const before = src.slice(Math.max(0, m.index - 1200), m.index);
/* 最近的 .height(...) 要在这一段里(弹层的修饰符链上) */
if (!/\.height\(('100%'|\d+|'[0-9]+%')\)/.test(before)) {
const line = src.slice(0, m.index).split('\n').length;
noHeight.push(`MailDetailPage.ets:${line}`);
}
}
assert.deepEqual(noHeight, [],
'★ 底部弹层必须有明确高度 —— 没有高度时它的尺寸由内容决定,' +
'而键盘弹出(默认 `KeyboardAvoidMode.OFFSET`)会把内容整体上移,' +
'**「取消 / 转发」按钮会被顶到键盘下面**(实测截图:按钮点不到)。\n' +
`缺高度的弹层:\n ${noHeight.join('\n ')}`);
/* ② 说明框用 layoutWeight 而不是固定高度(否则它撑住高度、按钮仍被挤出) */
const ta = /TextArea\(\{[\s\S]{0,200}?转发说明[\s\S]{0,400}?\n\s*\}\)([\s\S]{0,300}?)\.onChange/.exec(src);
assert.ok(ta, '要能找到转发弹层的说明框(TextArea)');
assert.match(ta[1], /\.layoutWeight\(1\)/,
'★ 转发说明框要用 `layoutWeight(1)`(不是固定 `height(N)`)—— ' +
'弹层高度固定后,只有它会伸缩才能把「取消 / 转发」按钮留在键盘之上');
});