跨端: P5 悬浮玻璃导航取代系统 TabBar —— 自绘浮动条 + 命中区 ≥44vp + 内容让位
(subject 原为「跨端(P5): …」—— 被自己的 commit-hygiene 判据判红:约定是 subject 里带 `跨端:`,而 `跨端(P5):` 让字面 grep 找不到。判据是对的,改提交不改成判据。)
This commit is contained in:
202
client/electron/test/harmony-nav.test.mjs
Normal file
202
client/electron/test/harmony-nav.test.mjs
Normal file
@ -0,0 +1,202 @@
|
||||
// P5 悬浮玻璃导航:判据钉"点得到、点对了、没盖住内容",不钉观感。
|
||||
//
|
||||
// 这一期换掉的是**条**(系统 `Tabs` 的 bar → 自绘悬浮玻璃条),信息架构没动:
|
||||
// 内容仍按 `currentIndex` 挂载,两个平级项仍是 通信 / 联系人。
|
||||
// 所以判据分四类:
|
||||
// ① 点击:每个导航项点下去落到**它自己**那个 index(配对,不是"存在 onClick");
|
||||
// ② 挂载:index 决定挂哪个页面(点第 2 项必须挂联系人,不是挂了但没变);
|
||||
// ③ 命中区:≥44vp —— 从 `NavItems.ts` **导入数值**判,不拿正则去源码里猜;
|
||||
// ④ 悬浮与让位:条是自绘的浮动层(留白 + 圆角 + 系统材质),
|
||||
// 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。
|
||||
import { readFileSync } from 'node:fs';
|
||||
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 => readFileSync(join(HARMONY_ETS, p), 'utf8');
|
||||
/** 剥掉注释与字符串,只看真代码(断言"代码里有什么"时必须这样读) */
|
||||
const code = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
||||
/** 取某个 `@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,否则 → ContactsTab,
|
||||
* 且两边都要收到 `bgActive`(背景开着时让出页面底,否则壁纸被内容盖住)。
|
||||
*/
|
||||
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 1 要挂联系人页');
|
||||
/*
|
||||
* 分派必须只有这两支,且必须**穷尽** NAV_ITEMS —— 现在只有两项,用 if/else 就够了;
|
||||
* 一旦 NAV_ITEMS 变长,这条会红并提醒改成按清单挂载(否则第 3 项点了没内容)。
|
||||
*/
|
||||
assert.equal(N.NAV_ITEMS.length, 2,
|
||||
`NAV_ITEMS 现在是 ${N.NAV_ITEMS.length} 项,而内容分派只有 if/else 两支 —— 加了项必须同时加分派`);
|
||||
// 内容挂在浮动条**下面**(先内容后条),否则条会被内容盖住、点不到
|
||||
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\)/, '条高取同一常量');
|
||||
});
|
||||
|
||||
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\(Theme\.navMaterial\)/, '材质用系统档次,不手写 alpha');
|
||||
/*
|
||||
* 色值检查要读**剥掉注释**的正文 —— 条上的注释正好写着"原来那两个手写玻璃色值",
|
||||
* 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。
|
||||
* 与规范里那条一致:判"代码里有什么"读剥注释的源码,判"理由写清了没"读原文。
|
||||
*/
|
||||
assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(code(bar)),
|
||||
`条上不许出现手写色值(深浅两套由系统给),实际:${(code(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 不解决遮挡)
|
||||
const contentPad = main.slice(main.indexOf('Column() {\n if (this.currentIndex === 0)'));
|
||||
assert.match(contentPad.slice(0, 400), /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/,
|
||||
'让位要加在挂载内容的那层上');
|
||||
});
|
||||
|
||||
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 = code(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(code(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里');
|
||||
|
||||
// 平级项仍是两项且顺序不变(信息架构这一期不动)
|
||||
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.throws(() => N.navKeyAt(1.5) === undefined, undefined, '非整数下标不该悄悄生效');
|
||||
});
|
||||
|
||||
test('★ 玻璃只在两处、且这一处是"背后有可变内容"(GLASS 登记的放行条件)', () => {
|
||||
/*
|
||||
* pi 撤回"模糊只由壁纸层负责"后给的是两条:①同一张底只许糊一次;
|
||||
* ②模糊该出现在"背后是可变内容"的层。悬浮条背后是**会滚动的内容** ✓,
|
||||
* 而壁纸层背后什么都没有 → 壁纸层不许有材质/模糊。
|
||||
* 这里钉的是这一期的分工,跨端的登记册在 `cross-client-theme.test.mjs`(GLASS_REGISTRY)。
|
||||
*/
|
||||
const wallpaper = builderBody(main, 'WallpaperLayer() {');
|
||||
assert.ok(!/backgroundBlurStyle/.test(wallpaper), '壁纸层不许有材质(同一张底糊两遍)');
|
||||
assert.ok(!/blur\(/i.test(code(wallpaper)), '壁纸层不许有任何模糊调用');
|
||||
const bar = builderBody(main, 'NavBar() {');
|
||||
assert.match(bar, /backgroundBlurStyle\(Theme\.navMaterial\)/, '悬浮条必须有系统材质(背后是滚动内容)');
|
||||
// 理由要写在**原文**(注释会被剥掉,而理由就在注释里)
|
||||
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/, '要指名登记册,后人查得到放行流程');
|
||||
});
|
||||
Reference in New Issue
Block a user