/** * 名片云 · 商家端(admin)页面测试 * * 背景: * 商家端 7 个页面中, 后台接口先就绪的是 contact/* 与 lead/*(以及 card/lists), * 因此本轮先上三个页面(其余 5 页等后台接口, 见 CARD_API_REFERENCE.md 缺口): * - admin/manage 名片管理(基础版: 搜索/详情/编辑, 详情编辑复用员工端页) * - admin/contacts 人脉管理(列表/搜索/来源筛选/编辑/转线索/删除) * - admin/leads 潜在客户(状态筛选/列表/更新跟进状态) * 本测试固化商家端契约: 注册/收口/鸿蒙安全导航/复用回跳。 * * 跑法: * node --test tests/card-admin.test.js */ 'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const ADMIN_DIR = path.join(ROOT, 'pages_tool/card/admin'); const readAdmin = f => fs.readFileSync(path.join(ADMIN_DIR, f), 'utf8'); const readCard = f => fs.readFileSync(path.join(ROOT, 'pages_tool/card', f), 'utf8'); const pagesSrc = fs.readFileSync(path.join(ROOT, 'pages.json'), 'utf8'); const utilSrc = fs.readFileSync(path.join(ROOT, 'common/js/util.js'), 'utf8'); // pages.json 含 uni-app 合法 // 注释, 剥后解析 const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1'); const pagesJson = JSON.parse(stripped); const ADMIN_PAGES = { 'card/admin/manage': '名片管理', 'card/admin/contacts': '人脉管理', 'card/admin/leads': '潜在客户' }; /** 取 pages_tool 分包已注册路径 */ function toolPaths() { const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool'); assert.ok(sub, 'pages_tool 分包应存在'); return (sub.pages || []).map(p => p.path); } // ============================================================= // 1. 页面注册 // ============================================================= test('注册: 商家端 3 页已注册进 pages.json 的 pages_tool 分包', () => { const paths = toolPaths(); for (const p of Object.keys(ADMIN_PAGES)) { assert.ok(paths.includes(p), `${p} 未注册: ${paths.join(',')}`); } }); test('注册: 商家端页面标题正确且不在 TabBar', () => { const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool'); for (const [p, title] of Object.entries(ADMIN_PAGES)) { const page = (sub.pages || []).find(x => x.path === p); assert.ok(page, `${p} 应存在`); assert.equal(page.style.navigationBarTitleText, title, `${p} 标题应为「${title}」`); // 名片云全部页面都不是 tabBar 根页 assert.ok(page.style.enablePullDownRefresh === true, `${p} 应开启下拉刷新`); } const tabPages = (pagesJson.tabBar && pagesJson.tabBar.list ? pagesJson.tabBar.list : []).map(p => p.pagePath); for (const p of Object.keys(ADMIN_PAGES)) { assert.ok(!tabPages.some(tp => tp.includes(p)), `TabBar 不应含 ${p}`); } }); test('注册: PAGE_KEY_MAP 含商家端 3 页且与 pages.json 一致', () => { const map = { PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage', PAGE_CARD_ADMIN_CONTACTS: '/pages_tool/card/admin/contacts', PAGE_CARD_ADMIN_LEADS: '/pages_tool/card/admin/leads' }; for (const [key, val] of Object.entries(map)) { const m = utilSrc.match(new RegExp(`${key}:\\s*'([^']+)'`)); assert.ok(m, `PAGE_KEY_MAP 应有 ${key}`); assert.equal(m[1], val); assert.ok(toolPaths().includes(val.slice('/pages_tool/'.length)), `${val} 未在 pages.json 注册`); } // 防回归: TAB_BAR_KEYS 不得被误加 admin 路径 const tabBlock = utilSrc.match(/TAB_BAR_KEYS\s*=\s*\{[\s\S]*?\}/); assert.ok(tabBlock, 'TAB_BAR_KEYS 应存在'); assert.ok(!/admin/.test(tabBlock[0]), 'TAB_BAR_KEYS 不应含 admin 路径'); }); // ============================================================= // 2. API 收口(与 card-api 测试同一契约, 覆盖新页面) // ============================================================= test('收口: 商家端 3 页零 /card/api/ 字面量且走 this.$cardApi', () => { for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) { const src = readAdmin(f); assert.ok(!/['"]\/card\/api\//.test(src), `${f} 仍直连 /card/api/ 字面量`); assert.ok(/this\.\$cardApi\./.test(src), `${f} 未使用 this.$cardApi`); } }); test('收口: 商家端页面从 card-api.js 导入共享字典', () => { assert.ok(/import\s*\{\s*CONTACT_SOURCES\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('contacts.vue')), 'contacts.vue 应导入 CONTACT_SOURCES'); assert.ok(/import\s*\{\s*LEAD_STATUS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('leads.vue')), 'leads.vue 应导入 LEAD_STATUS'); }); test('收口: leads.vue 的 setStatus 只接受 0-3(后端约束)', () => { const src = readAdmin('leads.vue'); // ActionSheet 选项来自 LEAD_STATUS(0-3), 不产生越界值 assert.ok(/itemList:\s*LEAD_STATUS\.map/.test(src), '状态选项必须来自 LEAD_STATUS 字典'); assert.ok(!/status\s*=\s*[4-9]/.test(src), '不得出现 0-3 之外的 status 值'); }); // ============================================================= // 3. 鸿蒙安全导航(禁裸 uni.switchTab 静默失败) // ============================================================= test('鸿蒙: 商家端 3 页平级互链全部走 $util.redirectTo', () => { const cross = { 'manage.vue': [['goContacts', '/pages_tool/card/admin/contacts'], ['goLeads', '/pages_tool/card/admin/leads']], 'contacts.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goLeads', '/pages_tool/card/admin/leads']], 'leads.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goContacts', '/pages_tool/card/admin/contacts']] }; for (const [f, pairs] of Object.entries(cross)) { const src = readAdmin(f); for (const [method, target] of pairs) { const m = src.match(new RegExp(`${method}\\(\\)\\s*\\{([\\s\\S]*?)\\n\\t\\t\\}`)); assert.ok(m, `${f} 应定义 ${method}`); assert.ok(/\$util\.redirectTo/.test(m[1]), `${f}#${method} 必须走 $util.redirectTo`); assert.ok(new RegExp(`redirectTo\\('${target.replace(/\//g, '\\/')}'\\)`).test(m[1]), `${f}#${method} 目标应为 ${target}`); } assert.ok(!/uni\.switchTab\s*\(/.test(src), `${f} 不得直接调 uni.switchTab`); } }); test('鸿蒙: manage.vue 跳详情/编辑用 navigateTo + encodeURIComponent(card_id)', () => { const src = readAdmin('manage.vue'); // 名片路由参数一律 card_id(字符串), 双编码无害(纯 ASCII) assert.ok(/\/pages_tool\/card\/detail\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src), 'goDetail 应 encodeURIComponent(card_id)'); assert.ok(/\/pages_tool\/card\/edit\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src), 'goEdit 应 encodeURIComponent(card_id)'); }); // ============================================================= // 4. 编辑页复用回跳(from=admin) // ============================================================= test('复用: contact-edit 支持 from=admin 且缺省行为不变', () => { const src = readCard('contact-edit.vue'); assert.ok(/this\.fromAdmin\s*=\s*options\.from\s*===\s*'admin'/.test(src), 'onLoad 应解析 from=admin'); const m = src.match(/backToList\(\)\s*\{([\s\S]*?)\n\t\t\}/); assert.ok(m, '应能提取 backToList 方法体'); assert.ok(/\$util\.redirectTo/.test(m[1]), 'backToList 必须走 $util.redirectTo'); assert.ok(/this\.fromAdmin\s*\?/.test(m[1]), 'backToList 应按来源分支'); assert.ok(m[1].includes("'/pages_tool/card/admin/contacts'"), '商家端应回 /pages_tool/card/admin/contacts'); assert.ok(m[1].includes("'/pages_tool/card/contacts'"), '员工端缺省仍回 /pages_tool/card/contacts(不破坏既有行为)'); assert.ok(!/uni\.switchTab\s*\(/.test(src), 'contact-edit.vue 不得直接调 uni.switchTab'); }); test('复用: admin/contacts 的编辑入口带 from=admin', () => { const src = readAdmin('contacts.vue'); assert.ok(/contact-edit\?from=admin&id='/.test(src), '编辑入口应带 from=admin&id='); assert.ok(/contact-edit\?from=admin'/.test(src), '添加入口应带 from=admin'); }); // ============================================================= // 5. 条件编译配对平衡(新文件同样纳入) // ============================================================= test('ifdef 配对平衡: 商家端 3 页', () => { for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) { const src = readAdmin(f); const open = (src.match(/#ifdef|#ifndef/g) || []).length; const close = (src.match(/#endif/g) || []).length; assert.equal(open, close, `${f}: #ifdef/#ifndef=${open} 但 #endif=${close}`); } });