Просмотр исходного кода

test(cfc-frontend): family.signature.routeIntegrate 契约级单测 S-cfc-54

Sisyphus Agent 2 дней назад
Родитель
Сommit
538c11ae99
1 измененных файлов с 320 добавлено и 0 удалено
  1. 320 0
      cfc-frontend/__tests__/pages/signature/signature.routeIntegrate.test.js

+ 320 - 0
cfc-frontend/__tests__/pages/signature/signature.routeIntegrate.test.js

@@ -0,0 +1,320 @@
+/**
+ * 契约级单测(TDD)——能力键 family.signature.routeIntegrate
+ *
+ * 溯源:
+ *   requirement: docs/superpowers/specs/2026-11-07-signature-page-landscape.md
+ *                §3 AC-R5(无回归:pages.json 路由注册正确、无遗留内嵌 hand-signature)
+ *                §5 WI-5(路由注册与联调验证)
+ *   design:      cfc-frontend/pages.json 路由注册(root=pages/signature + path=signature)
+ *                + pages/signature/signature.vue(回退/恢复逻辑 onUnload/doBack)
+ *
+ * 覆盖(对应 WI-5 验收条件):
+ *   AC5.1 pages.json 正确注册 pages/signature/signature 路由(subPackages root=pages/signature
+ *          + path=signature),全量路由唯一无重复,navigationStyle: custom 真全屏
+ *   AC5.2 页面文件 pages/signature/signature.vue 存在,其编译依赖(hand-signature 组件、
+ *         utils/signature-bus.js)存在,模板引用组件契约不缺失
+ *   AC5.3 两处入口(health-plan-summary / consignee-edit)均 navigateTo 新签名页;
+ *         全局业务页无内嵌 <hand-signature>(仅签名页内复用)
+ *   AC5.4 回退契约:onUnload 注销尺寸监听并恢复竖屏;doBack 在 navigateBack 前恢复竖屏、
+ *         _backed 防重复返回;onBack / onConfirm 均收敛到 doBack(回传后退出)
+ *
+ * 方法:只读 pages.json 与 signature.vue 源码并求值 <script> 段(同
+ *       __tests__/pages/signature/signature.canvas2d.test.js 约定),
+ *       不挂载组件、不修改任何业务代码。实现疑似已存在 → 预期意外全绿。
+ */
+
+const fs = require('fs')
+const path = require('path')
+
+const FRONT_ROOT = path.join(__dirname, '../../..')
+const PAGES_JSON = path.join(FRONT_ROOT, 'pages.json')
+const PAGES_DIR = path.join(FRONT_ROOT, 'pages')
+const PAGE = path.join(FRONT_ROOT, 'pages/signature/signature.vue')
+const PAD = path.join(FRONT_ROOT, 'pages/signature/components/hand-signature/hand-signature.vue')
+const SIG_BUS = path.join(FRONT_ROOT, 'utils/signature-bus.js')
+const ENTRY_HEALTH = path.join(FRONT_ROOT, 'pages/health/health-plan-summary.vue')
+const ENTRY_CONSIGNEE = path.join(FRONT_ROOT, 'pages/shop/consignee-edit/consignee-edit.vue')
+
+// ---------------------------------------------------------------------------
+// 工具
+// ---------------------------------------------------------------------------
+
+function readRaw(file) {
+  return fs.readFileSync(file, 'utf8')
+}
+
+function readScript(file) {
+  const src = readRaw(file)
+  const m = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!m) throw new Error('无法在 ' + file + ' 中定位 <script> 块(路由契约依赖页面脚本存在)')
+  return m[1]
+}
+
+function readTemplate(file) {
+  const src = readRaw(file)
+  const m = src.match(/<template>([\s\S]*?)<\/template>/)
+  return m ? m[1] : ''
+}
+
+/** 剥离 HTML 注释与 JS 块注释,用于「标签是否真实出现在代码中」的判断 */
+function stripComments(src) {
+  return src
+    .replace(/<!--[\s\S]*?-->/g, '')
+    .replace(/\/\*[\s\S]*?\*\//g, '')
+}
+
+function collectVueFiles(dir, out) {
+  out = out || []
+  fs.readdirSync(dir, { withFileTypes: true }).forEach(function (ent) {
+    if (ent.name === 'node_modules') return
+    const full = path.join(dir, ent.name)
+    if (ent.isDirectory()) collectVueFiles(full, out)
+    else if (ent.name.endsWith('.vue')) out.push(full)
+  })
+  return out
+}
+
+/** 解析 pages.json → { json, routes },routes 为全部页面的完整路由(分包展开为 root/path) */
+function loadRoutes() {
+  const json = JSON.parse(readRaw(PAGES_JSON))
+  const routes = []
+  ;(json.pages || []).forEach(function (p) { routes.push(p.path) })
+  ;(json.subPackages || []).forEach(function (sub) {
+    ;(sub.pages || []).forEach(function (p) { routes.push(sub.root + '/' + p.path) })
+  })
+  return { json: json, routes: routes }
+}
+
+/** 把 import 语句替换为同名空桩(保留脚本内引用,避免 ReferenceError) */
+function stubImports(code) {
+  return code.replace(/import\s+([\s\S]*?)\s+from\s+['"][^'"]+['"];?/g, function (_all, clause) {
+    const ids = String(clause)
+      .replace(/[{}]/g, ' ')
+      .split(',')
+      .map(function (x) {
+        const parts = x.trim().split(/\s+as\s+/)
+        return parts[parts.length - 1].trim()
+      })
+      .filter(function (x) {
+        return /^[A-Za-z_$][\w$]*$/.test(x)
+      })
+    return ids
+      .map(function (id) {
+        return 'var ' + id + ' = function () {};'
+      })
+      .join(' ')
+  })
+}
+
+/** 求值 SFC <script> 的 export default 选项对象(uni/wx/window 经 sandbox 注入) */
+function loadOptions(file, sandbox) {
+  let code = stubImports(readScript(file)).replace(/export\s+default/, 'var __opts__ =')
+  code = code + '\nreturn __opts__'
+  const names = Object.keys(sandbox)
+  // eslint-disable-next-line no-new-func
+  const fn = new Function(names.join(','), code)
+  return fn.apply(
+    null,
+    names.map(function (n) {
+      return sandbox[n]
+    })
+  )
+}
+
+/** 构造行为级 vm:data() + methods 合并(模拟 Vue 实例),$refs 置空 */
+function makeVm(sandbox) {
+  const opts = loadOptions(PAGE, sandbox)
+  const data = typeof opts.data === 'function' ? opts.data() : opts.data
+  const vm = Object.assign({}, data, opts.methods)
+  vm.$refs = {}
+  return { vm: vm, opts: opts }
+}
+
+// ---------------------------------------------------------------------------
+// AC5.1 路由注册:pages.json 已注册 pages/signature/signature,且唯一、样式正确
+// ---------------------------------------------------------------------------
+
+describe('AC5.1 pages.json 路由注册(root=pages/signature + path=signature)', function () {
+  let routes
+  let sub
+  let page
+
+  beforeAll(function () {
+    const loaded = loadRoutes()
+    routes = loaded.routes
+    sub = loaded.json.subPackages.filter(function (s) { return s.root === 'pages/signature' })
+    page = sub.length
+      ? sub[0].pages.filter(function (p) { return p.path === 'signature' })
+      : []
+  })
+
+  it('subPackages 注册 root=pages/signature 分包,且仅注册一次', function () {
+    expect(sub.length).toBe(1)
+  })
+
+  it('分包 pages 内注册 path=signature(组合路由 pages/signature/signature)', function () {
+    expect(page.length).toBe(1)
+  })
+
+  it('全量路由(主包 pages + subPackages)中 pages/signature/signature 唯一无重复', function () {
+    const hits = routes.filter(function (r) { return r === 'pages/signature/signature' })
+    expect(hits.length).toBe(1)
+  })
+
+  it('签名页 style 为 navigationStyle: custom(真全屏)+ 标题「签名」', function () {
+    expect(page[0].style).toMatchObject({ navigationStyle: 'custom', navigationBarTitleText: '签名' })
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC5.2 页面文件与编译依赖存在:signature.vue 及其引用的组件/工具不缺失
+// ---------------------------------------------------------------------------
+
+describe('AC5.2 页面文件与编译依赖(编译不再缺失文件即证明路由可编译)', function () {
+  it('页面文件 pages/signature/signature.vue 存在', function () {
+    expect(fs.existsSync(PAGE)).toBe(true)
+  })
+
+  it('signature.vue 含 <template>/<script>/<style> 三段(uni 编译输入完整)', function () {
+    const raw = readRaw(PAGE)
+    expect(raw).toMatch(/<template>/)
+    expect(raw).toMatch(/<script>/)
+    expect(raw).toMatch(/<style/)
+    const exportCnt = (raw.match(/export\s+default/g) || []).length
+    expect(exportCnt).toBe(1)
+  })
+
+  it('模板内嵌复用 hand-signature 组件(components 注册 + 模板标签均存在)', function () {
+    expect(readTemplate(PAGE)).toMatch(/<hand-signature/)
+    expect(readScript(PAGE)).toMatch(/components\s*:\s*\{\s*HandSignature/)
+  })
+
+  it('编译依赖存在:hand-signature.vue 组件与 utils/signature-bus.js', function () {
+    expect(fs.existsSync(PAD)).toBe(true)
+    expect(fs.existsSync(SIG_BUS)).toBe(true)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC5.3 无回归:两处入口 navigateTo 新签名页;全局无内嵌 <hand-signature>(仅签名页)
+// ---------------------------------------------------------------------------
+
+describe('AC5.3 入口接入新页面(无回归)', function () {
+  it('问卷签名入口 health-plan-summary.vue navigateTo /pages/signature/signature', function () {
+    expect(fs.existsSync(ENTRY_HEALTH)).toBe(true)
+    const src = stripComments(readRaw(ENTRY_HEALTH))
+    expect(src).toMatch(/navigateTo\s*\(\s*\{[\s\S]*?url\s*:\s*['"]\/pages\/signature\/signature/)
+    expect(src).not.toMatch(/<hand-signature/)
+  })
+
+  it('水机签名入口 consignee-edit.vue navigateTo /pages/signature/signature', function () {
+    expect(fs.existsSync(ENTRY_CONSIGNEE)).toBe(true)
+    const src = stripComments(readRaw(ENTRY_CONSIGNEE))
+    expect(src).toMatch(/navigateTo\s*\(\s*\{[\s\S]*?url\s*:\s*['"]\/pages\/signature\/signature/)
+    expect(src).not.toMatch(/<hand-signature/)
+  })
+
+  it('全局业务页无内嵌 <hand-signature>:仅签名页 signature.vue 真实使用', function () {
+    const offenders = collectVueFiles(PAGES_DIR).filter(function (file) {
+      const clean = stripComments(readRaw(file))
+      return clean.indexOf('<hand-signature') >= 0
+    })
+    const rel = offenders.map(function (f) { return path.relative(FRONT_ROOT, f).replace(/\\/g, '/') })
+    expect(rel.sort()).toEqual(['pages/signature/signature.vue'])
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC5.4 回退契约:onUnload / doBack / onBack / onConfirm 的竖屏恢复与导航还原
+// ---------------------------------------------------------------------------
+
+describe('AC5.4 回退逻辑契约(onUnload/doBack 恢复)', function () {
+  const pageScript = readScript(PAGE)
+
+  it('源码接线:onUnload 注销尺寸监听并恢复竖屏', function () {
+    expect(pageScript).toMatch(/onUnload\s*:\s*function[\s\S]*?unbindResizeListener\(\)/)
+    expect(pageScript).toMatch(/onUnload\s*:\s*function[\s\S]*?restorePortrait\(\)/)
+  })
+
+  it('源码接线:doBack 在 navigateBack 前恢复竖屏,_backed 防重复返回', function () {
+    expect(pageScript).toMatch(/doBack\s*:\s*function[\s\S]*?restorePortrait\(\)[\s\S]*?navigateBack\(\)/)
+    expect(pageScript).toMatch(/_backed/)
+  })
+
+  it('行为:doBack 先 setPageOrientation(portrait) 再 navigateBack,二次调用不再重复返回', function () {
+    const seq = []
+    const { vm } = makeVm({
+      uni: {
+        getWindowInfo: function () { return { windowHeight: 390 } },
+        navigateBack: function () { seq.push('navigateBack') }
+      },
+      wx: { setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) } },
+      window: undefined
+    })
+    vm.doBack()
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+    vm.doBack()
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+  })
+
+  it('行为:onBack(返回按钮)收敛到 doBack,不回传直接退出', function () {
+    const seq = []
+    const { vm } = makeVm({
+      uni: {
+        getWindowInfo: function () { return { windowHeight: 390 } },
+        navigateBack: function () { seq.push('navigateBack') }
+      },
+      wx: { setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) } },
+      window: undefined
+    })
+    vm.onBack()
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+  })
+
+  it('行为:onUnload 兜底恢复(注销监听 + 恢复竖屏),异常退出也不污染原页面', function () {
+    const seq = []
+    const sandbox = {
+      uni: { getWindowInfo: function () { return { windowHeight: 390 } } },
+      wx: {
+        onWindowResize: function () {},
+        offWindowResize: function () { seq.push('offResize') },
+        setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) }
+      },
+      window: undefined
+    }
+    const { vm, opts } = makeVm(sandbox)
+    vm.bindResizeListener()
+    opts.onUnload.call(vm)
+    expect(seq).toEqual(['offResize', 'orient:portrait'])
+  })
+
+  it('行为:onConfirm 先 emit signatureConfirm 再收敛到 doBack(确认后回传并退出)', function () {
+    const emitted = []
+    const seq = []
+    const { vm } = makeVm({
+      uni: {
+        getWindowInfo: function () { return { windowHeight: 390 } },
+        navigateBack: function () { seq.push('navigateBack') }
+      },
+      wx: { setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) } },
+      window: undefined
+    })
+    vm.getOpenerEventChannel = function () {
+      return {
+        emit: function (name, payload) { emitted.push([name, payload]) }
+      }
+    }
+    vm.onConfirm('data:image/png;base64,ABC')
+    expect(emitted).toEqual([['signatureConfirm', 'data:image/png;base64,ABC']])
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 权限流:签名页为通用独立页面,不区分角色,无 role 门禁 —— 适用性为「不适用」,
+// 显式记录(同 join-form.required-info.test.js 约定)。
+// ---------------------------------------------------------------------------
+
+describe.skip('权限流:签名页无角色门禁,不适用', function () {
+  it('占位:该能力面向通用签名交互,无 role 分支(详见汇报)', function () {})
+})