★ 这一轮从用户一句「滑动手势呢?」开始。查下去发现它不是"顺手加个手势",
而是 `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 从「✅(滑动翻页除外)」改为 ✅。
529 lines
32 KiB
JavaScript
529 lines
32 KiB
JavaScript
/*
|
||
* 管理页(用户管理)的判据 —— **行为**判据(纯逻辑真跑)+ **形态**判据(`.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 —— 密钥全文只在创建响应里出现一次,没有它就没法展示');
|
||
});
|