跨端: 顶栏不再自己铺白条 + 日历改左右两栏 + 常驻窗格动画真的会播(用户三处实测指出)

用户三条反馈,逐条对应:

① 「底栏数字为什么显示在图标下面?」
   WebUI 的徽标是 `absolute top-1 right-[22%]`(脱离文档流、浮在图标右上角),
   我写成了 `Column` 的第三个子节点 ⇒ 参与竖向布局、掉到文字下面。
   改用 `Stack({ alignContent: Alignment.TopEnd })` 锚在**图标**上。
   (顺带撞了 skill 里明写的坑:Stack 没有 `.justifyContent()`。)

② 「一个横着过去的白条,我真的服了」/「期望:融进背景」
   WebUI 的顶栏**自身没有底色** —— 只有 `border-b border-gray-200`
   (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
   底色由所在面板给;壁纸开启时那层面板是玻璃色(`index.css:876`)。
   鸿蒙三个窗格顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
   顶上都是一条不通明白带。改成与**页面底**同一口径
   (`bgActive ? Transparent : surface`)+ 补下边框。

③ 「日历页面和webui布局完全不同」
   WebUI 是**左右两栏**(`CalendarView.tsx:452-457`):左 `flex-1` 网格、
   右 `400px` 常驻面板(日程 / 编辑器 / 小时网格三态互斥)。
   鸿蒙原来是**单栏竖堆**。重搭为两栏,`paneWide` 由 `.onAreaChange`
   量本页**自己的**宽度(不是屏幕宽度 —— 宽屏下这一页已被侧栏占掉一截);
   编辑器改占右栏位置(不再整页盖掉正在看的那个月)。

④ 「最严重的动画问题你一点也不该改」
   日历是**常驻挂载**(`visibility` 控制,因为它里面 today 要随时间重算、
   也要保住"正在看哪个月"),而 `.transition()` 只在**挂载/卸载**时触发
   (SDK 原话 \"when it **appears and disappears**\")⇒ 挂在它上面的
   `.transition(paneRiseIn())` **一帧也不会播**,切过去是硬弹。
   WebUI 踩过同一个坑并把错法写进了 `index.css:1305-1320`
   (「只挂了类,却没让触发窗口出现 ⇒ 类挂着、动画永远不播」),
   它的解法是 `html.view-switch` 重放窗口。ArkUI 对应物是
   `animateTo` + 显式 `calPaneIn` 属性(`opacity` + `translate`)。
   ★ reset 必须在 `animateTo` **外**:写进回调里会与同帧的 1 相抵,
     渲染层只看得见最终值 ⇒ 动画退化成一个瞬移。

顺带修:
· 宽屏侧栏 = 3 项(通信/日历/**联系**)+ 底部一簇(头像/主题/退出),
  与底栏的四项(含「我的」)**不是同一份清单** —— WebUI 的 Sidebar 与
  NarrowNav 本就不同(`Sidebar.tsx:26-44` vs `NarrowNav.tsx:37-40`+143)。
  新增 `NAV_SIDEBAR_ITEMS` / `ME_PANE_INDEX` / `SIDEBAR_ITEM_*`。
· 退出登录抽成 `api/Logout.ets` 的 `performLogout()`:侧栏底簇与「我的」页
  两个入口必须做同一件事(尤其"先注销推送 token"那一步),复制一份就会不一致。
· 徽标 `'plain'` 档底色:WebUI 是石板灰 `--c-chrome-600`(#475569),
  我写成与未读共用红色 ⇒ 「联系」的徽标看起来像"有未读"。
· 主题快捷开关(侧栏底簇):对齐 `ThemeToggleButton` —— 从 `system` 翻转时
  落到**当前生效值的反面**(不是回 system;那可能毫无变化、让按钮看起来坏了)。
· 「浓度」→「压暗」+ 数值带单位(原先屏上印 `56.000000`;WebUI 是 `suffix="%"`)。

判据(8 个套件全绿:logic 28 / nav 18 / widescreen 7 / window 9 /
arkts 5 / contacts 5 / calendar 30 / system-api 5):
· `harmony-widescreen` ② 重写:**回读 `Sidebar.tsx` 数 `short:` 的个数**
  要求鸿蒙同数,并断言底部一簇三键真的被调用(`this.onToggleTheme()` ——
  第一版写成 `/onToggleTheme/`,变异测试当场证明它不咬:属性**声明**还在,
  正则照样匹上)。新增 ⑧:三档色调各自底色,期望值从 `--c-chrome-600` 读出。
· `harmony-nav` 动画条重写:改判**机制真的存在且被驱动**
  (旧断言 `.visibility(...).transition(...)` 锁的正是那个 bug ——
  判据引自己写的注释当依据,就会把错误锁死)。新增 reset-在-animateTo-外
  这条断言(否则动画退化成瞬移)。
· `harmony-nav` 设备条:`navItemsOf` 的宽屏过滤从"左边缘靠左 1/6"
  改成"**整个盒子在侧栏轨道内**" —— 旧条件把日历网格的格子
  (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
  新增 `navRailItemsOf`:导航轨贴顶、底部簇在屏底,按位置切一刀。
· `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS`
  **各自的数组**里取 label —— 原先把全文件 `label:` 一网打尽,
  得到 7 个(4+3 混在一起),任何一边改对了它都会红。

设备实测(HATriple 三折叠,3184×2232):侧栏 3 项 + 底簇 / 底栏徽标回到
图标右上角 / 日历左右两栏与 WebUI 并排同构。

server/go.mod:补 2da38bb 漏提交的 lunar-go 依赖。
This commit is contained in:
2026-09-19 12:30:05 +08:00
parent 652316674a
commit 36ef15a8f2
15 changed files with 1216 additions and 331 deletions

View File

@ -911,6 +911,58 @@ rc (原返回处):blind/unreadable/baseline-unrunnable|unknown 排第一
| ② 让自检断言 `staticDetail` **变量**内容完整 | 只删掉那句 `console.log` | **4b 照样报 ok**,而真实输出里播报段 **0 次** |
| ③ 断言源码里有 `${staticDetail}` 这个形状 | 同上 | **仍报 ok** —— 因为**锚点写在这段自检自己的注释里**,`includes` 命中的是注释 |
| ④ 剥注释后再数 `+ staticDetail);` 的命中 | 删 print | **4b 红 ✓**("可执行代码里没有任何一处把 `staticDetail` 拼进输出") |
| ⑤ **同一处改成"记录那次调用真的送了什么"(4c)** | 删 print | **4c 红 ✓** |
| ⑤ | **把 print 包进恒假条件**(`if (false)` / `while (false)` / `false ? … : 0`) | **4c 红 ✓,而 4b 仍报 ok**(三种写法都实测过) |
#### ★★ 4b 的漏格:它扫的是**文本**,而"那一跳"是**一次调用**(pi 报,dsh 2026-09-19 复现并修)
④ 只解决了"print 被删"。但 4b 的证据是**源码里存在那个锚文本** —— 于是**锚文本还在、
而那次调用根本不发生**时,它照样报 ok。**pi 报的正是这一格**,我完整跑复现:
```
baseline : 播报段出现 1 次 4b = ok
变异体 : 播报段出现 0 次 4b = ok ← 红清单与 baseline **逐条相同**
```
根因是**自检先于打印**:4b 在 `:1613` 跑(`staticDetail` 定义之后、打印之前),
打印在 `:3129` ⇒ 它**物理上到不了**那次调用,只能读文本。
⇒ **"验了那个形状"与"验了那件事发生"仍然是两件事**(§16.1 那条缝的又一格)。
**修法(4c)**:不再猜源码,改成**记录这次调用真的往 `console.log` 送了什么** ——
锚点落在「**实际发生的那次调用**」上,这是源码文本够不着的东西:
```js
const staticBroadcastSeen = [];
{ const realLog = console.log;
console.log = (...a) => { staticBroadcastSeen.push(a.join(' ')); realLog(...a); };
console.log(`…` + staticDetail);
console.log = realLog; }
if (!staticBroadcastSeen.some(t => STATIC_ONLY.every(([f, why]) => t.includes(f) && t.includes(why))))
reds.push('(自检)4c:…没有真的进 console.log…');
```
实测(完整跑,每次一个变异体):
| 变异 | 播报段 | 4b | 4c |
|---|---|---|---|
| 无(对照) | 1 | ok | 不红 ✓(红清单与改前**逐条相同**) |
| `if (false)` 包裹 | 0 | **仍 ok** | **红 ✓** |
| `while (false)` 包裹 | 0 | **仍 ok** | **红 ✓** |
| `false ? … : 0` | 0 | 红 | **红 ✓** |
| 删掉 print | 0 | **红 ✓** | **红 ✓**(不回归) |
★ 两条可复用教训:
1. **"文本里有" ≠ "那次调用发生了"** —— 只要判据读源码,`if (false)` 就是一个
**既保留文本、又取消行为**的通用逃逸。凡"必须真的发生"的那一跳,锚点要落在
**运行时观测**上,不能落在源码形状上。
2. **自检的位置本身就是判据的一部分**:4b 读的那个值定义在它之前、而**使用**在它之后
⇒ 它对"使用"这一跳永远只能读文本。**"自检跑在它守的那件事之前"是一类结构性盲区**,
不是这一条的偶然。
⚠️ 4c **不能**做成 `SELFTESTS` 的一条:快入口 `--only-selftest=<名>` 在
`:2503` 就 `process.exit` 了,那条路径**永远到不了** `:3129` 的打印 ⇒
它只会"看不见"而**不会红**(把它挂在那里等于又造一个 4b)。
★ 三条教训(都可复用):
@ -928,6 +980,8 @@ rc (原返回处):blind/unreadable/baseline-unrunnable|unknown 排第一
(Y 变 0 而 X 仍是 6 ⇒ 一眼看得出)。
- **每次运行都全表播报** `STATIC_ONLY`(文件 + 原因 + 是否到期**并排**)⇒ 第 2 列有了读者。
- **自检 4b** 钉住"那一跳真的存在"(剥注释后数锚点 + 内容完整 + 反空转长度)。
- **自检 4c** 钉住"**那一跳真的发生了**"(运行时记录 `console.log` 的实际参数)——
4b 管"源码里有",4c 管"调用发生了";**两条合起来才等价于"到达"**。
★ **通用规则**(本节定稿):

View File

@ -243,11 +243,24 @@ test('底部是 通信 / 日历 / 联系人 三个平级页签,「会话」不
const navLabels = [...pageCode.matchAll(/NAV_ITEMS\.map\([^)]*\.label\)|NAV_ITEMS/g)].length;
assert.ok(navLabels > 0, '底栏要由 NAV_ITEMS 驱动');
const navSource = code(join(HARMONY_ETS, 'model/NavItems.ts'));
const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
// 2026-09-14:日历页(P6 第 1 步)落地后入口上架 ⇒ 三项。撤掉「会话」这条判断本身没变:
// 它不是第三个地方,而是"同一批数据的另一种看法"(收件箱折叠 + 联系人卡片视图)。
// 2026-09-17:与 WebUI NarrowNav 对齐 ⇒ 四项(前三内容窗格 + 外壳入口「我的」)。
assert.deepEqual(labels, ['通信', '日历', '联系人', '我的'], `平级项应为 通信/日历/联系人/我的,实际:${labels.join('、')}`);
/*
* ★★ 2026-09-19 修:原先这里把 `NavItems.ts` 里**所有** `label:` 一网打尽
* ⇒ 得到 7 个(底栏 4 项 **加上** 侧栏 3 项)—— 因为这两份清单现在是**分开的**:
* · `NAV_ITEMS`(底栏)= 通信/日历/联系人/我的(对齐 WebUI `NarrowNav.tsx:37-40`+143)
* · `NAV_SIDEBAR_ITEMS`(侧栏)= 通信/日历/**联系**(对齐 WebUI `Sidebar.tsx:26-44`,
* 「我的」是底部头像,不在这份清单里)
* 把它们混着数,会得到一个既不是底栏、也不是侧栏的“第三份清单”,
* 于是任何一边改对了这一条都会红。⇒ 改成**分别从各自的数组里取**。
*/
const itemsOf = (name) => {
const m = navSource.match(new RegExp(`export const ${name}: NavItem\\[\\] = \\[([\\s\\S]*?)\\];`));
assert.ok(m, `NavItems.ts 要有 ${name}`);
return [...m[1].matchAll(/label:\s*'([^']+)'/g)].map(x => x[1]);
};
assert.deepEqual(itemsOf('NAV_ITEMS'), ['通信', '日历', '联系人', '我的'],
'底栏四项(对齐 WebUI NarrowNav:收件/发件/授权合并为「通信」,末项「我的」)');
assert.deepEqual(itemsOf('NAV_SIDEBAR_ITEMS'), ['通信', '日历', '联系'],
'侧栏三项(对齐 WebUI Sidebar.tsx 的 navItems:「我的」是底部头像,不在导航轨里)');
assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里');
assert.ok(!/sessions\(\)/.test(pageCode), '撤了入口就不该再拉 /me/sessions(否则是没人看的请求)');
});
@ -520,9 +533,16 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 +
// 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变)
// P5:底栏标签现在来自 NAV_ITEMS(自绘浮动条),不是 TabBarBuilder 的参数
const navSource2 = code(join(HARMONY_ETS, 'model/NavItems.ts'));
const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
/*
* ★★ 2026-09-19 修(同文件另一处同一个错):从 `NAV_ITEMS`(**底栏那份**)里取 label,
* 不能把全文件的 `label:` 一网打尽 —— 那样会把 `NAV_SIDEBAR_ITEMS`(三项)也数进来,
* 得到 7 个。两份清单现在是分开的(底栏 4 / 侧栏 3),见 `harmony-widescreen` ②。
*/
const navItemsBlock = navSource2.match(/export const NAV_ITEMS: NavItem\[\] = \[([\s\S]*?)\];/);
assert.ok(navItemsBlock, 'NavItems.ts 要有 NAV_ITEMS(底栏那份清单)');
const tabLabels = [...navItemsBlock[1].matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
// 2026-09-14:日历页(P6 第 1 步)做完后入口上架 —— 三项且顺序固定(通信/日历/联系人)
// 2026-09-17:对齐 WebUI 四入口 —— 前三内容窗格不变,第 4 项「我的」是外壳入口(见 NavItem.route)
// 2026-09-17:对齐 WebUI 四入口 —— 前三内容窗格不变,第 4 项「我的」也是内容窗格(currentIndex === 3)
assert.deepEqual(tabLabels, ['通信', '日历', '联系人', '我的'], `底部应为通信/日历/联系人/我的四项,实际:${tabLabels.join('、')}`);
// 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)——
// 所以这里钉的是"带参数地渲染通信页",不是光有个名字

View File

@ -460,9 +460,45 @@ test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵
assert.ok(branchRoots >= 2,
`★ transition 要挂在会被换掉的子树根上(if/else 分支),实际只找到 ${branchRoots} 处。` +
'挂在两级之上的稳定父容器上等于没挂(实测过:6 秒取样仍是硬切)。');
// 日历那一支(常驻 + visibility)也要有
assert.match(mainCode, /\.visibility\(this\.currentIndex === 1[\s\S]{0,120}\.transition\(Theme\.paneRiseIn\(\)\)/,
'★ 日历窗格(常驻挂载 + visibility 控制)也要挂过场过渡');
/*
* ★★ 2026-09-19 重写日历那一条(用户:「最严重的动画问题你一点也不该改」)。
*
* 原断言:`.visibility(...)` 后面跟 `.transition(Theme.paneRiseIn())` ——
* 它锚的是**写法**,而不是“动画真的会播”。而那个写法**一帧也不会播**:
* `.transition()` 只在**挂载/卸载**时触发(SDK 原话 "when it **appears and
* disappears**"),可日历是**常驻**的(用 `visibility` 控制,因为它里面
* `today` 要随时间重算、也要保住“正在看哪个月”)—— 永不重挂载。
* 所以旧断言是**把 bug 锁住了**(与我上次给侧栏编理由同一个错法)。
*
* 新断言改成断**机制真的存在且被驱动**:
* · 常驻窗格不能用 TransitionEffect,要用可插值的属性
* (`opacity` + `translate`);
* · 那个属性要有一个 @State 支撑;
* · 切窗格时要**真的把它从 0 推到 1**(且 reset 在 animateTo 外 ——
* 否则同一帧内 0→1,起点终点都是 1,动画退化成瞬移)。
*/
assert.ok(!/\.visibility\(this\.currentIndex === 1[\s\S]{0,120}\.transition\(Theme\.paneRiseIn\(\)\)/.test(mainCode),
'★ 日历窗格是**常驻挂载**(visibility 控制)—— 它上面的 `.transition()` 永远不会触发。' +
'那条断言锁的就是这个 bug,不得恢复。');
// 日历容器的入场:用可插值属性,且由 @State 支撑
assert.match(mainCode, /calPaneIn/,
'★ 日历窗格的入场进度要有一个 @State(`calPaneIn`)支撑 —— 常驻窗格只能靠属性插值做入场');
const calContainer = /\.visibility\(this\.currentIndex === 1[\s\S]{0,400}?\.opacity\(this\.calPaneIn\)/.exec(mainCode);
assert.ok(calContainer,
'★ 日历容器要真的用 `calPaneIn` 驱动透明度(这才是常驻窗格能播的入场)');
assert.match(mainCode, /\.translate\(\{[^}]*calPaneIn[^}]*\}\)/,
'★ 日历容器要同时驱动位移(对齐 WebUI `rise-in` 的 translateY)');
// reset 必须在 animateTo **外** —— 否则起点=终点,动画退化
const resetIdx = mainCode.indexOf('this.calPaneIn = 0');
const animIdx = mainCode.indexOf('getUIContext().animateTo(');
assert.ok(resetIdx > 0, '★ 切到日历前要先把 `calPaneIn` 瞬回 0(否则看不到“从无到有”)');
assert.ok(resetIdx < animIdx || animIdx < 0,
'★ `calPaneIn = 0` 必须在 `animateTo` **之前**:写进回调里会与同帧的 1 相抵,' +
'渲染层只看得见最终值 ⇒ 动画退化成一次瞬移(等于没修)');
const intoAnim = /getUIContext\(\)\.animateTo\([\s\S]{0,300}?this\.calPaneIn = 1/.exec(mainCode);
assert.ok(intoAnim,
'★ 推到 1 要在 `animateTo` 窗口里(与其余窗格的 transition 同时长、同曲线)');
// animateTo 走 getUIContext(全局 animateTo 已废弃,另行有判据钉)
assert.match(mainCode, /getUIContext\(\)\.animateTo\(/,
@ -933,24 +969,55 @@ function navItemsOf(root) {
const boxW = x2 - x1;
const boxH = y2 - y1;
/*
* ★ 阈值用**比例**而不是绝对 px,且必须容得下真实的 48vp 项。
* 实测(密度 2.875):项 138×138px、屏 3184×2232 ⇒
* x1=45 < 3184/6=531 ✓、boxW=138 < 3184/4=796 ✓、
* 但 boxH=138 需要 > 2232*0.08=178 ✗ ⇒ 四项**全被滤掉**。
* 也就是说这个阈值我第一版写大了,只是当时项被压成 39vp 才"恰好"通过
* (那是别的 bug,不是阈值对)。
* 改成 `screenH * 0.04`(=89px):48vp 项(138px)过得去,
* 而内容区那些横向长条(高 43px < 89)仍然被排除。
* ★★ 2026-09-19 修(被判据自己抳到):过滤条件从 “靠左 1/6” 改成
* “**整个盒子在侧栏轨道内**”。
*
* ★ 还要**要求有文字标签**:品牌标也是左上的可点方块(实测 `Stack [57,174][172,289]`
* 40vp、无文字),不加这一条它会混进来,而它不是导航项 ——
* 它是"点它回家"的品牌按钮(WebUI 的 `brand-mark`)。
* 原条件 `x1 < screenW / 6` 只要求**左边缘**靠左 —— 而日历网格的格子实测是
* `Column [229,511][366,794]`:x1=229 < 531 ✓、高 283 > 89 ✓、带文字 ✓
* ⇒ **被当成导航项**。于是一屏日历数出 11~12 个“导航项”,
* 而侧栏实际只有 3 项。
*
* 换成 “`x2`(右边缘)也在轨道内”:
* · 导航项 `[45,312][183,450]` ⇒ x2=183 ≤ 255 ✓
* · 品牌标 `[57,174][172,289]` ⇒ x2=172 ✓(但无文字,另行排除)
* · 日历格子 `[229,511][366,794]` ⇒ x2=366 > 255 ✗ **排除**
*
* 为什么用比例 `screenW * 0.08`(展开态 = 255px)而不是写死 183/200:
* 侧栏是 60vp,px 值随密度变(教训:**密度是第二个真相**,见 `isWideLayout`)。
* 0.08 在展开态的 3184px 上给 255,在单屏 1008px 上给 80(而单屏不是宽屏,不走这支)。
*/
return x1 < screenW / 6 && boxW < screenW / 4 && boxH > screenH * 0.04
return x2 <= screenW * 0.08 && boxH > screenH * 0.04
&& textsUnder(n).length > 0;
});
}
/**
* 宽屏侧栏里的**导航轨**(不含底部那一簇)。
*
* ★★ 2026-09-19 新增(用户:「你写的app和webui大面积不符」)。
*
* 侧栏底部那一簇(头像 / 主题 / 退出)也是可点、也有文字/图标,形状与导航项相近 ——
* `navItemsOf` 会把它们一起数进来(实测数出 12 个,而导航项应为 3 个)。
* 而 WebUI 确实有这一簇(`Sidebar.tsx:183-212`),所以**不能拿它当“多出来的项”判红**;
* 也不能因此放宽总项数(那会让“我的又回到侧栏”那个 bug 漏过去)。
*
* 所以改按**位置**切:导航项一簇**贴顶**(WebUI 实测 y = 70/122/174,`gap-1`),
* 下面用 `Blank()` 推到屏幕底部才是那一簇。取两者之间的空档切一刀即可。
*
* ★ 阈值用“屏幕高度的一半”:导航项在顶部 1/3 以内,底部簇在最后 1/4 以内,
* 中间有大段空白(实测展开态导航项在 y≈985-1380、底部簇在 y≈2000+)。
* 用比例而不是绝对 px,避开密度那第二个真相(同 `isWideLayout` 的教训)。
*/
function navRailItemsOf(root) {
const items = navItemsOf(root);
const screenH = screenHeightOf(root);
return items.filter(n => {
const m = (n.attributes?.bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if (!m) return false;
return Number(m[2]) < screenH * 0.5;
});
}
test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout 实测,live ⊆ source)', async (t) => {
const CRIT = 'harmony-nav/底栏可点项';
const hdc = findHdc();
@ -1041,8 +1108,33 @@ test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout
*
* 现在按两侧的**共同事实**判:侧栏项 = 图标(Path)+ 文字标签。
* 图标画出 + 文字命中源码清单,两条都要。
*
* ★★ 2026-09-19 修第二处(与 harmony-widescreen ② 同一个错):
* 这一段原来拿 `N.NAV_ITEMS`(**四项**,含「我的」)的 label 去比侧栏渲染出来的文字
* —— 但侧栏是**三项**(通信/日历/**联系**),第三项对不上(“联系” ≠ “联系人”),
* 而且它反过来证明不了“侧栏多了我的”那个 bug。
* 侧栏要比的是 `NAV_SIDEBAR_ITEMS`;两项前两项相同只是巧合,不能因此混用。
*/
const sourceLabels = new Set(N.NAV_ITEMS.map(i => i.label));
const sourceLabels = new Set(N.NAV_SIDEBAR_ITEMS.map(i => i.label));
/*
* 侧栏**总共**应该正好是 `NAV_SIDEBAR_ITEMS` 那个数。
* 只判“每项命中”不够:多出来的一项(例如「我的」)也可能命中一个 label,
* 而“多一个我的”本来就是这次报的 bug。所以同时判**个数**。
*/
/*
* ★★ 2026-09-19:改判**导航轨**(`navRailItemsOf`)而不是全部可点项 ——
* 侧栏底部那一簇(头像/主题/退出)是 WebUI 就有的(`Sidebar.tsx:183-212`),
* 把它数进来会得到 12,而那并不是“多了一个我的”。
* 数量仍要卡死(不然“我的回到侧栏”那个 bug 会漏过去),只是卡在**轨道**这一层。
*/
const railItems = navRailItemsOf(root);
assert.equal(railItems.length, N.NAV_SIDEBAR_ITEMS.length,
`宽屏侧栏导航轨应有 ${N.NAV_SIDEBAR_ITEMS.length} 项(与 WebUI Sidebar.tsx 的 navItems 同数),` +
`实际 ${railItems.length} 项(多出来很可能就是「我的」又回到了侧栏);` +
`(全部可点项 ${navItems.length} 个,含底部那一簇)`);
/* 后续逐项断言作用在导航轨上 */
navItems.length = 0;
navItems.push(...railItems);
for (const it of navItems) {
const paths = [...walk(it)].filter(x => x.attributes?.type === 'Path');
assert.ok(paths.length >= 1,
@ -1051,7 +1143,7 @@ test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout
const texts = textsUnder(it);
const matched = texts.filter(l => sourceLabels.has(l));
assert.ok(matched.length >= 1,
`宽屏侧栏项要带文字标签且命中源码 NAV_ITEMS(WebUI Sidebar 有 <span>{short}</span>);` +
`宽屏侧栏项要带文字标签且命中源码 NAV_SIDEBAR_ITEMS(WebUI Sidebar 有 <span>{short}</span>);` +
`实际文字:${texts.join('、')}`);
}
}

View File

@ -1,12 +1,18 @@
// 宽屏侧栏图标轨(WideSidebar)判据 —— 一比一复刻 WebUI 的 `Sidebar`(60px 图标轨)。
// 宽屏侧栏(WideSidebar)判据 —— 逐项复刻 WebUI 的 `Sidebar`。
//
// WebUI 的 `Sidebar`(components/Sidebar.tsx)是 60px 宽的图标列:
// 品牌标(顶) / 通信·日历·联系人(中) / 设置(底)
// 选中态 = 图标+文字变色 + 品牌浅底,**不加背景块/指示条**。
// ★★ 2026-09-19 重写本文件的头部说明(上面那一版是错的,与它判的东西矛盾):
// 旧文写的是「60px 图标轨 / 通信·日历·联系人(中)/ 设置(底)/
// 选中态只换颜色 + 品牌浅底,**不加背景块**」—— 那个描述是**编的**:
// · `Sidebar.tsx:26-44` 是**三项**(通信/日历/**联系**)——「我的」不在导航轨里,
// 而是 **底部头像按钮**(`Sidebar.tsx:186`);
// · `Sidebar.tsx:110` 有 `<span className="text-3xs">{short}</span>`(有文字);
// · `index.css:1590` 的 `.nav-item[data-active='true']` **有底色块**,
// 而且 CSS 注释专门说明侧栏**必须有**(“图标底下那一块底色是它唯一的选中线索”)。
// 更糟的是:判据当时**引用了这段自编的描述当依据**,于是把错误锁死 —— 全绿。
// 现在的口径:每一项都回读 WebUI 源码取值,不再引“我上次写的那句话”。
//
// 鸿蒙侧原来是**没有**宽屏布局的(只有底部导航条),WideSidebar 补的就是
// "宽屏模式"这半:≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。
// 这条判据钉的是 Sidebar 自己的形状:宽 60vp、三项导航 + 设置、选中态只换颜色。
// 鸿蒙侧原来**没有**宽屏布局(只有底部导航条),WideSidebar 补的就是"宽屏模式"这半:
// ≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。
import { code, prose, stripComments } from './lib/read.mjs';
import { readFileSync } from 'node:fs';
@ -18,8 +24,12 @@ 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');
/** WebUI 源码根:判据要**回读**它取值,而不是引注释里的话 */
const WEBUI_SRC = join(ROOT, 'client/electron/src');
const read = p => prose(join(HARMONY_ETS, p));
/** 读 WebUI 的源文件(**不去注释**:注释里就有真实理由) */
const readWeb = p => readFileSync(join(WEBUI_SRC, p), 'utf8');
/** 取某个成员/方法正文(按行切到下一个成员声明) */
function memberBody(src, signature) {
@ -41,41 +51,84 @@ test('① 宽度:SIDEBAR_WIDTH = 60vp(WebUI 的 `w-[60px]` 是同一数字
assert.match(main, /WideSidebar\(/, 'MainPage 要真正挂 WideSidebar');
});
test('② 四项导航(与底栏同源):不再有单列的"设置"入口', () => {
test('② 侧栏**三项**(通信/日历/联系)+ 底部一簇 —— 与底栏四项不是同一份清单', () => {
/*
* ★★ 2026-09-18 重写。原来这条判的是「三项导航 + 设置按钮」,
* 依据是"设置单列在下面、遍历 NAV_ITEMS 会出现两个 person 图标"。
* 那个依据**两半都错**:
* · `NAV_CONTENT_ITEMS = NAV_ITEMS.slice(0, NAV_CONTENT_COUNT)` 而
* `NAV_CONTENT_COUNT = 4` ⇒ **四项全在**(含「我的」),不存在"只有前三项";
* · 单列的"设置"走 `onSettings → pushUrl('pages/SettingsPage')`,
* 而那正是用户 2026-09-17 报过的「我的页面完全没有遵守 nav 的导航规则」
* (底栏那一支改成了窗格,侧栏这一支漏了)。
* ★★ 2026-09-19 重写(用户:「你写的app和webui大面积不符,问题特别大」)。
*
* WebUI `Sidebar.tsx` 的真实形状:`navItems` 三项 + **底部一簇**
* (账号头像 / 主题切换 / 退出)。「我的」在 WebUI 是 `viewMode === 'account'`,
* 由侧栏底部那个**头像按钮**进入 —— 也就是说它是**导航项**,不是"推出去的页"。
* 鸿蒙的对应物是第 4 项「我的」窗格(与底栏一致)。
* 上一版这条判的是「遍历 `NAV_CONTENT_ITEMS`」—— 而 `NAV_CONTENT_ITEMS` 是
* **四项**(含「我的」)。那正是**错的形状**:四项只对**底栏**。
*
* WebUI 两套导航**本来就不同**(同一份源码里两个文件):
* · `NarrowNav.tsx:37-40` items = 通信/日历/**联系人**,
* 再加第 4 个「我的」按钮(`NarrowNav.tsx:143`)⇒ 底栏 **4 项**;
* · `Sidebar.tsx:26-44` navItems = 通信/日历/**联系** ⇒ 侧栏 **3 项**,
* 「我的」是底部**头像按钮**(`Sidebar.tsx:186`,旁边的注释就说它是
* `title=...点击管理账号`)。
*
* 这条件判据的写法变了:不再只查鸿蒙源码的字符串,而是
* **回读 `Sidebar.tsx` 数出 `short:` 的个数**,要求鸿蒙那一份也是同一数字 ——
* 否则下次 WebUI 改了项数,这里只会继续绿。
*/
const sidebar = read('pages/WideSidebar.ets');
const code_ = stripComments(sidebar);
assert.match(code_, /ForEach\(NAV_CONTENT_ITEMS,/,
'侧栏要遍历 NAV_CONTENT_ITEMS(与底栏同一个清单 —— 两处各留一份会漂移)');
const sidebarSrc = readWeb('components/Sidebar.tsx');
const narrowSrc = readWeb('components/NarrowNav.tsx');
// 从 WebUI 源码里数导航项:`short: 'xx'` 的行(两处各数一遍)
const shortOf = (src) => [...src.matchAll(/short:\s*'([^']+)'/g)].map(m => m[1]);
const webSidebarShort = shortOf(sidebarSrc);
const webNarrowShort = shortOf(narrowSrc);
assert.deepEqual(webSidebarShort, ['通信', '日历', '联系'],
`WebUI 侧栏应为 3 项(读到的是 ${JSON.stringify(webSidebarShort)})——若这里变了,下面的期望值要跟着改`);
assert.deepEqual(webNarrowShort, ['通信', '日历', '联系人'],
`WebUI 底栏内容项应为 3 项(读到的是 ${JSON.stringify(webNarrowShort)})——与侧栏的第三项措辞**故意不同**`);
// 鸿蒙侧:侧栏清单必须与 WebUI 侧栏逐字一致(项数与文案)
const navItems = read('model/NavItems.ts');
const sidebarList = navItems.match(/export const NAV_SIDEBAR_ITEMS[\s\S]*?\];/);
assert.ok(sidebarList, 'model/NavItems.ts 要有 NAV_SIDEBAR_ITEMS(侧栏专用的三项清单)');
const harmonySidebarShort = [...sidebarList[0].matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
assert.deepEqual(harmonySidebarShort, webSidebarShort,
`NAV_SIDEBAR_ITEMS 的 label 要与 WebUI 侧栏逐字一致(现在读到 ${JSON.stringify(harmonySidebarShort)})`);
// 侧栏**不许**用四项那份清单(那正是这次报的“多出一个我的”)
const sidebar = stripComments(read('pages/WideSidebar.ets'));
assert.match(sidebar, /ForEach\(NAV_SIDEBAR_ITEMS,/,
'侧栏要遍历 NAV_SIDEBAR_ITEMS(三项)');
assert.ok(!/ForEach\(NAV_CONTENT_ITEMS,/.test(sidebar),
'侧栏不许再遍历 NAV_CONTENT_ITEMS(那是**底栏**的四项清单,含「我的」)');
// 品牌标:WebUI 是 `<button data-testid="brand-mark"><BrandMarkIcon/>` —— 可点 + brandMark 图标
assert.match(code_, /iconName: 'brandMark'/, '要有品牌标(WebUI `brand-mark` 的对应物,图标是 brandMark)');
// 「我的」必须在侧栏里作为一个**导航项**出现,且走 onSelect 而不是推页
assert.ok(!/onSettings/.test(code_),
assert.match(sidebar, /iconName: 'brandMark'/, '要有品牌标(WebUI `brand-mark` 的对应物,图标是 brandMark)');
assert.ok(!/onSettings/.test(sidebar),
'侧栏不许留 onSettings(它推 `pages/SettingsPage` ⇒ 侧栏整条消失,正是用户报过的形状)');
/*
* 「我的」由上面那个 `ForEach(NAV_CONTENT_ITEMS)` 覆盖(`NAV_CONTENT_ITEMS`
* 含第 4 项)—— 所以这里判的是"没有另起一个推页入口",
* 以及 onSelect 能把 index 传到第 4 项(`normalizeNavIndex` 不截到 3 项)。
*/
assert.match(code_, /onClick\(\(\) => \{ this\.onSelect\(index\); \}\)/,
'导航项点击要原样传 index(第 4 项「我的」因此走 onSelect(3) = 窗格)');
// 品牌标点它要回第一项(WebUI `onClick={() => setViewMode('inbox')}`)
assert.match(code_, /onClick\(\(\) => \{ this\.onSelect\(0\); \}\)/,
assert.match(sidebar, /onClick\(\(\) => \{ this\.onSelect\(0\); \}\)/,
'品牌标点击要回收件箱(WebUI brand-mark 的一致习惯:点左上角 logo 回家)');
/*
* 底部一簇 —— `Sidebar.tsx:183-212`:头像(含连接点)/ 主题切换 / 退出。
* 三者漏一个都会在并排截图里缺一块。
*
* ★ 判**真的被调用**(`this.onToggleTheme()`),不是"文件里出现过这个名字":
* 第一版就写了 `/onToggleTheme/` —— 变异测试当场证明它不咬:
* 把 `this.onToggleTheme()` 换成 `this.onSelect(0)` 后,
* 属性**声明**还在,正则照样匹上,判据全绿。同 `navBadgeCount` 那一课的同一个错。
*/
assert.match(sidebar, /this\.onToggleTheme\(\)/, '底部主题键要真的调 onToggleTheme()');
assert.match(sidebar, /this\.onLogout\(\)/, '底部退出键要真的调 onLogout()');
/* 头像:WebUI 取用户名前两字(`Sidebar.tsx:190` 的 `.slice(0, 2)`) */
assert.match(sidebar, /\.slice\(0, 2\)/, '头像要取展示名前两字(WebUI `.slice(0, 2)`)');
/* 头像右下角要带 SSE 连接状态点(WebUI 的 `<ConnectionIndicator/>`) */
assert.match(sidebar, /sseColorOf\(/, '头像要带连接状态点(颜色由 SSE 状态决定)');
const webInd = readWeb('components/ConnectionIndicator.tsx');
for (const [status, hex] of [['connected', '#22C55E'], ['connecting', '#FACC15'], ['reconnecting', '#FB923C'], ['disconnected', '#F87171']]) {
/* WebUI 用的是 tailwind 类名,这里判的是"四档都有"这个结构,不是具体色值 */
assert.ok(new RegExp(`${status}:`).test(webInd), `WebUI 的连接指示器要有 ${status} 档`);
assert.ok(sidebar.includes(hex), `鸿蒙的连接点要有 ${status} 对应的色值 ${hex}`);
}
/* 「我的」由**头像**进入(不是导航轨里的一项) */
assert.match(sidebar, /this\.onSelect\(ME_PANE_INDEX\)/,
'头像点击要进「我的」窗格(ME_PANE_INDEX)—— WebUI 是 setViewMode(\'account\')');
});
test('③ 侧栏选中态 = **浅蓝底块** + 变色(与底栏"只变色"是两条纪律)', () => {
@ -108,17 +161,29 @@ test('③ 侧栏选中态 = **浅蓝底块** + 变色(与底栏"只变色"是
`Theme.navActiveBg 要等于 WebUI 的 --nav-active-bg(${hex})`);
// 选中态必须**有底块**(WebUI 侧栏唯一的选中线索)
assert.match(code_, /backgroundColor\(this\.currentIndex === index \? Theme\.navActiveBg : Color\.Transparent\)/,
/*
* ★ 2026-09-19:表达式从 `this.currentIndex === index` 改成
* `this.currentIndex === sidebarContentIndex(item.key)` ——
* 侧栏现在只画三项,而 currentIndex 是**四项**那套(`me` = 3),
* 所以不能拿构图下标直接比。两条都接受(比的是"这个项是不是选中项")。
*/
assert.match(code_, /backgroundColor\(this\.currentIndex === (index|sidebarContentIndex\(item\.key\)) \? Theme\.navActiveBg : Color\.Transparent\)/,
'导航项选中要有底块(WebUI `.nav-item[data-active]` 的 --nav-active-bg)—— 侧栏不是底栏,不能只变色');
// 文字标签:WebUI `Sidebar.tsx:110` 有 <span>{short}</span>,所以侧栏**有** label
assert.match(code_, /Text\(label\)/,
/*
* 文字标签:WebUI `Sidebar.tsx:110` 有 `<span>{short}</span>`,所以侧栏**有** label。
* ★ 2026-09-19:侧栏改成 `Text(item.label)`(从清单取,不是参数传)——
* 两种写法都接受,只要真的把 label 交给了 `Text`。
*/
assert.match(code_, /Text\((label|item\.label)\)/,
'侧栏导航项要有文字标签(WebUI Sidebar 有 <span>{short}</span>)');
// ★ 自检:这三条都必须能判红
assert.ok(!/backgroundColor\(this\.currentIndex === index \? Theme\.navActiveBg : Color\.Transparent\)/.test(
'backgroundColor(this.currentIndex === index ? Color.Transparent : Theme.navActiveBg)'),
'自检:三元式写反了必须判红');
assert.ok(!/Text\((label|item\.label)\)/.test('Text(short)'),
'自检:标签没交给 Text 必须判红');
});
test('④ MainPage 接线:宽屏才挂侧栏、宽屏藏底部条、断点 768', () => {
@ -275,4 +340,29 @@ test('⑦ 导航项徽标:取值/色调与 WebUI Sidebar 同口径(纯逻辑
assert.ok(main.includes(key), `MainPage 要读 ${key}`);
assert.ok(sidebar.includes(key), `WideSidebar 要读 ${key}(与底栏同一组键)`);
}
/*
* ⑧ 三档色调都要有**各自的底色**(不能把三档压成两档)。
*
* ★★ 2026-09-19 修 bug:底栏与侧栏两处原先都写
* `tone === 'perm' ? warnFg : danger` —— 于是 `'plain'`(联系人数)走了**红**,
* 看起来像"有未读"。并排截图一眼可见:WebUI 是石板灰的 4,鸿蒙是红的。
*
* ★ 期望值从 WebUI 的 CSS 里**读出来**,不写死:
* `Sidebar.tsx:203-204` 的 `'plain'` 档是 `bg-chrome-600`,
* 而 `--c-chrome-600: 71 85 105`(`index.css:92`)= #475569。
*/
const cssSrc = readWeb('index.css');
const chromeM = cssSrc.match(/--c-chrome-600:\s*(\d+ \d+ \d+)/);
assert.ok(chromeM, 'WebUI index.css 要有 --c-chrome-600(中性徽标底色)');
const [cr, cg, cb] = chromeM[1].split(' ').map(Number);
const plainHex = '#' + [cr, cg, cb].map(v => v.toString(16).padStart(2, '0').toUpperCase()).join('');
const themeSrc = readFileSync(join(HARMONY_ETS, 'common', 'Theme.ets'), 'utf8');
assert.match(themeSrc, new RegExp(`badgePlain: string = '${plainHex}'`, 'i'),
`Theme.badgePlain 要等于 WebUI 的 --c-chrome-600(${plainHex})`);
for (const [name, src] of [['底栏', main], ['侧栏', sidebar]]) {
assert.match(src, /Theme\.badgePlain/,
`${name}的徽标底色要引用 Theme.badgePlain('plain' 档不能跟未读共用红色)`);
}
});

View File

@ -3126,8 +3126,36 @@ try {
const staticDebtLine = `static=${STATIC_ONLY.length}(其中已到期 ${dueStatic.length} 条)`;
// ★ 第 2 列的**读者**(【发现 1】的修法):每次运行都全表播报。
// 构造在 `dueStatic` 之后(自检 4b 要读它),这里只负责打印。
console.log(`\n---------- 只能静态验形态的判据(每次运行都列全,第 2 列不再是死重)----------\n`
+ staticDetail);
/*
* ★★ 自检 4c(dsh 2026-09-19;补 4b 的漏格。pi 报,我同刻复现)。
*
* 4b 扫的是**源码文本**(剥注释后数 `+ staticDetail);` 的命中)。于是有一个它看不见的变异:
* **把这条 `console.log` 包进恒假条件** —— 锚点文本还在源码里,而**播报 0 次**。
* 实测(完整跑,同刻 A/B):baseline 播报段出现 **1** 次、变异后 **0** 次,
* 而 **4b 两次都报 ok**、红清单**逐条相同**。
*
* 根因是**自检先于打印**:4b 在 `:1613` 跑(`staticDetail` 定义之后、打印之前),
* 打印在 `:3129` ⇒ 它物理上到不了那次调用,只读得了文本。
*
* ⇒ 修法:这里不猜源码,改**记录这次调用真的往 `console.log` 送了什么**。
* 锚点落在「**实际发生的那次调用**」上 —— 这是它的自述够不着的东西(本仓老纪律)。
* 包进恒假条件 ⇒ 记录为空 ⇒ 4c 红。
* ⚠️ 不能做成 `SELFTESTS` 的一条:快入口 `--only-selftest=` 在 `:2503` 就 `exit` 了,
* 那条路径**永远到不了这里**,于是它只会"看不见"而不会红。
*/
const staticBroadcastSeen = [];
{
const realLog = console.log;
console.log = (...a) => { staticBroadcastSeen.push(a.join(' ')); realLog(...a); };
console.log(`\n---------- 只能静态验形态的判据(每次运行都列全,第 2 列不再是死重)----------\n`
+ staticDetail);
console.log = realLog;
}
if (!staticBroadcastSeen.some(t => STATIC_ONLY.every(([f, why]) => t.includes(f) && t.includes(why)))) {
reds.push('(自检)4c:`STATIC_ONLY` 的播报段**没有真的进 `console.log`**'
+ `(只记录到 ${staticBroadcastSeen.length} 次调用)⇒ 第 2 列**仍然没有读者**。`
+ '4b 只扫源码文本,"包进恒假条件"它看不见 —— 这条就是补那一格的。');
}
console.log(`RESULT phase=${PHASE} ${staticDebtLine}${debtsLine} probe=${probeSummary}` +
(unknownProbes.length ? `(探针跑不了:${unknownProbes.join('、')} —— 已按到期处理)`
: '(只能验形态的判据:到期前提成立就自动变红)') + mutantsLine);