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 也不需要临时文件。 ## 两处真 bug(变异测试逼出来的,不是"新写坏的") 1. **压缩循环的第二档此前是死代码**:循环里的 break 与循环外那句 shouldRetryWithActual 互相抵消 —— 把循环里那处改成 `if (true)`(永远只压一档)整套判据照样全绿。 收成一处判定(overLimit),循环外只读结论,并加结构性判据(该函数在这条链上只许调用一次)。 2. **壁纸的模糊档一直是「只写不读」**(计划文档 §7.12 登记过):滑杆能拖、值能存、 blurStyleFor 也写了,就是**没有调用点**,壁纸一点没糊。 本次补上的调用点分两层:壁纸层 `.blur(px)` = **图片内容模糊** (与 WebUI 的 `filter: blur(var(--bg-blur))` 同一个量、同一个数,所以不需要映射表); 而那张**材质档**映射表 `blurStyleFor` 也终于有了调用点(`navMaterialFor` 内部复用它)。 `docs/HARMONY-ALIGN-PLAN.md` 的 §7.12 两行(材质 / 壁纸模糊度)已一并改准、不再互相矛盾。 ## pi 复核后**改回来的**(这一笔里我自己犯的两处,都由 pi 抓出) 1. **导航条材质一度绑定到 `bg_blur`,`bg_blur=0` 时整个消失。** 我把 `NavBar` 从固定档改成 `blurStyleFor(bgPlan.blurPx)`,而滑杆 `min: 0` 可达、 `blurStyleFor(0) === 'NONE'` ⇒ 用户把壁纸调清晰时**导航条一点材质都没有**。 而且它与本笔自己的论证**相反**:刚论证完"图片内容模糊"与"面板材质"是两个物理量, 转头把面板材质接到壁纸模糊这个输入上。 现在**分层**:`blurStyleFor` 是通用映射(**允许** NONE —— "0 px 不模糊"是它的正确语义); `navMaterialFor` 是**导航条专用、有下限**的入口(0 px ⇒ 最薄档)。 判据钉**可达性**(滑杆 0..40 每个整数 + 界外值都不许 NONE,且三档都要出现 —— 否则"恒定最薄档"会让滑杆成为死控件)。 2. **`Theme.navMaterial` 被我弄成了死令牌**,而看着它的判据**照样绿** (那条只断言"声明存在且不是 NONE" —— 守的是声明,坏的是活的调用路径)。 现在导航条真的用它;并把同文件里**只覆盖 `Theme.overlay` 一个令牌**的死令牌规则 **铺到 Theme 的全部 35 个令牌**(量**外部引用数**:只被 Theme 内部方法读、 而那个方法自己有外部调用点 ⇒ 不算死 —— `chipSpentBg` 是这种;`navMaterial` 当时 唯一的消费者是一张可整体删掉的局部表,所以必须被抓)。 ## pi 复核后**补上的**(这一笔漏掉的接线,都是我造成的) - **`test/run-all.mjs` 的 SUITE 没接两个新判据文件** ⇒ HEAD 上 `npm test` **一条判据都不跑、直接 exit 1**(套件自检 2 就是为这件事写的)。已接入, 并把两条登记进 `STATIC_ONLY`(`.ets` 要设备 ⇒ 静态欠账)。 - **`debt-visibility` 是我自伤**:那两个新文件里有 5 处"边界声明"但一次都没登记。 我当时报"2 条失败是改动前就红" —— **只对一半**:这条在父提交上是**绿的**。 我那次 `git stash push -u -- client/harmony` 的对照是**无效对照** (`-- client/harmony` 把 `client/electron/test/` 整个排除在外,新判据文件根本没被 stash), 所以两次跑都红、看着像"既有"。已按 pi 的建议改用 `git worktree` 到父提交做对照。 现在两处都登记进 `docs/DEBTS.json`(含 `static-criteria` 5→7,Go 侧同一份登记同步改)。 ## 一并修正的旧判据(都是"太宽/太窄/钉错东西",不是放宽标准) - 「模糊归属」:原文「壁纸层不许有**任何**模糊调用」把**图片内容模糊**与**面板材质** 混为一谈(WebUI 侧核实:`.app-backdrop` 的 filter 与它之上那层的 backdrop-filter 是两个不同的量)⇒ 改成按两种模糊分别钉。 - 「bgBlur 只写不读,消费侧必须为 0」:值不再成立,**形状保留**(逐文件登记 + 计数 + 理由), 标题与断言里的假话一并改掉。 - isDarkMode 那条 `/dark\s*\)/` 断的是**参数顺序**(加一个入参就误红)⇒ 改成"dark 在实参里"。 - 三条钉 `backgroundBlurStyle` **整条字面表达式**的断言 ⇒ 改成钉语义 ("用系统材质 + 材质有下限"),不再匹配那一行的字符。 ## 判据 新增 `harmony-admin.test.mjs`(22 条)、`harmony-imageprep.test.mjs`(29 条); `harmony-presets.test.mjs` 加 1 条(模糊档搬运与归一,含 `-0` 那个洞: `Math.round(-0.4)` 是 `-0` 而 `-0 < 0` 为 false ⇒ 改成判 `!(r > 0)`)。 **`node test/run-all.mjs`:22 个判据文件全部跑起来**,红的只有 1 个: `build-stamp`(`dist` 是 `a5fc86b` 上构建的,`gitRev` 对不上当前 HEAD)。 这条**不是我的代码造成的**(可证:`a5fc86b..HEAD` 之间,`srcHash` 覆盖的那批文件 ——`client/electron/src` 等——**一个都没动过**,所以 `srcHash` 没变,差的是 `gitRev`), 但也**不是"改动前就红"**:任何推进 HEAD 的提交都会让它变红,正确修法是重构建。 ## 未验(如实标注) - **本机无设备/无模拟器 ⇒ 全部观感未验**:管理页排版与卡片观感、滑杆手感、 模糊在真机上的实际档位观感、系统材质在自绘悬浮条上的实际效果。代码齐 ≠ 真机验过。 - 预设档**没有**上模糊(壁纸在预设档下是一叠自绘矩形,系统材质对它不生效)—— 这是我**主动收的范围**,不是漏,真机看一眼再决定要不要补。 - **Go 侧的 `debt_registry_test.go` 我没能跑**(沙箱里没有 Go 模块缓存,`go test` 起不来), 只做了 `gofmt` 校验;那处改动是一行 `Count: 5 → 7`。
404 lines
27 KiB
JavaScript
404 lines
27 KiB
JavaScript
// P5 悬浮玻璃导航:判据钉"点得到、点对了、没盖住内容",不钉观感。
|
||
//
|
||
// 这一期换掉的是**条**(系统 `Tabs` 的 bar → 自绘悬浮玻璃条),信息架构没动:
|
||
// 内容仍按 `currentIndex` 挂载,两个平级项仍是 通信 / 联系人。
|
||
// 所以判据分四类:
|
||
// ① 点击:每个导航项点下去落到**它自己**那个 index(配对,不是"存在 onClick");
|
||
// ② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人,不是挂了但没变);
|
||
// ③ 命中区:≥44vp —— 从 `NavItems.ts` **导入数值**判,不拿正则去源码里猜;
|
||
// ④ 悬浮与让位:条是自绘的浮动层(留白 + 圆角 + 系统材质),
|
||
// 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。
|
||
import { code, prose, stripComments } from './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';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = join(HERE, '..', '..', '..');
|
||
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||
const NAV_TS = join(HARMONY_ETS, 'model/NavItems.ts');
|
||
|
||
const read = p => prose(join(HARMONY_ETS, p));
|
||
/** 剥掉注释与字符串,只看真代码(断言"代码里有什么"时必须这样读) */
|
||
/** 取某个 `@Builder` 的正文(按行切到下一个成员) */
|
||
function builderBody(src, signature) {
|
||
const lines = src.split('\n');
|
||
const start = lines.findIndex(l => l.includes(signature));
|
||
assert.ok(start > 0, `要能找到 ${signature}`);
|
||
let stop = lines.length;
|
||
for (let i = start + 1; i < lines.length; i++) {
|
||
if (/^ (@Builder|build\()/.test(lines[i])) { stop = i; break; }
|
||
}
|
||
return lines.slice(start, stop).join('\n');
|
||
}
|
||
|
||
/**
|
||
* 取 `from` 处第一个 `{` 到**配对**的 `}` 之间的正文。
|
||
*
|
||
* ⚠️ 我第一版用的是非贪婪正则 `\{([\s\S]*?)\}`:它在
|
||
* `CommPage({ bgActive: this.bgActive })` 的**对象字面量** `}` 上就收尾了,
|
||
* 于是"else 分支里有没有 ContactsTab"被判成没有 —— 又是我自己那条
|
||
* "判结构要配对/解析,不要窗口"(判据也被它咬)。这里按花括号配对取。
|
||
*/
|
||
function braceBody(src, from) {
|
||
const at = src.indexOf('{', src.indexOf(from));
|
||
assert.ok(at > 0, `要能找到 ${from} 后面的 {`);
|
||
let depth = 0;
|
||
for (let i = at; i < src.length; i++) {
|
||
if (src[i] === '{') depth++;
|
||
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(at + 1, i); }
|
||
}
|
||
assert.fail(`${from} 的花括号没有闭合`);
|
||
}
|
||
|
||
const N = await import(NAV_TS);
|
||
const main = read('pages/MainPage.ets');
|
||
|
||
test('① 点击:每个导航项点下去落到**它自己**那个 index(配对,不是"有 onClick")', () => {
|
||
/*
|
||
* 这条刻意不写成"存在 onClick" —— 那是最容易被满足、也最容易骗人的形状:
|
||
* 点哪个都赋 0 也能过。这里要求**每一项的点击目标与它的项下标配对**:
|
||
* 点击处理器里赋的值,必须来自这一项自己的 index(ForEach 的第二个参数),
|
||
* 并且过 `normalizeNavIndex` 归一化(与内部页签同一套纪律:不直接赋值)。
|
||
*/
|
||
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
|
||
assert.ok(item.length > 100, '要能取到导航项的正文');
|
||
/*
|
||
* 点击处理器是 `() => { this.currentIndex = normalizeNavIndex(index); }`。
|
||
* 取赋值语句并断言它**同时**用到 `index` 与归一化函数 ——
|
||
* 只看"出现过 normalizeNavIndex"不够(可以写 `normalizeNavIndex(0)` 骗过去),
|
||
* 所以要求括号里的实参就是 `index`。
|
||
*/
|
||
const assigns = [...item.matchAll(/this\.currentIndex\s*=\s*([^;]+);/g)].map(m => m[1].trim());
|
||
assert.equal(assigns.length, 1, `导航项里应当恰好有一次点击赋值(实际 ${assigns.length} 次)`);
|
||
assert.match(assigns[0], /normalizeNavIndex\(\s*index\s*\)/,
|
||
`点击要落到本项自己的 index 并过归一化,现在赋的是:${assigns[0]}`);
|
||
// 处理器必须挂在**这一项**上(不是别的成员上)
|
||
assert.match(item, /\.onClick\(\(\)\s*=>\s*\{[\s\S]{0,120}?this\.currentIndex/, '点击处理器要挂在本项上');
|
||
// ForEach 要真的把 index 传进来(否则上面那句 index 无从谈起)
|
||
const bar = builderBody(main, 'NavBar() {');
|
||
assert.match(bar, /ForEach\(NAV_ITEMS,\s*\(item: NavItem,\s*index: number\)/, 'ForEach 要带 index 参数');
|
||
assert.match(bar, /this\.NavItem\(item,\s*index\)/, '要把每一项(连同 index)交给导航项 builder');
|
||
});
|
||
|
||
test('② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人)', () => {
|
||
/*
|
||
* 换了条之后最容易出的错是"点了没反应"——点击改了状态,但内容仍写死挂第一个页面。
|
||
* 所以把 index → 页面的映射钉死:0 → CommPage、2 → ContactsTab、1 → CalendarPage,
|
||
* 且三边都要收到 `bgActive`(背景开着时让出页面底,否则壁纸被内容盖住)。
|
||
*
|
||
* 日历与另两个不同:它**常驻挂载**(不是 if/else 的一支),用 `visibility` 控制显示 ——
|
||
* 理由写在 `CalendarPage.ets` 与 build() 的注释里(today 跨午夜、保住"在看哪个月")。
|
||
* 所以这里断言的是**两件事**:索引→页面的配对,以及日历那一支确实是"常驻 + 可见性开关"。
|
||
*/
|
||
const stack = main.slice(main.indexOf('Stack({ alignContent: Alignment.Bottom })'));
|
||
// 只在**根 build()** 里数分派(页面内部也有 if/else,扫全文会数错)
|
||
const root = stack.slice(0, stack.indexOf('this.NavBar()') + 100);
|
||
const dispatch = [...root.matchAll(/if \(this\.currentIndex === 0\)/g)];
|
||
assert.equal(dispatch.length, 1, `根里应当恰好一处"按 index 分派内容"(实际 ${dispatch.length} 处)`);
|
||
const first = braceBody(root, 'if (this.currentIndex === 0)');
|
||
const second = braceBody(root.slice(root.indexOf('if (this.currentIndex === 0)')), 'else');
|
||
assert.match(first, /CommPage\(\{ bgActive: this\.bgActive \}\)/, 'index 0 要挂通信页');
|
||
assert.match(second, /ContactsTab\(\{ bgActive: this\.bgActive \}\)/, 'index 2 要挂联系人页');
|
||
/*
|
||
* 联系人那一支必须**显式绑在 index 2**。注意条件在 `else if (...)` 里,
|
||
* 不在 `else` 的**花括号正文**里 —— braceBody 只取正文,所以这条要看 root 原文。
|
||
* 写成"否则就挂联系人"的后果:以后再加一项,新索引会被它静默接住(点了显示联系人)。
|
||
*/
|
||
assert.match(root, /else if \(this\.currentIndex === 2\)/, '联系人必须显式绑在 index 2,不许用 else 兜底');
|
||
/*
|
||
* 日历(index 1):**常驻挂载 + visible 由索引驱动 + Visibility 开关**,三样缺一不可。
|
||
* · `visible: this.currentIndex === 1` 是 `@Watch` 的触发源 —— 不带它,today 就不会在
|
||
* pane 变可见时重算(DEBTS 的 calendar-today-recompute);
|
||
* · `visibility(...=== 1 ? Visible : None)` 是显示开关;常驻 + 不隐藏 = 三个 pane 叠在一起。
|
||
*/
|
||
assert.match(root, /CalendarPage\(\{ bgActive: this\.bgActive, visible: this\.currentIndex === 1 \}\)/,
|
||
'index 1 要挂日历页,并把"是否可见"传下去(它是 today 重算的触发源)');
|
||
assert.match(root, /\.visibility\(this\.currentIndex === 1 \? Visibility\.Visible : Visibility\.None\)/,
|
||
'常驻挂载就要用 visibility 控制显示');
|
||
assert.ok(!/if \(this\.currentIndex === 1\)/.test(root),
|
||
'日历不该写成 if/else 的一支:它是常驻 pane(卸载重挂会丢掉"在看哪个月",且 today 只能靠挂载重算)');
|
||
/*
|
||
* 分派必须**穷尽** NAV_ITEMS。2026-09-14 日历入口上架时这条**如约变红**
|
||
* (当时是 `length === 2` + 两支 if/else)—— 加项必须同时加分派,否则第 3 项点了没内容。
|
||
* 现在的形状与"三项"绑定:通信(0) / 日历(1) / 联系人(2),日历那一支**常驻挂载**。
|
||
*/
|
||
assert.equal(N.NAV_ITEMS.length, 3,
|
||
`NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项,与分派(通信/日历/联系人)不匹配 —— 加了项必须同时加分派`);
|
||
// 内容挂在浮动条**下面**(先内容后条),否则条会被内容盖住、点不到
|
||
const navAt = stack.indexOf('this.NavBar()');
|
||
const contentAt = stack.indexOf('CommPage({ bgActive: this.bgActive })');
|
||
assert.ok(navAt > contentAt, '浮动条要在内容之后渲染(浮在上层)');
|
||
});
|
||
|
||
test('③ 命中区 ≥44vp:判的是数值本身(从 NavItems.ts 导入,不猜源码)', () => {
|
||
assert.ok(N.NAV_ITEM_MIN_HIT >= 44, `命中区下限必须 ≥44vp(现在 ${N.NAV_ITEM_MIN_HIT})`);
|
||
assert.ok(N.NAV_BAR_HEIGHT >= N.NAV_ITEM_MIN_HIT,
|
||
`条高(${N.NAV_BAR_HEIGHT})不得小于命中区下限(${N.NAV_ITEM_MIN_HIT})`);
|
||
// 数值写了还要**用上**:项必须有下限约束,且高度取条高
|
||
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
|
||
assert.match(item, /\.constraintSize\(\{\s*minHeight:\s*NAV_ITEM_MIN_HIT,\s*minWidth:\s*NAV_ITEM_MIN_HIT\s*\}\)/,
|
||
'导航项要显式声明命中区下限(minHeight 与 minWidth 都要)');
|
||
assert.match(item, /\.height\(NAV_BAR_HEIGHT\)/, '导航项高度取条高');
|
||
assert.match(item, /\.layoutWeight\(1\)/, '两项要等分条宽(否则命中区宽度靠内容撑,会小于下限)');
|
||
// 条自身也要够高
|
||
const bar = builderBody(main, 'NavBar() {');
|
||
assert.match(bar, /\.height\(NAV_BAR_HEIGHT\)/, '条高取同一常量');
|
||
});
|
||
|
||
/*
|
||
* ③ 的**来源**那一半(pi 2026-09-14;配对规则见 CRITERIA.md §6.7.0):
|
||
* 上面的"≥44"判的是**值**,但值对不等于"组件用的是那个值" —— 组件自己写死一个
|
||
* 够大的数字照样绿,而共享常量被改小时它不会跟着变。所以这一条判**来源**:
|
||
* 应用点必须引用常量,不许出现裸数字。
|
||
*/
|
||
test('③-b 命中区是**来源**判据:`.ets` 不许自己写数字,必须引用 NAV_ITEM_MIN_HIT', () => {
|
||
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
|
||
const bare = /(minHeight|minWidth|height|width):\s*\d+/.exec(item);
|
||
assert.equal(bare, null,
|
||
`导航项里出现了裸数字 \`${bare?.[0]}\` —— 命中区/条高必须引用 NavItems.ts 里的常量:` +
|
||
`值判据(≥44)管"数字够不够大",来源判据(这条)管"用的是不是同一个数字"。` +
|
||
`两条合起来才闭合(CRITERIA.md §6.7.0)。`);
|
||
});
|
||
|
||
/*
|
||
* 让位高度必须**派生**(pi 2026-09-14 §6:`76` 不该是并列常量)。
|
||
*
|
||
* 判的是 NavItems.ts 里的**声明**:算式里要出现条高与离底留白两个常量 ——
|
||
* 写死 `= 76` 就会在"哪天把条高改成 64"时静默失配(内容被压住,看得见点不到)。
|
||
* 顺带钉住:算式里不许出现裸数字(余量要有名字)。
|
||
*/
|
||
test('④-b 内容让位高度是**派生**的,不是并列常量', () => {
|
||
const src = prose(NAV_TS);
|
||
const decl = /export const NAV_CONTENT_RESERVE: number = ([^;]+);/.exec(src);
|
||
assert.ok(decl, 'NAV_CONTENT_RESERVE 的声明要能被判据读到(判据跟着改)');
|
||
const expr = decl[1];
|
||
assert.match(expr, /NAV_BAR_HEIGHT/, `让位高度必须含条高,现在是 \`${expr}\``);
|
||
assert.match(expr, /NAV_BAR_BOTTOM/, `让位高度必须含离底留白,现在是 \`${expr}\``);
|
||
const bare = /[^A-Z_]\d+/.exec(expr.replace(/NAV_[A-Z_]+/g, ''));
|
||
assert.equal(bare, null,
|
||
`算式里还有裸数字 \`${bare?.[0]}\` —— 余量也要起名字(NAV_CONTENT_GAP),否则"该让多少"永远是谜`);
|
||
});
|
||
|
||
test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),内容底部让出的高度 ≥ 条高 + 离底留白', () => {
|
||
const bar = builderBody(main, 'NavBar() {');
|
||
/*
|
||
* "悬浮"是可判的形状:四周留白 + 圆角 + 浮在内容之上。
|
||
* 这三样缺一样就不再是 WebUI 那条玻璃条了(贴边的全宽条 = 又变回系统 bar 的样子)。
|
||
*/
|
||
assert.match(bar, /\.margin\(\{\s*left:\s*NAV_BAR_SIDE,\s*right:\s*NAV_BAR_SIDE,\s*bottom:\s*NAV_BAR_BOTTOM\s*\}\)/,
|
||
'四周要留白(左右 + 离底),贴边就不是悬浮');
|
||
assert.match(bar, /\.borderRadius\(NAV_BAR_RADIUS\)/, '要圆角(胶囊)');
|
||
assert.match(bar, /\.backgroundBlurStyle\(NAV_MATERIAL_OF\[navMaterialFor\(this\.bgPlan\.blurPx\)\] \?\? Theme\.navMaterial\)/,
|
||
'材质用系统档次,不手写 alpha(且**不跟随** `bg_blur` —— 理由见本文件末那条"可达性"判据)');
|
||
/*
|
||
* 色值检查要读**剥掉注释**的正文 —— 条上的注释正好写着"原来那两个手写玻璃色值",
|
||
* 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。
|
||
* 与规范里那条一致:判"代码里有什么"读剥注释的源码,判"理由写清了没"读原文。
|
||
*/
|
||
assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(stripComments(bar)),
|
||
`条上不许出现手写色值(深浅两套由系统给),实际:${(stripComments(bar).match(/#[0-9A-Fa-f]{6,8}/g) || []).join('、')}`);
|
||
|
||
// 内容让位:让出的高度必须够(否则最后一行压在条底下 —— 看得见、点不到)
|
||
assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM,
|
||
`内容让位(${N.NAV_CONTENT_RESERVE})必须 ≥ 条高 + 离底留白(${N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM})`);
|
||
assert.match(main, /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/, '内容底部要让出这段高度');
|
||
/*
|
||
* 让位要生效在**内容**上,不是条上(条自己 padding 不解决遮挡)。
|
||
*
|
||
* 这里原来是 `main.slice(定位).slice(0, 400)` 的**窗口式**断言 —— 2026-09-14 加日历
|
||
* 那一支时它红了:内容层里多了一个 pane,`.padding(...)` 落到了 400 字符之外。
|
||
* 判的是"同一件事",红的原因却只是"变长了",正是本仓库反复记的窗口式判据。
|
||
* 改成**按花括号配对取正文**:内容层那个 Column 的 `{...}` 里必须出现让位。
|
||
*/
|
||
const contentOpen = main.indexOf('Column() {\n if (this.currentIndex === 0)');
|
||
assert.ok(contentOpen > 0, '要能找到挂载内容的那层 Column');
|
||
// `.padding(...)` 是**链在 `}` 之后**的,所以不能只看 {} 里面 —— 从配对结束处往后取到 NavBar 之前
|
||
const braceAt = main.indexOf('{', contentOpen);
|
||
let depth = 0;
|
||
let end = -1;
|
||
for (let i = braceAt; i < main.length; i++) {
|
||
if (main[i] === '{') depth++;
|
||
else if (main[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
|
||
}
|
||
assert.ok(end > 0, '内容层的花括号要闭合');
|
||
const chain = main.slice(end, main.indexOf('this.NavBar()', end));
|
||
assert.match(chain, /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/,
|
||
'让位要加在挂载内容的那层上(链在那个 Column 的 } 之后)');
|
||
});
|
||
|
||
test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且平级项是 通信/日历/联系人', () => {
|
||
/*
|
||
* 反面:如果只是"加了自绘条但仍留着系统 bar",界面上会出现两条导航(且系统那条
|
||
* 依然贴边、依然占位),这正是这一期要消除的东西。所以断言根导航里**没有**
|
||
* `Tabs(` / `TabContent` / `tabBar(`。
|
||
* 注意:**通信页内部**的三栏仍然用系统 `Tabs`(那是页面内部页签,不是根导航)——
|
||
* 所以这条只在"根组件"的范围内断言,别扫全文。
|
||
*/
|
||
const root = main.slice(main.indexOf('build() {\n /*\n * 底部三项'));
|
||
assert.ok(root.length > 300, '要能取到根 build() 的正文');
|
||
const navCode = stripComments(root);
|
||
assert.ok(!/\bTabs\(/.test(navCode), '根导航里不该再有系统 Tabs');
|
||
assert.ok(!/TabContent/.test(navCode), '根导航里不该再有 TabContent(内容改为按 index 挂载)');
|
||
assert.ok(!/\.tabBar\(/.test(navCode), '根导航里不该再有 tabBar()');
|
||
// 反面之二:也不许把旧 builder 留着不用(留着就是死代码,下一个人会以为它还在生效)
|
||
assert.ok(!/TabBarBuilder/.test(stripComments(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里');
|
||
|
||
// 平级项:通信/日历/联系人(日历是 P6 第 1 步做完后上架的,顺序与 WebUI 一致)
|
||
assert.deepEqual(N.NAV_ITEMS.map(i => i.label), ['通信', '日历', '联系人'], '平级项与顺序');
|
||
assert.equal(new Set(N.NAV_ITEMS.map(i => i.key)).size, N.NAV_ITEMS.length, '键要唯一(ForEach 的 key)');
|
||
assert.deepEqual(N.NAV_ITEMS.map(i => N.navKeyAt(N.NAV_ITEMS.indexOf(i))), N.NAV_ITEMS.map(i => i.key),
|
||
'navKeyAt 与清单一致');
|
||
// 归一化:越界/负数都退回第一项(点击与外部传值都过它)
|
||
assert.equal(N.normalizeNavIndex(-1), 0);
|
||
assert.equal(N.normalizeNavIndex(99), 0);
|
||
assert.equal(N.normalizeNavIndex(1), 1);
|
||
assert.equal(N.normalizeNavIndex(2), 2, '第三项(联系人)也必须归一到自己');
|
||
assert.throws(() => N.navKeyAt(1.5) === undefined, undefined, '非整数下标不该悄悄生效');
|
||
});
|
||
|
||
test('★ 玻璃只在两处、且这一处是"背后有可变内容"(GLASS 登记的放行条件)', () => {
|
||
/*
|
||
* pi 撤回"模糊只由壁纸层负责"后给的是两条:①同一张底只许糊一次;
|
||
* ②模糊该出现在"背后是可变内容"的层。悬浮条背后是**会滚动的内容** ✓,
|
||
* 而壁纸层背后什么都没有 → 壁纸层不许有材质/模糊。
|
||
* 这里钉的是这一期的分工,跨端的登记册在 `cross-client-theme.test.mjs`(GLASS_REGISTRY)。
|
||
*/
|
||
const wallpaper = builderBody(main, 'WallpaperLayer() {');
|
||
/*
|
||
* ★ 2026-09-15 修正(P4c 补上"壁纸真的会糊"之后):原来这里写的是
|
||
* 「壁纸层不许有**任何**模糊调用」。那句把两种不同的物理量混为一谈 ——
|
||
* 壁纸层要做的是 **图片内容模糊**(`.blur(px)`,与 WebUI 的
|
||
* `filter: blur(var(--bg-blur))` 同一个量),**不许**做的是 **面板材质**
|
||
* (`backgroundBlurStyle`:作用对象是"背后的内容",而壁纸层背后什么都没有,
|
||
* 那才是"给一张糊过的底再糊一遍"的形状)。理由与出处见
|
||
* `harmony-appearance.test.mjs` 里那条"模糊归属"。判定改成按**两种模糊**分别钉。
|
||
*/
|
||
const wallpaperCode = stripComments(wallpaper);
|
||
assert.ok(!/backgroundBlurStyle/.test(wallpaperCode),
|
||
'壁纸层不许有**面板材质**(作用在背后内容上;壁纸层背后什么都没有)');
|
||
const imgBlurs = wallpaperCode.match(/\.blur\(this\.bgPlan\.blurPx\)/g) || [];
|
||
assert.equal(imgBlurs.length, 1,
|
||
`壁纸层的**图片内容模糊**只许一次(现在 ${imgBlurs.length} 次)—— 同一张底糊两遍 = 更脏更掉帧`);
|
||
const bar = builderBody(main, 'NavBar() {');
|
||
assert.match(bar, /backgroundBlurStyle\(NAV_MATERIAL_OF\[navMaterialFor\(this\.bgPlan\.blurPx\)\] \?\? Theme\.navMaterial\)/,
|
||
'悬浮条必须有系统材质(背后是滚动内容),且**经 navMaterialFor 保底**(blur=0 也不许变透明)');
|
||
// 理由要写在**原文**(注释会被剥掉,而理由就在注释里)
|
||
const mainRaw = read('pages/MainPage.ets');
|
||
const navDoc = mainRaw.slice(mainRaw.indexOf('底部导航:**自绘的悬浮玻璃条**'), mainRaw.indexOf('@Builder\n NavBar() {'));
|
||
assert.match(navDoc, /会滚动的内容|滚动内容/, '要写清"为什么这一处可以有材质"(背后是可变内容)');
|
||
assert.match(navDoc, /GLASS_REGISTRY/, '要指名登记册,后人查得到放行流程');
|
||
});
|
||
|
||
/**
|
||
* ⑤ 选中态:**只换颜色**,且**图标与文字都换** —— 与 WebUI 同一套表达。
|
||
*
|
||
* 用户(2026-09-14):「同时底部导航栏选中对应的文字和图标变色即可」。
|
||
* 这条要求有两半,缺任何一半都会变成"假同步":
|
||
* · **只有颜色**:多一个背景块或指示条就不是"即可"了(WebUI 侧刚删掉这两样,
|
||
* `NarrowNav.tsx` 的 `bg-blue-400` 指示条与 `.nav-item` 的 nav-active-bg);
|
||
* · **图标也要变**:鸿蒙侧原来只给 label 上了色,图标保持默认色 ——
|
||
* 结构上"存在选中态"、看上去却像选中了一半。图标是文本字形(`item.icon`),
|
||
* 所以它必须和 label 过**同一个**三元式。
|
||
*
|
||
* 跨端对齐:这条与 WebUI 的 `nav-merge.test.mjs ④` 是同一条要求的两端实现,
|
||
* 两边各自钉住自己的写法(`cross-client-*` 那几条钉的是共享数值,不是这里)。
|
||
*/
|
||
test('⑤ 选中态只换颜色:图标与文字都变色,且不引入背景/指示条', () => {
|
||
const item = builderBody(main, 'NavItem(item: NavItem, index: number) {');
|
||
const itemCode = stripComments(item);
|
||
const active = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g;
|
||
const hits = itemCode.match(active) || [];
|
||
assert.equal(hits.length, 2,
|
||
`图标与文字都要过同一个选中三元式(现在 ${hits.length} 处)—— 只给文字上色就是"选中了一半"`);
|
||
// 图标(item.icon)与文字(item.label)各自紧跟着那个三元式
|
||
const icon = itemCode.slice(itemCode.indexOf('Text(item.icon)'), itemCode.indexOf('Text(item.label)'));
|
||
assert.match(icon, /fontColor\(this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg\)/,
|
||
'图标的 fontColor 必须跟着选中态走');
|
||
// 反面:选中态不得靠形状表达(背景 / 圆角块 / 下划线元素)
|
||
assert.ok(!/backgroundColor\([^)]*currentIndex/.test(itemCode),
|
||
'选中态不许改背景色("变色即可",形状是另一套语言)');
|
||
assert.ok(!/Divider\(|\.borderRadius\([^)]*currentIndex/.test(itemCode),
|
||
'选中态不许加指示条 / 圆角块');
|
||
// 选中色与未选中色必须真的是两个不同来源(都指向同一个令牌就成了恒等)
|
||
const theme = read('common/Theme.ets');
|
||
assert.match(theme, /static readonly navFgActive: string = Theme\.accentStrong/,
|
||
'选中色取品牌深色变体');
|
||
assert.match(theme, /static readonly navFg: Resource = \$r\('sys\.color\.ohos_id_color_text_secondary'\)/,
|
||
'未选中色取系统次要文字色(不手写色值)');
|
||
});
|
||
|
||
/** ⑤ 的变异自检:把图标那行退回"只给文字上色",判据必须红。 */
|
||
test('★ ⑤ 变异自检:图标不上色(改造前的写法)必须被判红', () => {
|
||
const oldIconLine = 'Text(item.icon).fontSize(20)';
|
||
const itemCode = `Column() {\n ${oldIconLine}\n Text(item.label)\n .fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)\n }`;
|
||
const hits = itemCode.match(/this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g) || [];
|
||
assert.equal(hits.length, 1, '旧写法只有 label 上色 ⇒ ⑤ 的"必须两处"会命中它');
|
||
/*
|
||
* 判定窗口必须与 ⑤ **完全同一条规则**:从 `Text(item.icon)` 切到下一个 `Text(`。
|
||
* 我第一版在这里写的是 `Text\(item\.icon\)[\s\S]{0,80}fontColor` —— 那个窗口
|
||
* 把**下一行 label 的** fontColor 也圈进来了,于是"旧写法没有图标上色"这条
|
||
* 自检自己绿了(正是本仓库反复记的"窗口式判据")。判据被它咬了一次。
|
||
*/
|
||
const icon = itemCode.slice(itemCode.indexOf('Text(item.icon)'), itemCode.indexOf('Text(', itemCode.indexOf('Text(item.icon)') + 1));
|
||
assert.ok(!/fontColor/.test(icon), `旧写法里图标没有 fontColor ⇒ ⑤ 的图标那一半会命中它(切片=${JSON.stringify(icon)})`);
|
||
});
|
||
|
||
/**
|
||
* ★ 导航条材质**在每一个可达 `bg_blur` 下都存在**(可达性判据,不是源码形状判据)。
|
||
*
|
||
* 这条的来历:我一度把导航条的档位**直接**接到用户的 `bg_blur` 上
|
||
* (`blurStyleFor(this.bgPlan.blurPx)`),而 `blurStyleFor(0)` 是 `'NONE'` ——
|
||
* 用户把模糊滑杆拖到 0(**要壁纸清晰**)时,**导航条一点材质都没有**。pi 复核抓出来的。
|
||
*
|
||
* 为什么原来那几条看不见它:它们钉的是"`Theme.navMaterial` **声明**了、且不是 NONE"
|
||
* 与"某一行出现了某个表达式" —— 声明是好的,坏的是**那条活的调用路径**。
|
||
* 判据名替实现作证。
|
||
*
|
||
* ── 这条判据自己先错过一次,记在这里 ──
|
||
* 第一版写成"`blurStyleFor` 在 0..40 上**不许**返回 NONE"。**那是错的**:
|
||
* `blurStyleFor` 是**通用映射**,"0 px ⇒ 不模糊"是它的**正确语义**,
|
||
* 该函数必须保留 `'NONE'` 这一档。写"不许 NONE"等于要求"用户把壁纸调清晰时
|
||
* 还得给壁纸留一点糊"—— 正是用户不要的。所以那条判据**恒红**,是我的判据错,不是代码错。
|
||
*
|
||
* 正确的形状是**分层**:
|
||
* · `blurStyleFor`:通用映射,**允许** NONE(它的边界判据在 `harmony-appearance`);
|
||
* · `navMaterialFor`:**导航条专用**入口,**有下限**(材质不低于最薄档)——
|
||
* 因为"导航条是玻璃"是设计不变量,而壁纸可以不模糊。
|
||
* 这条判据钉的是后者:**在可达输入上不许 NONE**;前者不许被导航条直接用。
|
||
*/
|
||
test('★ 导航条材质在**每一个可达的 bg_blur** 下都不为 NONE(可达性判据)', async () => {
|
||
const { pathToFileURL } = await import('node:url');
|
||
const A = await import(pathToFileURL(join(HARMONY_ETS, 'model', 'Appearance.ts')).href);
|
||
assert.equal(typeof A.navMaterialFor, 'function',
|
||
'要有**导航条专用**入口 `navMaterialFor`(与通用的 blurStyleFor 分开:一个有下限、一个允许 NONE)');
|
||
|
||
// 可达输入:滑杆 step=1 + 服务端 clamp 到 0~40 ⇒ 0..40 的每个整数
|
||
const reachable = [];
|
||
for (let px = 0; px <= 40; px++) reachable.push(px);
|
||
// 界外也要挡住(服务端/别的客户端可能送来越界值)
|
||
reachable.push(-1, -100, 41, 999, NaN);
|
||
|
||
const bad = reachable.filter((px) => A.navMaterialFor(px) === 'NONE');
|
||
assert.deepEqual(bad, [],
|
||
`★ 这些 bg_blur 取值会让导航条材质变成 NONE(= 没有材质,"玻璃"名存实亡):${bad.join('、')}`);
|
||
|
||
/*
|
||
* 而且它必须**真的跟着走**:不许"恒定返回最薄档"糊弄过去 ——
|
||
* 那样判据全绿,而用户把滑杆从 0 拉到 40 时导航条**毫无变化**(滑杆又成了死控件)。
|
||
* 所以钉住两端的**档位**,并要求整段上**至少出现 3 个不同档位**(薄/中/厚都用上)。
|
||
*/
|
||
assert.equal(A.navMaterialFor(0), 'COMPONENT_THIN', '0 px 时导航条取**最薄档**(不是 NONE)');
|
||
assert.equal(A.navMaterialFor(40), 'COMPONENT_THICK', '拉满时取最厚档');
|
||
const tiers = new Set(reachable.map((px) => A.navMaterialFor(px)));
|
||
assert.equal(tiers.size, 3,
|
||
`可达输入上应当出现 3 档(薄/中/厚),实际 ${tiers.size} 档:${[...tiers].join('、')} —— ` +
|
||
'少于 3 档说明滑杆在某一段上是死控件(拉了没反应)');
|
||
|
||
// 导航条那一处必须真的用它(否则上面那条判的是一个没人调的函数)
|
||
const bar = builderBody(main, 'NavBar() {');
|
||
assert.match(bar, /\.backgroundBlurStyle\(NAV_MATERIAL_OF\[navMaterialFor\(this\.bgPlan\.blurPx\)\] \?\? Theme\.navMaterial\)/,
|
||
'导航条的材质要经 `navMaterialFor`(有下限)—— 直接用通用的 `blurStyleFor` 会让 0 px 把玻璃弄没');
|
||
});
|