pi 的交付清单里缺的两块(`docs/GUI-PLAN-HARMONY.md` 原先把管理后台划在首版之外, 用户明确要求「功能做全再给我」之后收进来)。 标 `跨端:` 是因为本次的判据落在 `client/electron/test/`(鸿蒙的判据目录一向量在那里), 代码本体全在 `client/harmony/`。 ## 管理页(用户管理) - `pages/AdminUsersPage.ets`:新建 / 编辑(显示名·角色·白名单)/ 启停 / 重置密码。 排布照 `AdminUsersPage.tsx`,包括「受限」徽标的口径(普通用户且白名单非空才显示)、 最后登录缺席与空串都显示「从未登录」、管理员对白名单两项忽略。 - 入口在设置页底部,**仅管理员可见**(`role === 'admin'` 严格相等,与 `App.tsx` 同口径)。 读不到身份时**不**显示也不报错(乐观放行会让每个普通用户看到一个点进去 403 的入口)。 - `api/AdminApi.ets` + `model/AdminUsers.ts`(纯逻辑,零 import ⇒ 判据能真跑)。 - 启停**只发 status 一个字段** —— 服务端是部分更新,多发字段会把显示名与白名单一起改掉。 - `model/Models.ets` 补管理端 DTO;`main_pages.json` 注册路由。 ## P4c 壁纸上传 - `model/ImagePrep.ts`:阈值与两档策略(2560/0.85 → 1280/0.78,入口 20MB,压后上限 3.5MiB)。 **一处有意不对齐 WebUI** 并写明理由:WebUI 卡 data-URL 长度(含 base64 膨胀), 鸿蒙内存直传 ArrayBuffer,卡的是字节数。 - `common/BackgroundPicker.ets`:不设 / 预设 / 自定义图片 + 浓度与模糊滑杆。 上传链:picker → 判可不可以 → 逐档按 desiredSize 解码压缩 → 上传 → **请页面以服务端为准重新同步**。 失败**必带原因**(服务端 415/413 文案原样透出)。 - `ApiClient.uploadBytes`:MultiFormData.data 收 ArrayBuffer(核了 SDK,since 11;本工程 23) ⇒ 内存直传,不需要 base64、也不需要临时文件。 - 用户取消选图**不算失败**,什么都不说。 ## 顺带修掉的两处真问题(都是变异测试逼出来的) 1. **压缩循环的第二档此前是死代码**:循环里的 break 与循环外那句 shouldRetryWithActual 互相抵消 —— 把循环里那处改成 `if (true)`(永远只压一档)整套判据照样全绿。 收成一处判定(overLimit),循环外只读结论。 2. **壁纸的模糊档一直是「只写不读」**(计划文档 §7.12 登记过):滑杆能拖、值能存、 blurStyleFor 也写了,就是**没有调用点**,壁纸一点没糊。本次补上调用点 (壁纸层 .blur(px) = 图片内容模糊;导航条材质由 blurStyleFor 映射)。 同时按 §7.12 的原承诺更新了那一行。 ## 一并修正的旧判据(都是"太宽/太窄",不是放宽标准) - 「模糊归属」:原文「壁纸层不许有**任何**模糊调用」把**图片内容模糊**与**面板材质** 混为一谈(WebUI 侧核实:.app-backdrop 的 filter 与它之上那层的 backdrop-filter 是两个不同的量)⇒ 改成按两种模糊分别钉。 - 「bgBlur 只写不读,消费侧必须为 0」:值不再成立,**形状保留**(逐文件登记 + 计数 + 理由), 标题与断言里的假话一并改掉。 - isDarkMode 那条 `/dark\s*\)/` 断的是**参数顺序**(加一个入参就误红)⇒ 改成"dark 在实参里"。 - 导航材质三处断言原本钉 `Theme.navMaterial` 字面量 ⇒ 改成钉新的映射写法。 ## 判据 新增 `harmony-admin.test.mjs`(22 条)、`harmony-imageprep.test.mjs`(29 条); `harmony-presets.test.mjs` 加 1 条(模糊档搬运与归一,含 -0 那个洞)。 全量 203 条:**201 通过**,2 条失败为**改动前就红**的既有项 (BUILD_INFO 比对、词表↔余额)—— 用 stash 对照验证过。 两个新判据文件上跑了 **48 个变异体,全部被抓**(含"接线"类:删掉「受限」徽标、 组件自己宣布成功、release 不 await、按原图尺寸解码…), 其中 2 个变异体**红不了**,因此又补了 5 条判据(纯逻辑接线、退档判定只有一处、 两档都超限必拒、解码尺寸用的是目标尺寸而非原图尺寸、模糊档搬运)。 (数字口径:按 runner 的真实条件"锚点恰好命中 1 次才算跑过"统计; 另有 4 条锚点不命中、根本没跑,不算在这 48 里。我第一次写的是"40"—— 凭记忆累加的,错了,已更正。) **未验**:本机无设备/无模拟器 ⇒ 全部观感未验(管理页排版、滑杆手感、模糊在真机上的 实际档位观感)。代码齐 ≠ 真机验过。
309 lines
18 KiB
JavaScript
309 lines
18 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 { join } from 'node:path';
|
||
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';
|
||
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`);
|
||
}
|
||
});
|