Files
danyang-match/ui-tests/miniapp-capsule-safe-contracts.mjs
T
WorkBuddy 1fc34f1bcd fix(miniapp): 修复导航页与微信胶囊重合,并把首页两个栏目卡缩到 1/3
问题 1:五个导航页顶部与右上角胶囊按钮(··· ⊙)重合
- 根因:自定义导航用 env(safe-area-inset-top) 取的是设备物理安全区,
  在 Android 上常为 0,完全不反映胶囊位置;且右对齐元素从未为胶囊让出宽度。
- 新增 miniapp/utils/nav-metrics.js 作为唯一事实源:以
  wx.getMenuButtonBoundingClientRect() 实测胶囊矩形,反推并导出四个 CSS 变量
  --nav-status / --nav-h / --nav-right / --nav-top,并提供无接口时的兜底值。
- nav-bar 组件改为自行获取导航度量(attached + observer 双触发),
  标题按胶囊垂直居中并收窄 max-width;外部传参降级为兜底。
- index / discover / messages / activity / mine 五页根结点注入同一组变量,
  WXSS 里的 env(safe-area-inset-top)、硬编码 88rpx 与右对齐 right:0 全量替换。

问题 2:首页「善缘公益服务」「善缘安全提醒」两个栏目过宽
- 根因:封面 <image> 只声明 width 未声明 height,小程序默认按 240px 计算,
  实际把卡片撑到约 480rpx 高。
- 按「整体等比缩到 1/3」重构:两栏改为 mini-card 小卡,宽度
  calc((100% - 2*gap)/3)、高度写死 168rpx,封面 height:100%;
  公益服务一行三卡横向排布,安全提醒为「标题小卡 + 右侧文案条」,
  长文案收进详情页,安全提示原文保留。

守卫与验证
- 新增 ui-tests/miniapp-capsule-safe-contracts.mjs:
  A 层多机型断言导航行与胶囊垂直居中、右对齐让位、navTop 与兜底值;
  B 层纯函数审计五个自带导航行页面(页面清单由文件系统推导,失配直接报红);
  C 层断言首页小卡宽为 1/3 且显式声明高度,并钉住旧 service-/safety- 选择器回归。
  审计前剥离注释,避免守卫被自身示例文本误伤。
- 新增 scripts/audit-image-height.mjs:按属性名精确比对,审计全仓
  「有 width 无 height」的 <image>,当前 30 个类 0 命中。
- 负向注入 N1~N7(含 nav-safe 未接入、旧选择器回归、极小屏幕、无胶囊兜底等)
  全部被守卫咬住并 exit 1,还原后复跑通过。
- 真编译(wcc/wcsc):WXSS 88 / WXML 86,失败 0;
  tsc --noEmit 通过;UI 契约门控 21/21 PASS(新增第 21 个守卫并抬高 MIN_GUARDS)。
- 渲染探针实测:单卡 213rpx x 168rpx;五页右对齐元素右缘 423~528px,
  均在胶囊右缘 365px 之外。

未纳入本次提交:miniapp/project.config.json(微信开发者工具自动改写,
仅追加若干布尔开关并删掉文件末换行)与生成的预览页 artifacts preview.html
(约 7900 行生成产物),以免淹没代码评审视线。
2026-09-23 09:55:40 +08:00

241 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 契约:自定义导航页必须为右上角「胶囊按钮」留出安全区
//
// 由来(2026-09-23,用户在微信开发者工具实测反馈):
// 首页顶部、以及找对象/消息/活动/我的四个 Tab 页顶部的内容与微信胶囊按钮(「••• ⊙」)重合:
// - 竖直方向:消息页四个快捷入口、活动页分类胶囊整体落在胶囊所在横带里被压住;
// - 水平方向:首页「消息」、找对象「搜索」、活动「日历」、我的「设置 / 编辑资料」、
// 聊天页「⋯」(ui-nav-bar 的右侧 slot)被压在胶囊下面,点不到。
// 根因不是「padding 写小了」,而是**取值来源错了**:五个页面都写
// `padding-top: env(safe-area-inset-top)`。该值只反映设备物理安全区(刘海/挖孔),
// 安卓上常为 0,且**完全不反映胶囊按钮的位置**;右对齐元素更是从未让出胶囊宽度。
// ⇒ 唯一可靠来源是 `wx.getMenuButtonBoundingClientRect()` 实测。
//
// 本守卫锁三层(都是「改回旧写法就会复发」的部分):
// A. 几何算法:胶囊必须落在导航行内垂直居中,右对齐元素必须整体落在胶囊左侧;
// B. 页面覆盖:每个自定义导航页要么用 ui-nav-bar(组件统一处理),要么注入 navVars
// 并在 wxss 里用 var(--nav-status/--nav-h/--nav-right),且不得只依赖 env();
// C. 首页两张卡片的尺寸契约:宽度 1/3 + **显式 height**。
// (同一批反馈的第二个问题:封面图只写 width 不写 height,小程序 <image> 会取默认
// 240px = 480rpx,卡片被撑到约 480rpx 高。脚本 scripts/audit-image-height.mjs
// 对修复前版本实测命中 .service-cover / .safety-cover 两条规则。)
//
// 自检:A/B/C 各带正反样例,防止守卫自己变哑(本项目已有三次「守卫全绿但缺陷在」的教训)。
import assert from 'node:assert/strict'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import navMetrics from '../miniapp/utils/nav-metrics.js'
const root = resolve(import.meta.dirname, '..')
const read = (path) => readFileSync(resolve(root, path), 'utf8')
/**
* 分析前剥离注释 —— 本项目已有一次教训:守卫被**自己的说明注释**误伤
* (注释里写了示例代码,被当成真实代码断言)。本文件里就写了 `env(safe-area-inset-top)`
* 作为反例说明,不剥离会立刻假红。
* 注意保留换行,避免后续若按行定位时行号漂移(wxss-selectors.mjs 曾因此偏 68 行)。
*/
const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' '))
const readWxss = (path) => stripComments(read(path))
const { computeNavMetrics } = navMetrics
// ────────────────────────────── A. 几何算法 ──────────────────────────────
// 本守卫不得自己维护一份机型表当成事实源:真值必须来自胶囊实测。
const metricSource = read('miniapp/utils/nav-metrics.js')
assert.match(
metricSource,
/getMenuButtonBoundingClientRect/,
'顶部安全区必须由 wx.getMenuButtonBoundingClientRect() 实测胶囊位置,不得写死机型常量',
)
const DEVICE_SAMPLES = [
{ name: 'iPhone SE/8(无刘海)', windowWidth: 375, statusBarHeight: 20, capsule: { top: 24, left: 281, width: 87, height: 32 } },
{ name: 'iPhone 13(刘海)', windowWidth: 390, statusBarHeight: 47, capsule: { top: 51, left: 292, width: 87, height: 32 } },
{ name: 'Android 常见', windowWidth: 360, statusBarHeight: 24, capsule: { top: 32, left: 267, width: 87, height: 32 } },
{ name: '小屏 Android', windowWidth: 320, statusBarHeight: 20, capsule: { top: 28, left: 228, width: 87, height: 32 } },
]
for (const device of DEVICE_SAMPLES) {
const m = computeNavMetrics(device)
assert.equal(m.source, 'capsule', `${device.name}: 有胶囊信息时必须走实测分支`)
// 1) 胶囊在导航行内垂直居中 —— 导航行的上边缘 = 状态栏高度
const capsuleCenter = device.capsule.top + device.capsule.height / 2
const navBarCenter = m.statusBarHeight + m.navBarHeight / 2
assert.ok(
Math.abs(capsuleCenter - navBarCenter) <= 1,
`${device.name}: 导航行与胶囊未垂直对齐(胶囊中心 ${capsuleCenter} vs 导航行中心 ${navBarCenter})`,
)
// 2) 内容起点 = 状态栏 + 导航行
assert.equal(m.navTop, m.statusBarHeight + m.navBarHeight, `${device.name}: navTop 必须等于状态栏 + 导航行`)
// 3) 右对齐元素让出的宽度必须真正覆盖胶囊:右边缘 ≤ 胶囊左边缘 - 余量
const contentRightEdge = device.windowWidth - m.reserve
assert.ok(
contentRightEdge <= device.capsule.left,
`${device.name}: 右对齐元素右边缘 ${contentRightEdge} 仍在胶囊(左边缘 ${device.capsule.left})右侧,会被压住`,
)
assert.ok(m.reserve <= device.windowWidth / 2, `${device.name}: 安全区不得超过半屏,否则内容区被挤没`)
// 4) 导航行高度落在合理区间,不能退化成 0/NaN
assert.ok(m.navBarHeight >= 40 && m.navBarHeight <= 56, `${device.name}: 导航行高度异常 ${m.navBarHeight}`)
}
// 拿不到胶囊信息(低版本基础库 / 接口异常)时必须退回兜底值,且宁可多留不可少留
const fallback = computeNavMetrics({ windowWidth: 375, statusBarHeight: 44 })
assert.equal(fallback.source, 'fallback', '无胶囊信息时应标记为兜底取值')
assert.ok(fallback.navTop >= 64, `无胶囊信息时也必须留足顶部空间,实得 ${fallback.navTop}`)
// 极端输入:胶囊 tops 落在状态栏内(gap 为负)时不得产出 NaN / 负值
const degenerate = computeNavMetrics({ windowWidth: 375, statusBarHeight: 44, capsule: { top: 40, left: 281, width: 87, height: 32 } })
assert.ok(Number.isFinite(degenerate.navBarHeight) && degenerate.navBarHeight >= 40, '胶囊与状态栏重叠时仍须给出合法导航行高度')
// ─────────────────────── B. 页面覆盖:自定义导航页 ───────────────────────
/** 纯函数:审计一个「自带导航行」的自定义导航页,返回问题清单
* (并入参已剥离注释:见 stripComments 注释)*/
function auditTabHeader({ wxml = '', ts = '', wxss = '' } = {}) {
const issues = []
const rootTag = (wxml.match(/<view[^>]*reference-page[^>]*>/) || [''])[0]
if (!/style="\{\{navVars\}\}"/.test(rootTag)) {
issues.push('根节点未注入 style="{{navVars}}"(拿不到实测安全区)')
}
if (!/navVars\s*:\s*''/.test(ts)) issues.push('data 里缺少 navVars 初始值')
if (!/require\(['"]\.\.\/\.\.\/utils\/nav-metrics['"]\)/.test(ts)) issues.push('未引入 utils/nav-metrics')
if (!/setData\(\s*navSafeData\(\)\s*\)/.test(ts)) issues.push('未在 onLoad 里 setData(navSafeData())')
// env(safe-area-inset-top) 本身不是非法 API,但它只反映设备物理安全区、不反映胶囊位置,
// 单独使用即本次缺陷的原始写法 —— 出现即判红,改用 var(--nav-status)。
if (/env\(safe-area-inset-top\)/.test(wxss)) issues.push('仍在用 env(safe-area-inset-top)(不反映胶囊位置)')
if (!/var\(--nav-status/.test(wxss)) issues.push('顶部未用 var(--nav-status) 顶开状态栏')
if (!/var\(--nav-h/.test(wxss)) issues.push('导航行高度未用 var(--nav-h),无法与胶囊垂直对齐')
if (!/var\(--nav-right/.test(wxss)) issues.push('右对齐元素未让出 var(--nav-right) 胶囊安全区')
return issues
}
// 自检:正向样例(修好之后的样子)必须 0 问题;反向样例(本次缺陷的原始写法)必须全部命中
const SELF_CHECK = [
{
name: '正向:注入 navVars + 用 var(--nav-status/--nav-h/--nav-right)',
sample: {
wxml: '<view class="page app-page reference-page" style="{{navVars}}">',
ts: "const { navSafeData } = require('../../utils/nav-metrics')\nnavVars: ''\nthis.setData(navSafeData())",
wxss: '.hero{padding:var(--nav-status, 44px) 24rpx 24rpx}.nav{height:var(--nav-h, 80rpx)}.act{right:var(--nav-right, 102px)}',
},
expect: 0,
},
{
name: '反向:本次缺陷的原始写法(只用 env(safe-area-inset-top),无 navVars)',
sample: {
wxml: '<view class="page app-page reference-page">',
ts: 'Page({ data: {}, onLoad() {} })',
wxss: '.hero{padding:env(safe-area-inset-top) 24rpx 24rpx}.nav{height:88rpx}.act{right:0}',
},
expect: 8,
},
]
for (const c of SELF_CHECK) {
const issues = auditTabHeader(c.sample)
assert.equal(
issues.length,
c.expect,
`守卫自检失败 —— ${c.name}\n期望 ${c.expect} 条问题,实得 ${issues.length} 条:\n ${issues.join('\n ')}`,
)
}
// 从文件系统推导清单(不写死),新增自定义导航页会被自动纳入
const pagesRoot = resolve(root, 'miniapp/pages')
const pageNames = readdirSync(pagesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
const customNavPages = pageNames.filter((name) => {
const jsonPath = join(pagesRoot, name, `${name}.json`)
if (!existsSync(jsonPath)) return false
try {
return JSON.parse(readFileSync(jsonPath, 'utf8')).navigationStyle === 'custom'
} catch {
return false
}
})
assert.ok(customNavPages.length >= 20, `自定义导航页只找到 ${customNavPages.length} 个 —— 扫描清单可能失配,拒绝报绿`)
const selfNavPages = customNavPages.filter((name) => {
const wxmlPath = join(pagesRoot, name, `${name}.wxml`)
return existsSync(wxmlPath) && !readFileSync(wxmlPath, 'utf8').includes('ui-nav-bar')
})
const EXPECTED_SELF_NAV = ['index', 'discover', 'messages', 'activity', 'mine']
for (const name of EXPECTED_SELF_NAV) {
assert.ok(selfNavPages.includes(name), `${name} 应自带导航行(未用 ui-nav-bar)但未被识别 —— 清单失配`)
}
const failures = []
for (const name of selfNavPages) {
const issues = auditTabHeader({
wxml: read(`miniapp/pages/${name}/${name}.wxml`),
ts: read(`miniapp/pages/${name}/${name}.ts`),
wxss: readWxss(`miniapp/pages/${name}/${name}.wxss`),
})
if (issues.length) failures.push(`${name}: ${issues.join(';')}`)
}
assert.deepEqual(failures, [], `以下自定义导航页仍会让内容与胶囊按钮重合:\n ${failures.join('\n ')}`)
// 右侧 slot 的使用者(聊天页「⋯」)依赖 ui-nav-bar 的胶囊安全区,必须仍走组件
for (const name of pageNames) {
const wxmlPath = join(pagesRoot, name, `${name}.wxml`)
if (!existsSync(wxmlPath)) continue
if (readFileSync(wxmlPath, 'utf8').includes('slot="right"')) {
assert.ok(
read(`miniapp/pages/${name}/${name}.wxml`).includes('ui-nav-bar'),
`${name} 用了导航栏右侧 slot 但未使用 ui-nav-bar,右侧操作会落在胶囊下面`,
)
}
}
// ui-nav-bar 组件自身必须处理胶囊(20 个非 Tab 页共用它)
const navBarJs = read('miniapp/components/nav-bar/index.js')
const navBarWxss = read('miniapp/components/nav-bar/index.wxss')
assert.match(navBarJs, /getNavMetrics/, 'ui-nav-bar 必须实测胶囊位置(见 utils/nav-metrics.js)')
assert.match(navBarJs, /--nav-right/, 'ui-nav-bar 必须把胶囊安全区注入 wxss')
assert.match(
navBarWxss,
/\.ui-nav__title\s*\{[^}]*max-width:\s*calc\(100% - 2 \* var\(--nav-right/,
'ui-nav-bar 标题必须让开胶囊(左右对称留白,才能既居中又不被压住)',
)
assert.match(
navBarWxss,
/\.ui-nav__right\s*\{[^}]*padding-right:\s*var\(--nav-right/,
'ui-nav-bar 右侧 slot 必须整体左移到胶囊左侧',
)
assert.match(navBarWxss, /\.ui-nav__bar\s*\{[^}]*height:\s*var\(--nav-h/, 'ui-nav-bar 导航行高度必须与胶囊对齐')
// ─────────────────── C. 首页两张卡片:1/3 尺寸 + 显式高度 ───────────────────
const indexWxss = readWxss('miniapp/pages/index/index.wxss')
const cardRule = (indexWxss.match(/\.mini-card\s*\{([^}]*)\}/) || [])[1] || ''
assert.ok(cardRule, '首页必须保留 .mini-card 规则(公益服务 / 安全提醒的 1/3 小卡)')
assert.match(cardRule, /width:\s*calc\(\(100% - var\(--spacing-2\) \* 2\) \/ 3\)/, '小卡宽度必须是三分一行(原尺寸的 1/3)')
assert.match(cardRule, /(?:^|;)\s*height:\s*\d+rpx/, '小卡必须写死高度(约 1/3),否则会被内容或默认高度撑开')
const coverRule = (indexWxss.match(/\.mini-card-cover\s*\{([^}]*)\}/) || [])[1] || ''
assert.ok(coverRule, '首页必须保留 .mini-card-cover 规则')
assert.match(coverRule, /(?:^|;)\s*width\s*:/, '封面必须有显式宽度')
assert.match(
coverRule,
/(?:^|;)\s*height\s*:/,
'封面必须写显式 height —— 小程序 <image> 未指定高度时取默认 240px(=480rpx),卡片会被撑到约 480rpx',
)
// 回归钉:本次缺陷的两条「只写 width / min-height」的封面规则不得复活
for (const legacy of ['.service-cover', '.safety-cover']) {
assert.ok(
!new RegExp(`\\${legacy}\\s*\\{`).test(indexWxss),
`首页不得恢复「只写 width 不写 height」的 ${legacy} 规则(该写法会把卡片撑到约 480rpx)`,
)
}
console.log(`胶囊安全区契约通过(实测机型 ${DEVICE_SAMPLES.length} 组 + 自带导航行页面 ${selfNavPages.length} 个)`)