/** * card 电子名片模块跨平台适配测试: 微信小程序 / H5 / 鸿蒙元服务 * * 背景: * uni-app 产物覆盖 MP-WEIXIN / H5 / MP-HARMONY(鸿蒙元服务) 三端, * card 模块(名片夹/详情/编辑/线索/分享)任何平台分支遗漏都会在对应端 * 表现为「按钮消失」或「整页白屏」。本测试把适配契约固化为断言。 * * 覆盖: * 1. 条件编译配对平衡(防止 #ifdef 漏 #endif 导致编译失败) * 2. 平台 token 覆盖(detail/share 必须同时照顾三端) * 3. 保存二维码按钮在 微信+鸿蒙 双端可见(saveImageToPhotosAlbum 鸿蒙可用) * 4. share.vue 无白页: web-view 分支覆盖 微信+鸿蒙+App, H5 分支有跳转 * 5. goBack 不用裸 uni.switchTab(鸿蒙 switchTab 静默失败, catch 兜底失效, * 必须走 $util.redirectTo —— 见 common/js/util.js#redirectTo tabBar 分支) * 6. 分享 URL 编解码闭环(三端语义不同: 微信/鸿蒙不 decode 值, H5 预 decode) * 7. 平台敏感 API 只在受保护的分支内调用 * 8. card_id 参数编码安全(纯 ASCII, encodeURIComponent 幂等) * * 跑法: * node --test tests/card-platform.test.js */ 'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const CARD_DIR = path.resolve(__dirname, '../pages_tool/card'); const read = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8'); // ============================================================= // 工具: 条件编译块解析(行注释 // / 块注释 /* */ / HTML 三种写法) // ============================================================= /** * 把文件解析成 [{ cond, start, end }] 顶层块列表。 * 兼容三种写法: 行注释 `// #ifdef X`、块注释 `/* #ifdef X *\/`、HTML ``。 * 关键细节: * - 条件名含连字符(MP-WEIXIN/APP-PLUS), 捕获到注释终止符为止 * - 一行可内联 "#ifdef X -->text", 开/关都按出现次数计 */ function parseIfdefBlocks(src) { const lines = src.split('\n'); const stack = []; // [{ cond, startLine }] const blocks = []; // 顶层块 // 开标记: 捕获条件串(惰性到注释终止符/行尾); 关标记: 单独识别 const TOKEN_RE = /(?:\/\/\s*|\/\*\s*||\*\/|$)/g; lines.forEach((line, i) => { TOKEN_RE.lastIndex = 0; let m; const tokens = []; while ((m = TOKEN_RE.exec(line)) !== null) { if (m[0].trim() === '') break; // 防空匹配死循环 tokens.push({ kind: m[1], cond: m[2] ? m[2].trim() : null }); } for (const tk of tokens) { if (tk.kind === '#ifdef' || tk.kind === '#ifndef') { if (stack.length === 0) blocks.push({ cond: tk.cond, start: i, end: lines.length - 1 }); stack.push({ cond: tk.cond, startLine: i }); } else { const top = stack.pop(); if (top && stack.length === 0) { const b = blocks[blocks.length - 1]; if (b && b.start === top.startLine) b.end = i; } } } }); return blocks; } /** 判断 cond 串(如 "MP-WEIXIN || MP-HARMONY")在指定平台下是否命中 */ function condHits(cond, platform) { return cond .split('||') .map(s => s.trim().replace(/^!/, '')) .includes(platform); } /** 找出源码中某 API 出现的所有位置及其所属顶层 cond(无保护返回 null) */ function apiOccurrences(src, apiRegex) { const lines = src.split('\n'); const blocks = parseIfdefBlocks(src); const out = []; lines.forEach((line, i) => { if (!apiRegex.test(line)) return; // 找包含该行的顶层块 const b = blocks.find(b => i >= b.start && i <= b.end); out.push({ line: i + 1, text: line.trim(), cond: b ? b.cond : null }); }); return out; } // ============================================================= // 1. 条件编译配对平衡 // ============================================================= for (const f of ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'share.vue']) { test(`ifdef 配对平衡: ${f}`, () => { const src = read(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}(漏配对编译必失败)`); }); } // ============================================================= // 2. 平台 token 覆盖 // ============================================================= test('平台覆盖: detail.vue 同时含 MP-WEIXIN / H5 / MP-HARMONY 分支', () => { const src = read('detail.vue'); for (const p of ['MP-WEIXIN', 'H5', 'MP-HARMONY']) { assert.ok(src.includes(p), `detail.vue 缺少 ${p} 分支(该端分享面板会缺按钮)`); } }); test('平台覆盖: share.vue 的 web-view 分支含 MP-HARMONY(防鸿蒙白屏)', () => { const src = read('share.vue'); // web-view 必须出现在 MP-WEIXIN || MP-HARMONY 保护块内 const wvOcc = apiOccurrences(src, /= 2, 'share.vue 至少应有 2 处 web-view(小程序+App)'); const mpWv = wvOcc.find(o => o.cond && /MP-WEIXIN/.test(o.cond)); assert.ok(mpWv, 'web-view 必须位于 MP-WEIXIN 分支内'); assert.ok(/MP-HARMONY/.test(mpWv.cond), `web-view 分支必须同时覆盖 MP-HARMONY, 实际 cond="${mpWv.cond}"(鸿蒙会白页)`); }); test('平台覆盖: share.vue H5 分支有 location.href 跳转(防 H5 停留中转页)', () => { const src = read('share.vue'); const occ = apiOccurrences(src, /window\.location\.href/); const h5Occ = occ.find(o => o.cond === 'H5'); assert.ok(h5Occ, 'location.href 必须位于 H5 分支内'); }); // ============================================================= // 3. 保存二维码按钮双端可见 // ============================================================= test('保存二维码: 按钮在 MP-WEIXIN 与 MP-HARMONY 分支均可见', () => { const src = read('detail.vue'); // saveQr 的按钮绑定点(template 内 @tap="saveQr") const btnOcc = apiOccurrences(src, /@tap="saveQr"/); const conds = btnOcc.map(o => o.cond).filter(Boolean); assert.ok(conds.some(c => /MP-WEIXIN/.test(c)), '保存二维码按钮应有 MP-WEIXIN 分支'); assert.ok(conds.some(c => /MP-HARMONY/.test(c)), `保存二维码按钮应有 MP-HARMONY 分支(鸿蒙 uni.saveImageToPhotosAlbum 可用), 实际: ${JSON.stringify(conds)}`); }); test('保存二维码: saveImageToPhotosAlbum/downloadFile 调用不受平台分支保护误删', () => { // 这两个 API 在鸿蒙受支持, 但不能出现在 H5 分支( H5 无此 API ) const src = read('detail.vue'); for (const api of [/uni\.saveImageToPhotosAlbum/, /uni\.downloadFile/]) { for (const occ of apiOccurrences(src, api)) { assert.notEqual(occ.cond, 'H5', `${api} 不应在 H5 分支内(第 ${occ.line} 行)`); } } }); // ============================================================= // 4. 分享中转页不白屏(各端都有可渲染内容) // ============================================================= test('share.vue 三端均有渲染分支: MP-WEIXIN||MP-HARMONY / H5 / APP-PLUS', () => { const src = read('share.vue'); const conds = parseIfdefBlocks(src).map(b => b.cond); assert.ok(conds.some(c => c && /MP-WEIXIN/.test(c) && /MP-HARMONY/.test(c)), `应有合并的 MP-WEIXIN||MP-HARMONY 分支, 实际: ${JSON.stringify(conds)}`); assert.ok(conds.includes('H5'), '应有 H5 分支'); assert.ok(conds.includes('APP-PLUS'), '应有 APP-PLUS 分支'); }); // ============================================================= // 5. goBack 不用裸 switchTab(鸿蒙静默失败回归) // ============================================================= test('goBack: share.vue 不用裸 uni.switchTab(鸿蒙静默失败, catch 不触发)', () => { const src = read('share.vue'); // 提取 goBack 方法体 const m = src.match(/goBack\(\)\s*\{([\s\S]*?)\n\t\t\},/); assert.ok(m, '应能提取 goBack 方法体'); const body = m[1]; // 只断言真实调用(带括号), 注释里提到 uni.switchTab 是说明文字不算 assert.ok(!/uni\.switchTab\s*\(/.test(body), 'goBack 不得直接调 uni.switchTab: 鸿蒙上 success/fail 都不触发, .catch 兜底失效'); assert.ok(/\$util\.redirectTo/.test(body), 'goBack 栈底回首页必须走 $util.redirectTo(内部有 switchTab→500ms→reLaunch 兜底)'); }); // ============================================================= // 6. 分享 URL 编解码闭环(三端语义) // ============================================================= /** * 模拟三端 onShareAppMessage path → 目标页 onLoad options.url 的传递: * - 微信: path 里的 query 按 & = 切分但不 decode value(项目实测结论, * 见 common/js/util-navigate.js#_safeEncodeForPlatform 注释) * - 鸿蒙: 同微信语义(uni-app harmony 沿用小程序路由) * - H5: 浏览器预 decode, onLoad 拿到已解码值 */ function simulateShareOpen(pathStr, platform) { const qi = pathStr.indexOf('?'); const query = qi === -1 ? '' : pathStr.slice(qi + 1); const params = {}; for (const kv of query.split('&')) { if (!kv) continue; const eq = kv.indexOf('='); const k = kv.slice(0, eq); const raw = kv.slice(eq + 1); // 微信/鸿蒙: value 原样(不 decode); H5: 浏览器已 decode params[k] = platform === 'h5' ? safeDecode(raw) : raw; } return params; } function safeDecode(s) { try { return decodeURIComponent(s); } catch (e) { return s; } } test('分享闭环: 微信/鸿蒙 onLoad 拿到编码值, decode 后还原完整 URL', () => { const shareUrl = 'https://shop.example.com/card/wap/index/detail?site_id=1&id=237'; const pathStr = '/pages_tool/card/share?url=' + encodeURIComponent(shareUrl); for (const p of ['mp-weixin', 'mp-harmony']) { const opts = simulateShareOpen(pathStr, p); assert.ok(opts.url, `${p}: options.url 应存在`); // 编码值不应含裸 & (否则 query 被切碎, url 被截断) assert.ok(!opts.url.includes('&'), `${p}: url 参数不应含裸 &`); // share.vue onLoad 的 decodeURIComponent 必须还原出完整 URL assert.equal(safeDecode(opts.url), shareUrl, `${p}: decode 后应还原完整分享 URL`); } }); test('分享闭环: H5 onLoad 拿到已解码值, 再 decode 是幂等的(无双解码)', () => { const shareUrl = 'https://shop.example.com/card/wap/index/detail?site_id=1&id=237'; const pathStr = '/pages_tool/card/share?url=' + encodeURIComponent(shareUrl); const opts = simulateShareOpen(pathStr, 'h5'); assert.equal(safeDecode(opts.url), shareUrl, 'H5: 浏览器已解码, 页面内再 decode 应仍得完整 URL'); }); test('分享闭环: onShareAppMessage 的 path 确实预编码了 url(与 webview.vue 惯例一致)', () => { const src = read('detail.vue'); assert.ok(/path:\s*'\/pages_tool\/card\/share\?url='\s*\+\s*encodeURIComponent/.test(src), 'onShareAppMessage path 必须 encodeURIComponent(url), 否则 & 会切碎 query'); }); // ============================================================= // 7. card_id 参数编码安全 // ============================================================= test('card_id 编码幂等: 纯 ASCII 编号 encodeURIComponent 不变(双编码无害)', () => { // 服务端生成格式 c_YYYYMMDD_hex, 只含字母数字下划线 const cardId = 'c_20261012_f1018de954e6'; assert.equal(encodeURIComponent(cardId), cardId, 'card_id 编码后应不变'); assert.equal(decodeURIComponent(encodeURIComponent(cardId)), cardId, '双解码也应无害'); }); // ============================================================= // 8. member/index.vue 的 tourl 导航走 $util.redirectTo(鸿蒙安全) // ============================================================= test('member/index.vue 的 tourl 走 $util.redirectTo(鸿蒙安全)', () => { const memberSrc = fs.readFileSync( path.resolve(__dirname, '../pages_tool/member/index.vue'), 'utf8'); const m = memberSrc.match(/tourl\(url\)\s*\{([\s\S]*?)\n\t\t\}/); assert.ok(m, 'member/index.vue 应定义 tourl'); assert.ok(/\$util\.redirectTo/.test(m[1]), 'tourl 必须走 $util.redirectTo(鸿蒙 switchTab 静默失败兜底在内)'); }); // ============================================================= // 9. 端到端: 三端分享面板按钮矩阵 // ============================================================= test('端到端: 三端分享面板按钮矩阵(静态推导)', () => { const src = read('detail.vue'); // 复制链接: 无条件(三端都有) assert.ok(/@tap="copyLink"/.test(src), '复制链接应三端可用'); // 保存二维码: MP-WEIXIN + MP-HARMONY // 浏览器分享: H5 // 发给好友: MP-WEIXIN(鸿蒙无转发菜单, 用复制链接代替) const h5Btns = []; const wxBtns = []; const harmonyBtns = []; // 简化推导: 逐行扫描按钮行并记录当前 cond const blocks = parseIfdefBlocks(src); src.split('\n').forEach((line, i) => { const b = blocks.find(b => i >= b.start && i <= b.end); const cond = b ? b.cond : null; const tm = line.match(/@tap="(\w+)"/); if (!tm || !/ss-btn/.test(line)) return; if (!cond) { h5Btns.push(tm[1]); wxBtns.push(tm[1]); harmonyBtns.push(tm[1]); return; } if (condHits(cond, 'MP-WEIXIN')) wxBtns.push(tm[1]); if (condHits(cond, 'MP-HARMONY')) harmonyBtns.push(tm[1]); if (condHits(cond, 'H5')) h5Btns.push(tm[1]); }); assert.ok(wxBtns.includes('copyLink') && wxBtns.includes('saveQr') && wxBtns.includes('shareToFriend'), `微信按钮矩阵: ${JSON.stringify(wxBtns)}`); assert.ok(harmonyBtns.includes('copyLink') && harmonyBtns.includes('saveQr'), `鸿蒙按钮矩阵(无发给好友): ${JSON.stringify(harmonyBtns)}`); assert.ok(!harmonyBtns.includes('shareToFriend'), '鸿蒙不应出现「发给好友」(无转发菜单)'); assert.ok(h5Btns.includes('copyLink') && h5Btns.includes('h5Share'), `H5 按钮矩阵: ${JSON.stringify(h5Btns)}`); assert.ok(!h5Btns.includes('saveQr'), 'H5 不应出现「保存二维码」(无 saveImageToPhotosAlbum)'); }); // ============================================================ // 1.8.0 后台「选择链接」注册: PAGE_KEY_MAP ↔ pages.json 对齐 // // 后端 lucky_link 注册 CARD_LIST/CARD_LEADS(page_key=PAGE_CARD_LIST/ // PAGE_CARD_LEADS), 前端导航 resolveLinkUrl 优先用 page_key 经 // PAGE_KEY_MAP 解析; 两边必须与 pages.json 注册路径一致, 否则链接点了 404。 // ============================================================ test('选择链接: PAGE_KEY_MAP 含 card 两个页面常量', () => { const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8'); const m = utilSrc.match(/PAGE_CARD_LIST:\s*'([^']+)'/); const m2 = utilSrc.match(/PAGE_CARD_LEADS:\s*'([^']+)'/); assert.ok(m, 'PAGE_KEY_MAP 应有 PAGE_CARD_LIST'); assert.ok(m2, 'PAGE_KEY_MAP 应有 PAGE_CARD_LEADS'); assert.equal(m[1], '/pages_tool/card/list'); assert.equal(m2[1], '/pages_tool/card/leads'); }); test('选择链接: card 页面已在 pages.json 注册', () => { const pagesSrc = fs.readFileSync(path.resolve(__dirname, '../pages.json'), 'utf8'); // pages.json 含 uni-app 合法 // 注释, 剥后解析 const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1'); const pages = JSON.parse(stripped); const sub = (pages.subPackages || []).find(sp => sp.root === 'pages_tool'); assert.ok(sub, 'pages_tool 分包应存在'); const paths = (sub.pages || []).map(p => p.path); assert.ok(paths.includes('card/list'), `card/list 未注册: ${paths.join(',')}`); assert.ok(paths.includes('card/leads'), `card/leads 未注册: ${paths.join(',')}`); }); test('选择链接: card 页面不在 TabBar(NO_TAB_PAGES 语义)不影响导航兜底', () => { // card 页不是 tabBar 根页, redirectTo 走 navigateTo 分支; // 此处仅防回归: 确认 util.js 的 TAB_BAR_KEYS 未被误加 card 路径 const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8'); const tabBlock = utilSrc.match(/TAB_BAR_KEYS\s*=\s*\{[\s\S]*?\}/); assert.ok(tabBlock, 'TAB_BAR_KEYS 应存在'); assert.ok(!/card/.test(tabBlock[0]), 'TAB_BAR_KEYS 不应含 card 路径(card 非 tabBar 页)'); }); // ============================================================ // 2.0.0 人脉库: contacts / contact-edit 注册 + 互链 + 鸿蒙安全 // ============================================================ test('人脉库: PAGE_KEY_MAP 含 PAGE_CARD_CONTACTS', () => { const utilSrc = fs.readFileSync(path.resolve(__dirname, '../common/js/util.js'), 'utf8'); const m = utilSrc.match(/PAGE_CARD_CONTACTS:\s*'([^']+)'/); assert.ok(m, 'PAGE_KEY_MAP 应有 PAGE_CARD_CONTACTS'); assert.equal(m[1], '/pages_tool/card/contacts'); }); test('人脉库: contacts/contact-edit 已注册进 pages.json 且不在 TabBar', () => { const pagesSrc = fs.readFileSync(path.resolve(__dirname, '../pages.json'), 'utf8'); const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1'); const pages = JSON.parse(stripped); const sub = (pages.subPackages || []).find(sp => sp.root === 'pages_tool'); assert.ok(sub, 'pages_tool 分包应存在'); const paths = (sub.pages || []).map(p => p.path); assert.ok(paths.includes('card/contacts'), `card/contacts 未注册: ${paths.join(',')}`); assert.ok(paths.includes('card/contact-edit'), `card/contact-edit 未注册: ${paths.join(',')}`); // TabBar 根页不在 pages_tool 分包, 天然不在 TabBar; 防回归: tabBar 列表不得含 card const tabPages = (pages.tabBar && pages.tabBar.list ? pages.tabBar.list : []).map(p => p.pagePath); assert.ok(!tabPages.some(p => /card/.test(p)), 'TabBar 不应含 card 页面'); }); test('人脉库: contacts/leads 顶部互链都走 $util.redirectTo(鸿蒙静默失败回归)', () => { const contactsSrc = read('contacts.vue'); const leadsSrc = read('leads.vue'); // contacts → leads assert.ok(/goLeads\(\)\s*\{[\s\S]*?\$util\.redirectTo\('\/pages_tool\/card\/leads'\)/.test(contactsSrc), 'contacts.vue 顶部「潜在客户」链接必须走 $util.redirectTo'); // leads → contacts assert.ok(/goContacts\(\)\s*\{[\s\S]*?\$util\.redirectTo\('\/pages_tool\/card\/contacts'\)/.test(leadsSrc), 'leads.vue 顶部「我的人脉」链接必须走 $util.redirectTo'); // 两文件不得出现裸 uni.switchTab 调用 for (const [name, src] of [['contacts.vue', contactsSrc], ['leads.vue', leadsSrc]]) { assert.ok(!/uni\.switchTab\s*\(/.test(src), `${name} 不得直接调 uni.switchTab`); } }); test('人脉库: contact-edit 保存成功回 contacts 走 $util.redirectTo(鸿蒙安全)', () => { const src = read('contact-edit.vue'); assert.ok(/backToList\(\)\s*\{[\s\S]*?\$util\.redirectTo\('\/pages_tool\/card\/contacts'\)/.test(src), '保存成功回列表必须走 $util.redirectTo'); assert.ok(!/uni\.switchTab\s*\(/.test(src), 'contact-edit.vue 不得直接调 uni.switchTab'); // 姓名必填: 模板有 req-star + 保存前有空名校验 assert.ok(/req-star/.test(src), '姓名必填应有 req-star 红星'); assert.ok(/请填写姓名/.test(src), '保存前应校验姓名非空'); });