import assert from 'node:assert/strict' import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' const root = resolve(import.meta.dirname, '..') const read = (path) => readFileSync(resolve(root, path), 'utf8') /** * 需要读取页面运行源时先断言存在。 * 直接 readFileSync 会抛裸 ENOENT 栈,看不出「契约引用了陈旧文件名」这一真实原因—— * 页面在 2026-09-14 全量 TS 化后,本契约曾因仍引用 .js 而崩溃(而非给出可读断言)。 */ const readSource = (path) => { assert.ok(existsSync(resolve(root, path)), `契约引用的运行源不存在: ${path}(页面已迁移?请更新契约路径)`) return read(path) } /** 断言页面运行源为 TypeScript 且不存在同名 .js 双源 */ const readPageLogic = (page) => { const ts = `miniapp/pages/${page}/${page}.ts` assert.equal( existsSync(resolve(root, `miniapp/pages/${page}/${page}.js`)), false, `${page} 存在 .js 与 .ts 双源,违反单一运行源约定`, ) return readSource(ts) } const app = JSON.parse(read('miniapp/app.json')) const corePages = [ 'index', 'discover', 'messages', 'activity', 'mine', 'matchmaker', 'counseling-intro', 'premarital-intro', 'content', 'anti-fraud', 'service-station', ] const quickEntryCodes = [ 'find_object', 'matchmaker', 'activities', 'counseling', 'classroom', 'premarital', 'anti_fraud', 'service_station', ] assert.deepEqual( app.tabBar.list.map((item) => item.text), ['首页', '找对象', '消息', '活动', '我的'], '五个 Tab 的顺序发生偏移', ) for (const page of corePages) { const base = `miniapp/pages/${page}/${page}` for (const extension of ['.json', '.wxml', '.wxss']) { assert.ok(existsSync(resolve(root, base + extension)), `${page}${extension} 不存在`) } const template = read(base + '.wxml') assert.match(template, /reference-page/, `${page} 未接入参考图页面骨架`) assert.doesNotMatch(template, /[\u{1F300}-\u{1FAFF}]/u, `${page} 含 Emoji`) // 首批 11 页必须为 TypeScript 单一运行源(readPageLogic 会同时断言 .js 不存在) readPageLogic(page) } const homeLogic = read('miniapp/pages/index/index.ts') let previousIndex = -1 for (const code of quickEntryCodes) { const index = homeLogic.indexOf(code) assert.ok(index >= 0, `首页缺少入口 ${code}`) assert.ok(index > previousIndex, `首页入口 ${code} 顺序错误`) previousIndex = index } const discoverTemplate = read('miniapp/pages/discover/discover.wxml') assert.match(discoverTemplate, /discover-filter-chips/, '找对象页缺少参考图胶囊筛选区') assert.match(discoverTemplate, /candidate-list/, '找对象页缺少参考图候选人列表') assert.match(discoverTemplate, /\/assets\/icons\/ui\/search\.png/, '找对象页必须使用独立搜索图标') const messagesTemplate = read('miniapp/pages/messages/messages.wxml') const messagesLogic = readPageLogic('messages') assert.match(messagesTemplate, /message-shortcuts/, '消息页缺少四项快捷入口') assert.match(messagesTemplate, /conversation-list/, '消息页缺少会话列表容器') assert.match(messagesTemplate, /ui-conv__badge|unreadTotal/, '消息页缺少未读提示') assert.match(messagesTemplate, /互相喜欢后/, '消息页缺少无 Mutual 状态说明') assert.match(messagesLogic, /canSendMessage/, '消息页进入聊天前必须校验服务端会话能力') const activityTemplate = read('miniapp/pages/activity/activity.wxml') const activityCard = read('miniapp/components/activity-card/index.wxml') const activityLogic = readPageLogic('activity') assert.match(activityTemplate, /activity-categories/, '活动页缺少活动分类') assert.match(activityTemplate, /activity-calendar/, '活动页缺少日历入口') assert.match(activityCard, /ui-act__capacity/, '活动卡缺少人数和容量') assert.match(activityCard, /ui-act__status-action/, '活动卡缺少状态按钮') assert.doesNotMatch(activityLogic, /matchStore|to\.chat/, '活动列表不得直接创建 Match 或进入聊天') const mineTemplate = read('miniapp/pages/mine/mine.wxml') assert.match(mineTemplate, /profile-hero/, '我的页缺少个人头部') assert.match(mineTemplate, /profile-stats/, '我的页缺少四项统计') assert.match(mineTemplate, /我在善缘丹阳/, '我的页缺少公益身份卡') for (const label of ['我的红娘', '我的咨询', '我的活动', '我的预约', '账号设置']) { assert.ok(mineTemplate.includes(label), `我的页缺少入口 ${label}`) } // U37「我的」四组结构(《页面级功能清单与交互流程修订版》第三十一章信息架构) // 缺组会让入口找不到归属,缺入口会让已实现的页面变成不可达死页(Phase 19 的 U49 就是如此)。 const mineGroups = mineTemplate.match(/ui-section-header\s+title="[^"]*"/g) || [] assert.strictEqual(mineGroups.length, 4, `我的页必须是四组结构,实际 ${mineGroups.length} 组`) for (const group of ['我的资料与缘分', '我的公益服务', '安全与隐私', '公益信息']) { assert.ok(mineTemplate.includes(`title="${group}"`), `我的页缺少分组「${group}」`) } for (const label of ['公益服务中心', '公益服务记录', '黑名单', '婚恋课堂', '婚恋反诈', '善缘服务站', '关于善缘丹阳']) { assert.ok(mineTemplate.includes(label), `我的页缺少入口 ${label}`) } // 每个 bindtap 必须在同名 .ts 里真的实现了,否则是"点不动的入口" const mineLogic = readPageLogic('mine') const mineHandlers = new Set( [...mineTemplate.matchAll(/bind(?:tap|action|confirm|cancel)="([A-Za-z0-9_]+)"/g)].map((m) => m[1]), ) for (const handler of mineHandlers) { assert.ok( new RegExp(`\\b${handler}\\s*\\(`).test(mineLogic), `我的页入口绑定了未实现的处理器 ${handler}(死链)`, ) } const matchmakerTemplate = read('miniapp/pages/matchmaker/matchmaker.wxml') const matchmakerLogic = readPageLogic('matchmaker') const counselingTemplate = read('miniapp/pages/counseling-intro/counseling-intro.wxml') const premaritalTemplate = read('miniapp/pages/premarital-intro/premarital-intro.wxml') for (const template of [matchmakerTemplate, counselingTemplate, premaritalTemplate]) { assert.match(template, /ui-service-hero/, 'B 组服务页必须统一使用 service-hero') } for (const label of ['公益免费', '真实匹配', '隐私保护']) { assert.ok(matchmakerTemplate.includes(label), `红娘页缺少服务承诺 ${label}`) } assert.match(matchmakerTemplate, /activeService|申请状态/, '红娘页缺少申请状态') assert.doesNotMatch(matchmakerLogic, /matchStore\.(?:like|pass)\s*\(/, '红娘不得替用户 Like 或 Pass') assert.match(counselingTemplate, /service-topics/, '咨询页缺少四项咨询主题') assert.match(counselingTemplate, /预约咨询/, '咨询页缺少预约按钮') assert.match(counselingTemplate, /服务状态/, '咨询页缺少服务状态') assert.match(premaritalTemplate, /service-topics/, '婚前辅导页缺少四项辅导主题') assert.match(premaritalTemplate, /预约辅导/, '婚前辅导页缺少预约按钮') assert.match(premaritalTemplate, /服务状态/, '婚前辅导页缺少服务状态') const contentTemplate = read('miniapp/pages/content/content.wxml') const contentLogic = readPageLogic('content') assert.match(contentTemplate, /content-categories/, '婚恋课堂缺少分类导航') assert.match(contentTemplate, /content-list/, '婚恋课堂缺少内容列表') assert.match(contentTemplate, /viewCount/, '婚恋课堂缺少真实浏览量') assert.match(contentTemplate, /publishAtText/, '婚恋课堂缺少发布日期') assert.match(contentLogic, /\/content\/categories/, '婚恋课堂分类必须来自正式接口') assert.match(contentLogic, /\/content\/articles/, '婚恋课堂文章必须来自正式接口') assert.doesNotMatch(contentLogic, /id:\s*['"]1['"]/, '婚恋课堂不得保留演示文章') const antiFraudTemplate = read('miniapp/pages/anti-fraud/anti-fraud.wxml') assert.match(antiFraudTemplate, /ui-service-hero/, '婚恋反诈必须使用独立主视觉') assert.match(antiFraudTemplate, /risk-grid/, '婚恋反诈缺少四类风险') assert.match(antiFraudTemplate, /举报可疑行为/, '婚恋反诈缺少举报入口') assert.match(antiFraudTemplate, /安全求助/, '婚恋反诈缺少求助入口') const stationTemplate = read('miniapp/pages/service-station/service-station.wxml') const stationLogic = readPageLogic('service-station') const stationService = readSource('miniapp/services/serviceStationService.js') for (const label of ['地址', '联系电话', '开放时间', '服务项目', '地图定位', '预约到站服务']) { assert.ok(stationTemplate.includes(label), `服务站缺少 ${label}`) } assert.match(stationLogic, /wx\.makePhoneCall/, '服务站电话必须使用微信原生拨号') assert.match(stationLogic, /wx\.openLocation/, '服务站定位必须使用微信原生地图') assert.match(stationLogic, /serviceStationService/, '服务站页面必须通过 services 层取数(不得直连 utils/api)') assert.doesNotMatch(stationLogic, /utils\/api/, '服务站页面不得直接依赖 utils/api(分层约定见 services/index.js)') // Phase 19:站点主体信息来自首页聚合。断言落在**服务层**——页面已按分层约定 // 收敛到 serviceStationService,若仍断言页面源码会将「分层重构」误判为「不再读真实数据」。 assert.match(stationService, /api\.get\(\s*['"]\/home['"]\s*\)/, '服务站必须读取真实首页服务站数据') assert.doesNotMatch(stationService, /00000000/, '服务站不得保留演示电话') assert.doesNotMatch(stationLogic, /00000000/, '服务站不得保留演示电话') // Phase 19:U50「临时公告」。此前全仓无公告存储与接口,本轮补齐后端 + 用户端只读端点。 assert.ok(stationTemplate.includes('临时公告'), '服务站缺少临时公告区块(U50 内容清单明确要求)') assert.match(stationService, /\/service-locations\/\$\{[^}]*\}\/announcements/, '公告必须来自正式接口 /service-locations/{id}/announcements') assert.match(stationLogic, /getVisibleAnnouncements/, 'U50 必须接入可见公告服务方法') // 公告为增强信息:失败必须降级为空数组,不得把整页打成错误页 assert.match(stationService, /catch[\s\S]{0,200}return \[\]/, '公告加载失败必须降级为空数组(公告挂了不该让服务站详情页整体报错)') { const stationHandlers = new Set( [...stationTemplate.matchAll(/bind(?:tap|action|confirm|cancel)="([A-Za-z0-9_]+)"/g)].map((m) => m[1]), ) for (const handler of stationHandlers) { assert.ok( new RegExp(`\\b${handler}\\s*\\(`).test(stationLogic), `服务站页 bindtap="${handler}" 在 .ts 中没有对应实现(点了没反应)`, ) } } for (const envFile of ['miniapp/env.js', 'miniapp/env.ts']) { const envSource = read(envFile) assert.match(envSource, /getAccountInfoSync/, `${envFile} 必须通过微信运行版本识别开发环境`) assert.match(envSource, /envVersion\s*===\s*['"]develop['"]/, `${envFile} 必须将 develop 环境指向本地 API`) } const theme = read('miniapp/app.wxss') for (const token of [ '--mini-night-start', '--mini-night-end', '--mini-accent-pink', '--mini-accent-coral', '--mini-card-bg', '--mini-card-radius', '--mini-shadow', ]) { assert.ok(theme.includes(token), `缺少全局 Token ${token}`) } console.log('Miniapp reference contracts passed')