Files
lucky_shop/tests/card-platform.test.js
ZF sun b92f474ac8 feat(card): 员工端「我的人脉」页 + 与潜在客户导航互链
- 新增 contacts.vue: 搜索/来源筛选/列表卡(来源徽标)/编辑/转潜在客户/删除
- 新增 contact-edit.vue: 新增编辑复用, 姓名红星必填, 其余(选填)
- leads.vue 顶部互链「我的人脉 ›」(导航合并第一步)
- pages.json 注册两页; PAGE_KEY_MAP 补 PAGE_CARD_CONTACTS
- card-platform.test.js 追加 4 测试(25/25)
2026-10-12 17:35:16 +08:00

396 lines
20 KiB
JavaScript

/**
* 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 `<!-- #ifdef X -->`。
* 关键细节:
* - 条件名含连字符(MP-WEIXIN/APP-PLUS), 捕获到注释终止符为止
* - 一行可内联 "#ifdef X -->text<!-- #endif -->", 开/关都按出现次数计
*/
function parseIfdefBlocks(src) {
const lines = src.split('\n');
const stack = []; // [{ cond, startLine }]
const blocks = []; // 顶层块
// 开标记: 捕获条件串(惰性到注释终止符/行尾); 关标记: 单独识别
const TOKEN_RE = /(?:\/\/\s*|\/\*\s*|<!--\s*)(#ifdef|#ifndef|#endif)\b(?:\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, /<web-view/);
assert.ok(wvOcc.length >= 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), '保存前应校验姓名非空');
});