Files
MailUI4Agents/client/electron/test/harmony-admin.test.mjs
JianFeeeee 28e3da76d4 跨端: 左右滑动翻页(P6 第 3 步)+ 还 gesture-semantics 债 + 修跑不起来的判据基建
★ 这一轮从用户一句「滑动手势呢?」开始。查下去发现它不是"顺手加个手势",
  而是 `docs/DEBTS.json` 里挂着的一笔债 —— `gesture-semantics` 的原话是:

    「P6 第 3 步:鸿蒙侧出现滑动手势代码时**立即建**判据
      (此前建 = 只有一端存在的假判据)」

  也就是**先有手势、再钉语义**。WebUI 2026-09-14 就有滑动翻页(用户当时
  亲口提的),鸿蒙一直没有 ⇒ 之前建判据会是空真(∀x∈∅)。

## 一、手势本体(两端语义逐项对齐,数值各自定)

按 `HARMONY-ALIGN-PLAN.md:118-128` 显式选的 **(b) 口径**:
「手势的物理量本来就不该强求同值……该对齐的是**语义层**」。

· 判定逻辑放**纯逻辑层** `model/Calendar.ts` 的 `judgeSwipe`(可被 node 直跑,
  写在 .ets 里就跑不了判据,语义没法被单测钉住)。四道门:位移 / 纵向优先 /
  快滑窗口 / 方向。
· 阈值**不引用** WebUI 的 40 / 1.5 / 600(那是把巧合当契约),
  各自定为 56vp / 1.4× / 700ms,并在注释里写出取值依据。
· 接线在 `CalendarPage.ets`:`PanGesture({direction: Horizontal})` +
  `onActionStart`(记时 —— `GestureEvent` **没有时间戳字段**,我查了 SDK
  的 gesture.d.ts 确认)+ `onActionEnd`(读 offsetX/offsetY)。
· ★ 挂在**网格列**上而不是整页:右栏(日程/编辑器)里有输入框与可滚内容,
  整页挂会让「在表单里横划一下」变成翻月。
· ★ 翻页复用 `shiftRange()`(与 ‹ › 按钮**同一个来源**)—— WebUI 的注释
  专门交代过:各写一套的话,阈值、边界、三档行为迟早分叉。
  手势回调里**不准**直接改 year/month(锚点是唯一真相,年月只能由
  `shiftRange → syncYearMonthFrom` 派生)。

**有意差异(记录在案,不是漏做)**:WebUI 在周/日档会额外检查「触点是否落在
可横向滚动的区域里」,是则让给滚动条(用户 2026-09-14 报过这个冲突)。
鸿蒙周档是「一行 7 格按 layoutWeight 等分」、**不横滚** ⇒ 该条件不适用。
哪天加了横滚必须同时补上它。

## 二、判据(8 条语义契约 + 行为层)

新增 `cross-client-gesture.test.mjs`:两端都真有手势 / 方向映射逐项相同 /
纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。
**只比语义、不比数值**,并反向断言鸿蒙的阈值常量不得直接取 WebUI 的那三个数。

`harmony-logic.test.mjs` 加行为判据:真跑 `judgeSwipe`,把四道门各自验一遍
(只钉字符串的话,一个 return 写漏了照样全绿)。

## 三、顺手修掉的三处**判据基建**缺陷(不修就没法验证上面这些)

1. `run-all.mjs` 只认 `# pass N`,而 node v24 打的是 `ℹ pass N`
   ⇒ **21 个文件被记成"没自报条数"**、套件在 HEAD 就恒红(memory 里记过这条,
   修法也记过,今天终于落进代码:4 个正则加 `(?:#|ℹ)`)。修完 `unreported` 27 → 0。
   代价是暴露出一批此前被"没自报"掩盖的真实问题(下面 4~6 条)。
2. `harmony-nav` 的设备判据 `navItemsOf`:宽屏过滤条件从「左边缘靠左 1/6」
   改成「**整个盒子在侧栏轨道内**」。旧条件把**日历网格的格子**
   (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
   这是设备实测抓出来的 —— 我第一版还以为是"底部簇混进来了",
   打印真实数据才发现是隔壁页面的格子。
3. `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS` **各自的数组**里取
   label。原先把全文件 `label:` 一网打尽 ⇒ 得到 7 个(4+3 混在一起),
   任何一边改对了它都会红。

## 四、被判据拦住后的正经修法(每条都按判据自己给的方向改,不改判据迁就代码)

· `cross-client-theme` A2 拦住我:新增的 `navActiveBg`/`navBrandFg`/`badgePlain`
  未登记;又拦住我:`sseColorOf` 里四个裸色值。→ 抽成 `Theme.sseConnected` 等
  四个令牌(取值对齐 WebUI 的 Tailwind 类)+ 登记 + 在 Theme.ets 的表里写理由。
· 同一条判据的"死令牌"检出:`Theme.durBase` 声明了却从没人读。
  **查 WebUI 才发现壁纸淡入是真有的动效**(`index.css:742-752` 的
  `.app-backdrop` 从 opacity:0 → 1,180ms)⇒ 补上而不是删令牌
  (删掉等于把差异抹平、还说成"清理")。reset 在 `animateTo` **外**,
  与 `calPaneIn` 同一条纪律。
· 玻璃登记:`NavItemBuilder` → `SidebarItem`(重构后按最近的 @Builder 命名),
  登记同步跟上。
· `harmony-admin` 的退出判据:退出逻辑抽成 `api/Logout.ets` 的 `performLogout()`
  (两个入口——「我的」页与侧栏底簇——必须做同一件事,尤其"先注销推送 token"
  那一步)。判据相应改成**追到实际执行处**(两半都断:按钮调了 + 函数真清了全部),
  并写明"别再退回直接匹配 SETTINGS_PAGE 的写法"(那会随重构假红,
  下一个人只会去改判据而不看行为)。
· `harmony-widescreen` 的连接点色值:色值搬进 Theme 后,判据改成断
  「令牌定义对了 + 侧栏真的用了它」两半(只断任一半都有假绿形态)。
· `align-refs`:`CalendarView.tsx` 变了,按判据要求**读一遍差异**再更新登记
  (差异只有农历小字的灰阶档位 gray-300→gray-400/500,**骨架未变**)。
· `criteria-hygiene`:`harmony-contacts` 自造了一个 `code()`、`harmony-widescreen`
  裸用 `readFileSync` ⇒ 都改用 `lib/read.mjs` 的共享入口。
  途中撞出一个**判据自己的 bug**:`code()` 的块注释正则
  `/\*[\s\S]*?\*\//` 会把注释里出现的 `/*`(如 `/」**` 这种中文夹星号)
  当成块注释起点,一路吃到几十行后的 `*/`,把中间的 import 全吞掉 ——
  于是 hygiene 判据假红"没 import"。改掉那处写法后正常。

## 五、验证

判据面:`run-all.mjs` → `files=32 ran=32 checks=497 pass=497 fail=0
skip=0 red=0 broken=0 unreported=0`。
其中新/改判据:gesture 8、nav 18、widescreen 7、logic 30、admin 27、
cross-client-theme 15、criteria-hygiene 6。
构建:`hvigorw assembleHap` 成功;前端 `npm run build` + 重新打 AppImage/deb
(`build-stamp` 7/7、`packaging` 5/5)。

**设备实测(HATriple 三折叠 3184×2232,hdc 连 127.0.0.1:5555)**:
· 农历在格子里真的显示(1=二十 / 7=廿六 / 19=**初九** / 11=八月),与 WebUI 一致;
  这条同时验证了**服务端农历路由已部署**(之前线上是 404)。
· 日历左右两栏:编辑器出现在**右栏**、左栏月份仍可见(单栏模式下编辑器会整页盖掉它)。
· 侧栏 3 项 + 底部一簇;徽标回到图标右上角。

**仍未验(如实标注)**:滑动翻页的**手感**(阈值 56vp/1.4×/700ms 是否合适)
只能真人滑过才知道;我只验了判定逻辑与接线形态。动画同理 ——
机制已验证(`animateTo` 驱动 + reset 在窗口外),但"看起来顺不顺"未做取样验证。

docs:`DEBTS.json` 销掉 `gesture-semantics`(并记结算说明)、
`HARMONY-ALIGN-PLAN.md` P6 从「✅(滑动翻页除外)」改为 ✅。
2026-09-19 14:01:21 +08:00

529 lines
32 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 —— 密钥全文只在创建响应里出现一次,没有它就没法展示');
});