@ -101,8 +101,10 @@ test('② 挂载: index 决定挂哪个页面(点第 2 项必须挂联系人
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 要挂联系人页 ' ) ;
assert . match ( first , /CommPage\(\{ bgActive: this\.bgActive,\s*navReserve: this\.navReserve \}\)/ ,
'index 0 要挂通信页(并把底部条高度透传下去作为列表末尾让位) ' ) ;
assert . match ( second , /ContactsTab\(\{ bgActive: this\.bgActive,\s*navReserve: this\.navReserve \}\)/ ,
'index 2 要挂联系人页(同样透传底部条高度)' ) ;
/*
* 联系人那一支必须**显式绑在 index 2**。注意条件在 `else if (...)` 里,
* 不在 `else` 的**花括号正文**里 —— braceBody 只取正文,所以这条要看 root 原文。
@ -115,8 +117,8 @@ test('② 挂载: index 决定挂哪个页面(点第 2 项必须挂联系人
* 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 , /CalendarPage\(\{\s* bgActive: this\.bgActive,\s* visible: this\.currentIndex === 1,\s*navReserve: this\.navReserve\s* \}\)/ ,
'index 1 要挂日历页,并把"是否可见"与底部条高度都 传下去' ) ;
assert . match ( root , /\.visibility\(this\.currentIndex === 1 \? Visibility\.Visible : Visibility\.None\)/ ,
'常驻挂载就要用 visibility 控制显示' ) ;
assert . ok ( ! /if \(this\.currentIndex === 1\)/ . test ( root ) ,
@ -197,8 +199,19 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
* "悬浮"是可判的形状:四周留白 + 圆角 + 浮在内容之上。
* 这三样缺一样就不再是 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*\}\)/ ,
'四周要留白(左右 + 离底),贴边就不是悬浮' ) ;
/*
* ★ 留白必须靠**外层容器的 padding**,不能靠 `width('100%') + margin`( 2026-09-17 修)。
*
* ArkUI 的 margin 加在宽度**外面**: `width('100%')` 再配左右 margin 不会缩到
* 「100% − margin」, 而是整个顶出父容器、两侧被裁。实测底栏左缘 x=0、右缘贴满
* 1256( 应各留 16vp=56px) ⇒ 看起来是**贴边通栏**,不是浮在壁纸上的胶囊,
* 玻璃材质也随之失去意义(背后没有内容/壁纸可透)。
* 这与收件箱头卡片修过的是同一个坑。
*/
assert . match ( bar , /\.padding\(\{\s*left:\s*NAV_BAR_SIDE,\s*right:\s*NAV_BAR_SIDE,\s*bottom:\s*NAV_BAR_BOTTOM\s*\}\)/ ,
'留白要用外层容器的 padding( 左右 + 离底)—— width(100%) + margin 在 ArkUI 里不缩宽,会顶出父容器' ) ;
assert . ok ( ! /\.margin\(\{[^}]*NAV_BAR_SIDE/ . test ( bar ) ,
'★ 不许再用 margin 做留白: ArkUI 的 margin 加在宽度外面, 100% 宽度 + margin 会撑出屏幕边界' ) ;
assert . match ( bar , /\.borderRadius\(NAV_BAR_RADIUS\)/ , '要圆角(胶囊)' ) ;
assert . match ( bar , /\.backgroundBlurStyle\(Theme\.navMaterial\)/ ,
'材质用系统档次,不手写 alpha( 且**不跟随** `bg_blur` —— 理由见本文件末那条"可达性"判据)' ) ;
@ -214,13 +227,23 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
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 } ) ` ) ;
/*
* ★ 2026-09-15: padding 现在是**条件式**(宽屏变 0、窄屏取 NAV_CONTENT_RESERVE )。
* 宽屏有侧栏图标轨、没有底部条,所以内容不再需要让位 —— 但窄屏那条老规矩仍然在。
* 断言匹配两种形态:旧的直接取值、新的三元式(宽屏取 0) 。
* ★ 2026-09-17: 让位方式**改了**,这条判据跟着改(旧形状已被证明是错的 )。
*
* 旧做法是把 `NAV_CONTENT_RESERVE` 加在**窗格的 `padding({bottom})`** 上 。
* 那看着等价于"最后一行能滚出来", 实际还多了一个副作用: padding 会
* **缩短窗格本身** ⇒ 内容永远到不了条底下 ⇒ 玻璃条背后只剩一张已经被壁纸层
* 模糊过的壁纸 ⇒ 系统材质无东西可糊 ⇒ 看起来是一块普通浅色面板,而不是玻璃。
* 用户 2026-09-17: 「底栏不是玻璃质感, 滑动内容无法穿过底栏」——同一个根因。
*
* 正确做法:窗格**满高**(内容滑得到条底下,真正穿过),
* 让位加在各滚动容器的**内容末尾**( `contentEndOffset(this.navReserve)`)。
* 与 WebUI 同构:`.narrow-shell` 是 flex-col, 列表满高、`.narrow-nav` 叠在上面。
*/
const padOk = /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\} \)/. test ( main )
|| /\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/ . test ( main ) ;
assert . ok ( padOk , '内容底部要让出这段高度(窄屏取 NAV_CONTENT_RESERVE; 宽屏可变 0) ' ) ;
const contentOffsets = [ ... main . matchAll ( /\.contentEndOffset\(this\.navReserve \)/g ) ] ;
assert . ok ( contentOffsets . length >= 5 ,
` 各滚动容器都要在**内容末尾**让位(至少 5 处:收件箱/发件箱/授权/联系人× 2/日历),实际 ${ contentOffsets . length } 处 ` ) ;
assert . ok ( ! /\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/ . test ( main ) ,
'★ 不许再用窗格 padding 让位 —— 那会缩短窗格、内容到不了条底下,玻璃就没东西可糊' ) ;
/*
* 让位要生效在**内容**上,不是条上(条自己 padding 不解决遮挡)。
*
@ -241,9 +264,16 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
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*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/ ,
'让位要加在挂载内容的那层上(链在那个 Column 的 } 之后),宽屏变 0、窄屏取 NAV_CONTENT_RESERVE' ) ;
/*
* 注:这里**不再**用"从内容层 `}` 往后扫"的窗口式断言。
* 那个切片靠数花括号定界,而本文件(以及这些注释本身)里就有大量 `{` / `}`
* ( `.padding({` 这类字面量)—— 计数被注释里的括号带偏,切片能长到 1700+ 字符,
* 一路扫进 `onAreaChange`(那里会合法地出现 `NAV_CONTENT_RESERVE`)⇒ 假红。
* 正是本仓库反复记的"窗口式判据"。
*
* 要判的事上面那条**已经判了**:全文件不得再有
* `.padding({ bottom: ... NAV_CONTENT_RESERVE })`(负向断言,不依赖切片)。
*/
} ) ;
test ( '★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且平级项是 通信/日历/联系人' , ( ) => {
@ -326,32 +356,53 @@ test('★ 玻璃只在两处、且这一处是"背后有可变内容"( GLASS
* 跨端对齐:这条与 WebUI 的 `nav-merge.test.mjs ④` 是同一条要求的两端实现,
* 两边各自钉住自己的写法(`cross-client-*` 那几条钉的是共享数值,不是这里)。
*/
test ( '⑤ 选中态只换颜色:纯 图标导航、图标变 色,且不引入背景/指示条/文字 ' , ( ) => {
test ( '⑤ 选中态只换颜色:图标与文字**一起**换 色,且不引入背景/指示条' , ( ) => {
const item = builderBody ( main , 'NavItem(item: NavItem, index: number) {' ) ;
const itemCode = stripComments ( item ) ;
/*
* 2026-09-16 用户:「底部导航栏不允许有文字」⇒ 导航项是**纯图标 **,
* 只有 1 处选中三元式( AmIcon 的 iconColor) 。文字那一半被去掉。
* ★ 2026-09-17 修正一处**事实错误 **。
*
* 这条判据原来钉的是"纯图标、不允许有文字",依据是注释里那句
* 「WebUI 的底部导航是纯图标」。那句话是**错的**:
* `NarrowNav.tsx:83-86` 的每个导航项是
* `flex flex-col items-center justify-center gap-0.5`
* 里面 `<Icon />` 之后就是 `<span className="text-3xs leading-none">{short}</span>`
* —— WebUI **一直有文字**(通信 / 日历 / 联系人 / 我的)。
*
* 于是那条判据把一个**假事实**固化成了规则,还反过来挡住了正确做法。
* 用户 2026-09-17 原话:「还是在导航栏加上文字吧,没有文字还是不好看」。
*
* 现在钉的是真正的契约:**图标与文字都跟着选中态换色**(两处三元式),
* 且**不引入**背景块/指示条 —— 用户 2026-09-14: 「选中对应的文字和图标变色即可」。
*/
const active = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g ;
const hits = itemCode . match ( active ) || [ ] ;
assert . equal ( hits . length , 1 ,
` 纯 图标导航只应有 1 处 选中三元式(现在 ${ hits . length } 处)— 若有 2 处是文字还留着 ` ) ;
// 图标上色必须存在( AmIcon 的 iconColor 跟着选中态走)
const icon = itemCode . slice ( itemCode . indexOf ( 'AmIcon({' ) , itemCode . indexOf ( 'AmIcon({' ) + 160 ) ;
assert . equal ( hits . length , 2 ,
` 图标与文字**各一处** 选中三元式(现在 ${ hits . length } 处)—— ` +
'少了是"选中了一半"(图标或文字不换色),多了是别的形状混进来了' ) ;
// 图标:换色 + 尺寸(与 WebUI 的 w-5 h-5 = 20 同量级,鸿蒙取 24)
const icon = itemCode . slice ( itemCode . indexOf ( 'AmIcon({' ) , itemCode . indexOf ( 'AmIcon({' ) + 200 ) ;
assert . match ( icon , /iconColor:\s*this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/ ,
'图标的 iconColor 必须跟着选中态走' ) ;
// 图标不能太小( 用户: 22 太小)
assert . match ( icon , /iconSize: 28/ ,
'底部导航图标要 28vp( 用户嫌 22 太小)' ) ;
// 无 文字:导航项里不该有 label 的 Text
assert . ok ( ! /Text\(item\.label\)/ . test ( itemCode ) ,
'底部导航是纯图标:不允许有文字 label( 2026-09-16 用户明确要求)' ) ;
assert . match ( icon , /iconSize:\s*24/ ,
'底栏图标 24vp( 与 WebUI `NarrowNav` 的 24× 24 同几何;原来 28 是为了"补没有文字时的小") ' ) ;
// 文字:**必须有**,且与图标过**同一个**三元式
assert . match ( itemCode , /Text\(item\.label\)/ ,
'底栏要有文字 label —— WebUI `NarrowNav.tsx` 一直是「图标 + 文字」,' +
'「纯图标」那条依据是错的(用户 2026-09-17: 「没有文字还是不好看」) ' ) ;
const label = itemCode . slice ( itemCode . indexOf ( 'Text(item.label)' ) ) ;
assert . match ( label . slice ( 0 , 220 ) ,
/fontColor\(this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg\)/ ,
'文字要与图标过同一个选中三元式(否则"选中了一半") ' ) ;
// 反面:选中态不得靠形状表达(背景 / 圆角块 / 下划线元素)
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/ ,
@ -360,19 +411,27 @@ test('⑤ 选中态只换颜色:纯图标导航、图标变色,且不引入
'未选中色取系统次要文字色(不手写色值)' ) ;
} ) ;
/** ⑤ 的变异自检:退回"带文字 / 太小 / 图标不上色"必须被判红。 */
test ( '★ ⑤ 变异自检: nav 项退回带 文字或 22 尺寸或图标不上色 必须被判红' , ( ) => {
// 变异 1: 带文字( Text(item.label))⇒ ⑤ 的"无文字"会命中它
const withLabel = ` Column() { \n AmIcon({ iconName: item.iconKey, iconSize: 28 }) \n Text(item.label) \n .fontColor( this.currentIndex === index ? Theme.navFgActive : Theme.navFg) \n } ` ;
assert . ok ( /Text\(item\.label\)/ . test ( withLabel ) ,
'变异 1 自检失败:样本里必须有 label 文字' ) ;
// 变异 2: 图标 22( 太小) ⇒ ⑤ 的"iconSize: 28"会命中它
const smallIcon = ` Column() { \n AmIcon({ iconName: item.iconKey, iconSize: 22, iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg }) \n } ` ;
assert . ok ( /iconSize: 22/ . test ( smallIcon ) && ! /iconSize: 28/ . test ( smallIcon ) ,
'变异 2 自检失败:样本里图标必须是 22 且不是 28' ) ;
// 变异 3: 图标不上色( 没有 iconColor) ⇒ ⑤ 的"iconColor 跟着选中态"会命中它
const noColor = ` Column() { \n AmIcon({ iconName: item.iconKey, iconSize: 28 }) \n } ` ;
assert . ok ( ! /iconColor/ . test ( noColor ) , '变异 3 自检失败:样本里不该有 iconColor' ) ;
/** ⑤ 的变异自检:退回"只有图标换色 / 没有文字 / 图标不上色"必须被判红。 */
test ( '★ ⑤ 变异自检: nav 项退回缺 文字、或图标/文字只换一半色, 必须被判红' , ( ) => {
const oneActive = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g ;
const twoActive = / this\ .currentIndex === index \ ? Theme\ .navFgActive : Theme\ .navFg/g ;
// 变异 1: 没有文字( 就是被纠正的那版) ⇒ 三元式只剩 1 处 + 没有 Text(item.label)
const noLabel = 'Column() { AmIcon({ iconName: item.iconKey, iconSize: 24, iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg }) }' ;
assert . equal ( ( noLabel . match ( oneActive ) || [ ] ) . length , 1 ,
'变异 1 自检失败:无文字样本应当只有 1 处选中三元式' ) ;
assert . ok ( ! /Text\(item\.label\)/ . test ( noLabel ) ,
'变异 1 自检失败:无文字样本里不该有 Text(item.label)' ) ;
// 变异 2: 文字不换色( 选中了一半)
const labelNoColor = 'Column() { AmIcon({ iconName: item.iconKey, iconSize: 24, iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg }) Text(item.label).fontColor(Theme.navFg) }' ;
assert . equal ( ( labelNoColor . match ( twoActive ) || [ ] ) . length , 1 ,
'变异 2 自检失败:文字不换色时应当只有图标那 1 处三元式(判据会判红)' ) ;
// 变异 3: 图标不上色
const noIconColor = 'Column() { AmIcon({ iconName: item.iconKey, iconSize: 24 }) Text(item.label).fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg) }' ;
assert . ok ( ! /iconColor/ . test ( noIconColor ) ,
'变异 3 自检失败:样本里图标不该有 iconColor' ) ;
} ) ;
/**