UAT 走查(docs/UAT-WALKTHROUGH-2026-09-23.md)发现 4 项硬阻塞,本次闭环其中 3 项 (#2 微信登录、#5 照片直传不在本轮范围),并顺带修掉走查中新发现的跨端字段契约缺陷。 后端 - #6 推荐恒空:新增 MatchScoringService 作为唯一评分事实源(复用 UserPreference 既有 10 维权重,零新造常量);RecommendationEngineService.calculateScore/generateReasons 由 桩(return 50.0)改为委托;MatchEligibilityService 3 处桩全部去雷。此前 50 < 阈值 60 → GET /recommendations 恒返回空数组。 - #4 保存即失败:updateProfile / updateBasicProfile / savePreference 改为 「无则插入、有则更新」。注意 UserPreference 实体 @TableId=id 与库主键 user_id 不一致 (V037 后补的可空列),故按 user_id 定位更新而非 updateById(后者会静默不更新)。 gender / birth_date 为 NOT NULL 且向导不采集,缺失时从已通过实名认证的 user_identities 补齐,仍缺则抛 400 业务拒绝,不再让 DataIntegrityViolationException 渲染成 500。 GET /me/profile 的 preferences 补回 5 个此前未返回字段(regionPriority / maritalPriority / minEducationCode / educationPriority / childrenPriority)。 - #8 越权端点:POST /activities/{id}/interests/confirm 不再直调「建 Match 原语」 RelationshipService.confirmActivityInterest,改为与 /activities/{id}/mutual/* 同一条 二阶段同意链路(createMutualConsent + confirmConnection),单方调用不再可能建 Match (INV-014 / INV-015)。 - #10 文档不一致:MatchmakerController 类注释更正(operator 端点实际在 /operator/matchmaker)。 - 存量 500 家族:RuntimeExceptionThrowGuardTest 的正则只认 throw new RuntimeException(, 而 17 处裸抛写作 orElseThrow(() -> new RuntimeException(...)),整体绕过守卫(CI 全绿却 仍有 500 家族缺陷)→ 17 处全量改为 GlobalExceptionHandler.BusinessException; 守卫扩为「整段代码 + 该形态」,并加自检断言防止守卫退化成恒真空断言。 小程序(跨端字段名 / 取值契约) - 字段名对齐后端规范名:hasChildren→childrenStatus、occupationCategory→occupationCode、 workRegion→workRegionCode、ageMin/ageImportance→minAge/agePriority、 educationAccepted/maritalStatusAccepted→minEducationCode/maritalStatuses。 此前字段名对不上,后端读不到 → 字段被静默丢弃。 - 取值修 4 类违反 DB CHECK(写入直接 500)的项:has→yes;high_school→senior_high、 phd→doctor(chk_profile_edu);occasional→sometimes(chk_profile_smoking/drinking)。 - 预填改读服务端真实字段:此前读 profile.hasChildren / occupationCategory / workRegion, 而服务端返回的是 childrenStatus / occupationCode / workRegionCode → 表单永远回填不上。 - profile-confirm 完成度改为直接采用服务端 profileCompletion(AGENTS 第 9 条:DB 是 Source of Truth),废弃原先 22 条「读服务端不存在字段」的本地猜测式断言。 - 择偶条件页新增「展示形态 ↔ 服务端规范形态」适配器(toApiPayload / fromApiPayload)。 测试与门禁 - 新增 MatchScoringServiceTest、RecommendationEngineServiceBehaviorTest、 ProfileSaveUpsertBehaviorTest、ActivityInterestConfirmConsentBehaviorTest 与 ui-tests/miniapp-profile-contracts.mjs(桩 stores,真跑三个页面的事件处理与 onNext, 断言提交负载用规范字段名与枚举码)。 - MIN_GUARDS 21→23(并修正上一轮漏改:该值当时仍为 21,与守卫数不符,使「数量下限」 保护形同虚设)。 - 负向注入实测:把 #6 评分改回桩 50.0、#4 改回恒 insert、#8 改回绕开二阶段、并注入 orElseThrow 形态裸异常 → 4 个守卫全部变红(含精确定位到 RecommendationEngineService.java:282)。 - 后端:795 tests / 0 failures / 3 skipped;UI 守卫 23/23;语种审计 0 命中;tsc 通过; wcc/wcsc 真编译 88 WXSS + 86 WXML / 0 失败。 仍未修(需产品/数据决策,见报告 8.3) - regionScope 需 GB/T 2260 单值区划码,而小程序选项含「镇江其他辖区」这类非单值项 → 不臆造区划码(该约束默认 preferred 软约束,不误过滤); - occupationPreference 后端无对应列;settlementPreference 后端无取值字典。
122 lines
6.6 KiB
JavaScript
122 lines
6.6 KiB
JavaScript
#!/usr/bin/env node
|
||
// UI 契约门禁统一执行器(本地 + CI 共用入口)
|
||
//
|
||
// 背景:ui-tests/*.mjs 是一组「源码结构与关键行为断言」守卫(纯 node 内建模块、零 npm 依赖),
|
||
// 2026-09-18 之前**只在本地手工逐个执行** —— 没有 CI 把关,改坏了没人知道。
|
||
// 本脚本把「逐个跑 + 汇总 + 非零退出」固化下来:
|
||
//
|
||
// node scripts/run-ui-contracts.mjs # 跑 ui-tests/ 下全部守卫(默认)
|
||
// node scripts/run-ui-contracts.mjs <file...> # 只跑指定守卫(排查用)
|
||
//
|
||
// 三条约定(都对应踩过的坑):
|
||
// 1) **子进程 cwd 固定为仓库根**。守卫分两类路径风格:多数用 `import.meta.dirname` 自锚定,
|
||
// 但 phase03/04/06/07、tabbar-icons、miniapp-auth 用相对路径 —— 在别处调用会假红。
|
||
// 2) **守卫数量下限硬校验**:防止「守卫被删除/改名」后依然报绿(Phase 19 教训:扫描清单本身会漏)。
|
||
// 新增守卫要上调 MIN_GUARDS;有意删减则下调,二者都必须显式改这行,不能默默变绿。
|
||
// 3) 任一守卫非零退出 → 本脚本非零退出(CI 才会红),并打印该守卫的原始输出便于定位。
|
||
|
||
import { spawnSync } from 'node:child_process'
|
||
import { readdirSync } from 'node:fs'
|
||
import { basename, dirname, resolve } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||
const UI_TESTS_DIR = resolve(root, 'ui-tests')
|
||
|
||
// 2026-09-19 基线:ui-tests/ 下 18 个守卫
|
||
// 14 → 15:新增 wxml-structure-contracts(补「WXML 能否编译」这一层 ——
|
||
// 此前 14 绿时小程序仍有两个页面编译失败);
|
||
// 15 → 17:新增 miniapp-self-method-contracts(`this._x()` 无定义 → 运行期 TypeError,
|
||
// 且该调用位于 resolve 之前时会让所有请求的 Promise 永不 settle)、
|
||
// wxss-component-selector-contracts(组件 wxss 用了标签/ID/属性选择器);
|
||
// 17 → 18:新增 miniapp-api-client-behavior(**唯一一组行为断言**:桩掉 wx.request,
|
||
// 真跑 api.js,验证成功/失败/幂等键释放/收尾容错 4 组行为)。
|
||
// 18 → 19:新增 admin-design-tokens(管理端设计 token 守卫:未定义 token / 硬编码色值 /
|
||
// 字号下限 / 非法字重 / EP 调色板残留 / **对比度实算** —— 最后一项是此前
|
||
// 完全无人把关的一类,且人工目视很难覆盖 24 个页面)。
|
||
// 19 → 20:新增 admin-reactivity-contracts(管理端响应式契约:**非响应式状态**——
|
||
// 普通 `const x = [...]` 被直接改元素且绑到 v-for 时,改动了但渲染不更新,
|
||
// 界面显示陈旧/默认值。2026-09-23 实测数据统计页漏斗卡恒为 0 即此因。
|
||
// 该缺陷无 TS/lint/构建报错,前 19 个守卫全绿也抓不到,只有真跑起来看渲染
|
||
// 结果才暴露 → 必须由源码结构断言接管)。
|
||
// 20 → 21:新增 miniapp-capsule-safe-contracts(胶囊按钮安全区几何,防 Tab 页与右上角
|
||
// 胶囊按钮重合——这类布局缺陷目视才发现,结构化断言钉住安全区变量)。
|
||
// 21 → 22:新增 miniapp-language-contracts(界面语种守卫:复用 scripts/audit-miniapp-language.mjs,
|
||
// 断言「全量扫描 0 命中」,兜住英文/非中文简体枚举码 any/accept/reject、never_married、
|
||
// bachelor、U51… 直接上屏这类只有真机才暴露的缺陷)。
|
||
// 22 → 23:新增 miniapp-profile-contracts(小程序「资料/择偶条件」提交契约:桩掉 stores,
|
||
// 真跑 profile-basic / profile-lifestyle / profile-preference 的事件处理与 onNext(),
|
||
// 断言提交负载用的是**后端规范字段名 + 枚举码**。此前 hasChildren / occupationCategory /
|
||
// workRegion / ageMin / educationAccepted 等字段名对不上,后端读不到 → 字段被**静默丢弃**;
|
||
// 且 high_school / phd / occasional 违反 DB CHECK 直接 500。均为 .js 层,
|
||
// tsc 与静态扫描都发现不了)。
|
||
// 新增/删减守卫时同步修改本默认值。
|
||
// 可用环境变量 UI_GUARDS_MIN 覆盖 —— 唯一目的是让「数量下限」这条分支本身可被自检
|
||
// (`UI_GUARDS_MIN=99 node scripts/run-ui-contracts.mjs` 必须拒绝报绿)。
|
||
const MIN_GUARDS = Number(process.env.UI_GUARDS_MIN ?? 23)
|
||
|
||
const cliArgs = process.argv.slice(2)
|
||
const explicit = cliArgs.length > 0
|
||
|
||
const guards = explicit
|
||
? cliArgs.map((p) => resolve(root, p))
|
||
: readdirSync(UI_TESTS_DIR)
|
||
.filter((f) => f.endsWith('.mjs'))
|
||
.sort()
|
||
.map((f) => resolve(UI_TESTS_DIR, f))
|
||
|
||
if (guards.length === 0) {
|
||
console.error('ERROR: 未找到任何守卫(ui-tests/*.mjs)')
|
||
process.exit(1)
|
||
}
|
||
|
||
if (!explicit && guards.length < MIN_GUARDS) {
|
||
console.error(
|
||
`ERROR: 只发现 ${guards.length} 个守卫,少于下限 ${MIN_GUARDS} —— 守卫可能被删除或改名,拒绝报绿。`,
|
||
)
|
||
console.error(' 新增守卫请上调 MIN_GUARDS;确为有意删减请下调(并说明原因)。')
|
||
process.exit(1)
|
||
}
|
||
|
||
console.log(`UI 契约门禁(源码结构与关键行为断言)— ${guards.length} 个守卫\n`)
|
||
|
||
const pad = String(guards.length).length
|
||
const failures = []
|
||
const t0 = Date.now()
|
||
|
||
guards.forEach((file, i) => {
|
||
const name = basename(file)
|
||
const started = Date.now()
|
||
const r = spawnSync(process.execPath, [file], {
|
||
cwd: root,
|
||
encoding: 'utf8',
|
||
env: { ...process.env, NO_COLOR: '1' },
|
||
})
|
||
const ms = Date.now() - started
|
||
const tag = `[${String(i + 1).padStart(pad)}/${guards.length}]`
|
||
if (r.status === 0) {
|
||
console.log(`${tag} PASS ${name} (${ms}ms)`)
|
||
} else {
|
||
console.log(`${tag} FAIL ${name} (${ms}ms)`)
|
||
failures.push({ name, status: r.status, stdout: r.stdout, stderr: r.stderr })
|
||
}
|
||
})
|
||
|
||
if (failures.length > 0) {
|
||
console.log(`\n${'='.repeat(64)}\n失败详情(守卫原始输出)\n${'='.repeat(64)}`)
|
||
for (const f of failures) {
|
||
console.log(`\n--- ${f.name}(exit ${f.status ?? 'null'})---`)
|
||
const out = [f.stdout, f.stderr].filter(Boolean).join('\n').trim()
|
||
console.log(out || '(无输出 —— 守卫自身异常退出,需人工排查)')
|
||
}
|
||
}
|
||
|
||
const secs = ((Date.now() - t0) / 1000).toFixed(1)
|
||
console.log('')
|
||
if (failures.length === 0) {
|
||
console.log(`✅ PASS ${guards.length} / ${guards.length},0 失败,用时 ${secs}s`)
|
||
process.exit(0)
|
||
}
|
||
console.log(`❌ PASS ${guards.length - failures.length} / ${guards.length},${failures.length} 失败,用时 ${secs}s`)
|
||
process.exit(1)
|