跨端判据: 导航判据第一次在宽屏下跑就误报 —— 它只认底栏,而宽屏导航是左侧栏

三折叠展开态(3184×2232,宽高比 1.43)实测 `harmony-nav` 的行为判据变红:

    底栏要渲染出 ≥1 个带文字标签的可点导航项(实际 0)

## 红得没错,但没用

`navItemsOf` 里写着 `+m[2] < screenH * 0.75 ⇒ 丢掉`,也就是**只找屏幕下 1/4 里的
可点容器**。而宽屏按设计导航就是**左侧栏**(`WideSidebar`),实测形状
`Column [28,985][201,1380]` —— 一条都落不进"下 1/4",于是取到 0,
判据转身说"导航没挂 / 被盖住 / 全不可点"。

★ 这条判据**从来没有在宽屏下跑过**(宽屏分支此前从未真正运行 —— 这正是本轮
  建三折叠模拟器要解锁的东西)。第一次跑就误报,说明"红"也需要先确认
  它在判什么,不能一看红就去改被测代码。

## 修:分模式判,且**两侧的契约本来就不同**

| | 窄屏(底栏) | 宽屏(左侧栏) |
|---|---|---|
| 形状 | 屏幕下 1/4 的可点容器 | 屏幕左 1/6 内、宽 < W/4、高 > H*0.08 的可点容器 |
| 文字 | **必须有**(标签就是底栏的主体) | **必须没有** |

侧栏无文字不是"放宽",是两侧本来就不一样:WebUI 的 `Sidebar`(60px)是纯图标轨,
且用户 2026-09-16 明确说过「底部导航栏不允许有文字」⇒ 侧栏同口径。
实测侧栏项子树只有 3 个节点 `Column → Stack → Path`,**没有任何 Text**。
拿"有文字"去要求纯图标轨,只会永远红。

⇒ 宽屏改为判**图标确实画出来了**(每个导航项子树里有 `Path`)且**不该有 Text**
(有文字就说明有人往纯图标轨里塞了标签 —— 那正是被否掉的方案)。

★ 两个条件分开写,**不能**只把"下 1/4"放宽成"下 1/4 或左 1/6":宽屏下内容区
  卡片也在左侧(x 很小)且可点,放宽就全被当成导航项 ⇒ "导航项数 ≥1" 恒真,
  判据等于没有。所以还要加宽度条件把内容卡片(宽 900+)排除掉。

★ 宽高比用 **> 1.2** 而不是绝对 vp:源码 `isWide` 判的是 `width >= 768`(vp),
  而 dumpLayout 给的是 px,换算要写密度 —— 而密度是设备属性,写进来就是第二份
  真相(这条判据刚因为"包名写死"吃过一次一模一样的亏)。

## 自检补上宽屏那支

`navItemsOf` 的形状判断改了之后,**宽屏那一支此前没有任何自检覆盖** ——
而自检没覆盖的分支就是下次回归不会响的那一支。按实测形状造合成树
(3184×2232、`Column [28,985][201,1380]`、子树只有 Path),并断言:
侧栏取到 2 个而不是 3 个(第 3 个是内容卡片,被误当导航项的话
"导航没挂"就永远判不出来);窄屏样本不许被误判成宽屏。

## 两个模式都实测过

- 窄屏(fold single,1008×2232,比例 0.45):底栏 4 项,标签
  通信 / 日历 / 联系人 / 我的 全部命中源码清单。
- 宽屏(fold triple,3184×2232,比例 1.43):侧栏 5 个图标项,零 Text、各有 Path。

18/18 绿。
This commit is contained in:
2026-09-18 12:20:22 +08:00
parent 4ff6b10260
commit 009ea172b7

View File

@ -838,6 +838,41 @@ function screenHeightOf(root) {
return h;
}
/** 屏幕宽度(px)—— 用来判断当前是窄屏(底栏)还是宽屏(左侧栏) */
function screenWidthOf(root) {
let w = 0;
for (const n of walk(root)) {
const m = (n.attributes?.bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (m) w = Math.max(w, +m[3]);
}
return w;
}
/**
* 当前是否处于**宽屏**(导航是左侧栏,不是底栏)。
*
* ★ 2026-09-18 加:这个判断是因为下面那条行为判据**在三折叠展开态实测变红**了 ——
* 而它红得**没错但没用**:3184px 展开态下导航按设计就是左侧栏,
* `navItemsOf` 却只找"屏幕下 1/4"里的可点容器 ⇒ 返回 0 ⇒ 判"导航没挂"。
* 也就是说这条判据**从来没有在宽屏下跑过**(宽屏分支此前从未真正运行),
* 第一次跑就误报。
*
* 阈值:源码 `MainPage.ets` 的 `isWide` 判据是 `width >= 768`(**vp**)。
* 这里拿到的是 **px**,而密度是设备属性 ⇒ 用比例判断更稳:
* 宽屏时内容区至少能放下 navbar 侧栏 + 一个窗格,实测量到的是 910vp。
* 768vp 在 3.5 密度下 ≈ 2688px。取 0.75×宽度做"左侧栏存在"的判断会
* 与窄屏的底栏判据互斥,所以直接用 wxh 两个比例:
* 宽屏 = 宽高比 > 1.2(三折叠展开 3184/2232 = 1.43;折叠 1008/2232 = 0.45)。
* ★ 刻意用**宽高比**而不是绝对 px/vp:绝对阈值要写密度,而密度是设备属性,
* 写进来就是第二份真相(这条判据刚因为"包名写死"吃过一次亏)。
*/
function isWideLayout(root) {
const w = screenWidthOf(root);
const h = screenHeightOf(root);
if (w === 0 || h === 0) return false;
return w / h > 1.2;
}
/** 某节点**子树里**(不含自己)的非空 Text 文案。图标也是 Text,所以别拿它当"标签全集"。 */
function textsUnder(node) {
const out = [];
@ -860,13 +895,44 @@ function textsUnder(node) {
*/
function navItemsOf(root) {
const screenH = screenHeightOf(root);
const wide = isWideLayout(root);
/*
* 窄屏:屏幕**下 1/4** 里的可点容器(底栏)。
* 宽屏:屏幕**左 1/6** 里、高度足够大的可点容器(左侧栏项)。
*
* ★ 两个条件必须分开写,不能只把"下 1/4"放宽成"下 1/4 或左 1/6":
* 宽屏下内容区的卡片也在左侧(x 很小)且可点,放宽就全被当成导航项,
* 于是"导航项数 ≥1"恒真 —— 判据等于没有。
* 左侧栏项的实测形状(3184px 展开态):`Column [28,985][201,1380]`,
* 即 x < 220、宽 ~173、高 ~395。用"左 1/6 内 + 高 > 屏高 8%"框住它,
* 内容卡片(宽 900+)因宽度条件被排除。
*/
return [...walk(root)].filter(n => {
const a = n.attributes || {};
if (a.clickable !== 'true') return false;
if (a.type === 'Text') return false;
const m = (a.bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (!m || +m[2] < screenH * 0.75) return false;
return textsUnder(n).length > 0;
if (!m) return false;
const [, x1, y1, x2, y2] = m.map(Number);
if (!wide) {
// 窄屏(底栏):必须有文字标签 —— 底栏的文字就是它的主体
if (textsUnder(n).length === 0) return false;
return y1 >= screenH * 0.75;
}
/*
* 宽屏(左侧栏):**不要求文字**。
*
* ★ 这不是放宽,是两侧本来就不同:WebUI 的 `Sidebar`(60px)是**纯图标轨**
* (无 label 文字),而用户 2026-09-16 明确说过「底部导航栏不允许有文字」
* ⇒ 侧栏同样按纯图标走。实测(3184px 展开态)侧栏项 `Column [28,985][201,1380]`
* 的子树只有 3 个节点:`Column → Stack → Path`,**没有任何 Text** ——
* 拿"有文字"去要求它,就是把底栏的契约硬套到侧栏上。
* 识别它的办法是形状(位置 + 尺寸),不是文字。
*/
const screenW = screenWidthOf(root);
const boxW = x2 - x1;
const boxH = y2 - y1;
return x1 < screenW / 6 && boxW < screenW / 4 && boxH > screenH * 0.08;
});
}
@ -915,23 +981,51 @@ test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout
`要能从 dumpLayout 里量出屏幕高度(实际 ${screenHeightOf(root)})—— 拿不到说明树是空的`);
const navItems = navItemsOf(root);
const wideNow = isWideLayout(root);
assert.ok(navItems.length >= 1,
`底栏要渲染出 ≥1 个带文字标签的可点导航项(实际 ${navItems.length})—— ` +
(wideNow ? '宽屏左侧栏' : '窄屏底栏') + `要渲染出 ≥1 个可点的导航项(实际 ${navItems.length})—— ` +
'一个都没有说明导航没挂 / 被盖住 / 全不可点(dumpLayout 实测)');
/*
* 每个导航项要至少亮一个源码 NAV_ITEMS 里定义过的标签。
*
* ★ 宽屏下**侧栏没有文字**(纯图标轨,见 `navItemsOf` 的说明)⇒ 这一条
* 只在窄屏底栏上有意义。宽屏时改为判"有 ≥1 个可点导航项"(上面那条已覆盖),
* 文字对不上标签这件事在纯图标轨上不成立。
* 这是**模式差异**,不是放宽:把纯图标轨按"要有文字"判,只会永远红。
*
* (下面这段只在窄屏执行。)
* 不要求"所有 Text 都在清单里"—— 图标(emoji 或 Path 渲染出的字形)也是 Text 节点,
* 但它不是导航项的**标签**。要求"至少一个文字命中源码清单"足以抓住"标签写错"
* (写成了源码没有的字),又不误伤图标。这就是 "live ⊆ source" 的可操作写法。
*/
const sourceLabels = new Set(N.NAV_ITEMS.map(i => i.label));
for (const it of navItems) {
const texts = textsUnder(it);
const matched = texts.filter(l => sourceLabels.has(l));
assert.ok(matched.length >= 1,
`底栏导航项(bounds=${it.attributes.bounds})要至少亮一个源码定义过的标签;` +
`实际文字:${texts.join('、')} —— 一个都没命中 NAV_ITEMS(live ⊆ source 被破坏)`);
if (!wideNow) {
const sourceLabels = new Set(N.NAV_ITEMS.map(i => i.label));
for (const it of navItems) {
const texts = textsUnder(it);
const matched = texts.filter(l => sourceLabels.has(l));
assert.ok(matched.length >= 1,
`底栏导航项(bounds=${it.attributes.bounds})要至少亮一个源码定义过的标签;` +
`实际文字:${texts.join('、')} —— 一个都没命中 NAV_ITEMS(live ⊆ source 被破坏)`);
}
} else {
/*
* 宽屏:侧栏是**纯图标轨**(用户 2026-09-16:「底部导航栏不允许有文字」,
* 侧栏同口径 ⇒ WebUI `Sidebar` 也是无文字)。
* 但"没有文字"不等于"什么都不能判"—— 判**图标确实画出来了**:
* 每个导航项子树里要有 `Path`(`AmIcon` 的画法)且**不该有 Text**
* (有文字就说明有人往纯图标轨里塞了标签,那正是被否掉的那个方案)。
*/
for (const it of navItems) {
const kinds = textsUnder(it); // 复用 walk:非空 Text
assert.equal(kinds.length, 0,
`宽屏侧栏项不该有文字(纯图标轨,用户明确否掉了带文字的方案);` +
`实际:${kinds.join('、')}`);
const paths = [...walk(it)].filter(x => x.attributes?.type === 'Path');
assert.ok(paths.length >= 1,
`宽屏侧栏项(bounds=${it.attributes.bounds})要画出图标(Path 节点)—— ` +
'没有 Path 说明图标是空的,侧栏会变成一排摸不着的空白区');
}
}
/*
* ★ 走到这里 = 行为层**真的跑绿了** ⇒ 留一条"本机验过"的记录(pi 2026-09-18 闸 (ii))。
@ -987,6 +1081,42 @@ test('★ 判据自检:底栏取值逻辑 —— 好样本取得到、缺底
assert.equal(textsUnder(wrongItems[0]).filter(l => labels.has(l)).length, 0,
'★ 自检失败:源码里没有的标签被判成命中了 —— 那"live ⊆ source"这条就是空跑');
/*
* ⑤ 宽屏(左侧栏):**纯图标轨**—— 形状识别,且不要求文字。
*
* 加这一步的原因:`navItemsOf` 的形状判断在 2026-09-18 第一次真跑宽屏时
* 把它自己判红了(三折叠展开态 3184px,导航是左侧栏而不是底栏,
* "屏幕下 1/4" 找不到任何东西)。改成分模式之后,**宽屏那一支此前没有任何
* 自检覆盖** —— 而自检没覆盖的分支就是下次回归不会响的那一支。
*
* 合成树按实测形状造:3184×2232、侧栏项 `Column [28,985][201,1380]`、子树只有 Path。
*/
const iconRail = (y0) => ({
// 侧栏项:宽 ~173(< W/4)、高 ~395(> H*0.08)、x1=28(< W/6)
attributes: { type: 'Column', bounds: `[28,${y0}][201,${y0 + 395}]`, clickable: 'true' },
children: [{ attributes: { type: 'Stack', bounds: `[77,${y0 + 160}][152,${y0 + 235}]`, clickable: 'false' },
children: [{ attributes: { type: 'Path', bounds: `[79,${y0 + 164}][148,${y0 + 230}]`, clickable: 'false' }, children: [] }] }],
});
const wideRoot = {
attributes: { type: 'Row', bounds: '[0,0][3184,2232]', clickable: 'false' },
children: [iconRail(985), iconRail(1380),
// 内容区一张宽卡片(可点、有文字)—— 它**不该**被当成导航项
{ attributes: { type: 'ListItem', bounds: '[263,212][1115,568]', clickable: 'true' },
children: [text('pi', '[309,245][360,280]')] }],
};
assert.equal(isWideLayout(wideRoot), true, '自检:3184×2232 要判成宽屏');
const wideItems = navItemsOf(wideRoot);
assert.equal(wideItems.length, 2,
`★ 自检失败:宽屏侧栏应当取到 2 个图标项(实际 ${wideItems.length})—— ` +
'取到 3 个说明内容卡片被误当导航项,"导航没挂"就永远判不出来');
for (const it of wideItems) {
assert.equal(textsUnder(it).length, 0, '自检:侧栏项样本本来就没有文字(纯图标轨)');
assert.ok([...walk(it)].some(x => x.attributes?.type === 'Path'), '自检:侧栏项要含 Path');
}
// 窄屏样本不能被误判成宽屏(否则上面那套形状条件会去滤底栏项)
assert.equal(isWideLayout(good), false, '自检:1200×2800 要判成窄屏');
assert.equal(navItemsOf(good).length, 2, '自检:窄屏样本仍按底栏取到 2 项');
/*
* ④ 自洽的可点 Text 不算导航项。
*