diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index 37f0664..2ac8ac2 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -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 不算导航项。 *