/** * 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, 且必须是原生 button(open-type=share) —— 普通 view 的 @tap // 调不起转发面板(微信未开放该 API), 只能弹「请点击右上角···」的死胡同提示 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; if (!/ss-btn/.test(line)) return; // 原生转发按钮没有 @tap, 统一记成 nativeShare 便于断言 const tm = line.match(/@tap="(\w+)"/); const token = tm ? tm[1] : (/open-type="share"/.test(line) ? 'nativeShare' : null); if (!token) return; if (!cond) { h5Btns.push(token); wxBtns.push(token); harmonyBtns.push(token); return; } if (condHits(cond, 'MP-WEIXIN')) wxBtns.push(token); if (condHits(cond, 'MP-HARMONY')) harmonyBtns.push(token); if (condHits(cond, 'H5')) h5Btns.push(token); }); assert.ok(wxBtns.includes('copyLink') && wxBtns.includes('saveQr') && wxBtns.includes('nativeShare'), `微信按钮矩阵(发给好友须为 nativeShare): ${JSON.stringify(wxBtns)}`); assert.ok(!wxBtns.includes('shareToFriend'), '「发给好友」不能退回 @tap 弹提示的死胡同, 必须原生 button 直接拉起转发面板'); assert.ok(harmonyBtns.includes('copyLink') && harmonyBtns.includes('saveQr'), `鸿蒙按钮矩阵: ${JSON.stringify(harmonyBtns)}`); assert.ok(!harmonyBtns.includes('nativeShare'), '鸿蒙元服务无转发菜单, 不应出现原生转发按钮'); assert.ok(h5Btns.includes('copyLink') && h5Btns.includes('h5Share'), `H5 按钮矩阵: ${JSON.stringify(h5Btns)}`); assert.ok(!h5Btns.includes('saveQr'), 'H5 不应出现「保存二维码」(无 saveImageToPhotosAlbum)'); }); // ============================================================= // 9.1 「发名片」主按钮: 微信端一键转发给好友 // ============================================================= test('发名片: 微信端原生 button 一键转发 + 分享链接预先就绪', () => { const src = read('detail.vue'); const btnLine = src.split('\n').find(l => /send-btn/.test(l) && /open-type="share"/.test(l)); assert.ok(btnLine, 'detail.vue 应有「发名片」按钮(open-type="share")'); assert.ok(/