|
|
@@ -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 () {})
|
|
|
+})
|