Merge branch 'feat/camcard' into dev/1.0

This commit is contained in:
2026-10-29 08:31:52 +08:00
14 changed files with 1548 additions and 99 deletions

View File

@@ -0,0 +1,430 @@
/**
* 名片分享带卡 ID · 前端契约测试(2026-10-28)
*
* 背景:
* 小程序端「我的名片」组件(diy-card-self)的「发送名片」按钮是 <button open-type="share">,
* 微信只认**页面级** onShareAppMessage。承载该组件的 DIY 页自带 onShareAppMessage
* (common/js/diy.js) → Vue2 defaultStrat(子覆盖父)会顶掉全局兜底 mixin
* (common/js/mp-share-default.js)里那段 dataset.cardId 分支
* → 分享 path 不带 card_id, 访客打开看到的是企业兜底卡而不是分享者那张卡。
*
* 修法(前端侧):
* ① common/js/card-share.js(新增): 三条取 card_id 的通道
* dataset(发送给朋友) / storage(朋友圈) / 路由 query(落地回传)
* ② common/js/diy.js: 页面级分享钩子把 card_id 拼进分享 path
* ③ components-diy/diy-card-mini.vue: 分享按钮 @tap 记住当前卡片(storage 通道)
* ④ components-diy/diy-card-self.vue: 从路由 query 取 card_id 回传 /card/api/diy/mycard
*
* 跑法: node --test tests/card-share-card-id.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 read = f => fs.readFileSync(path.join(ROOT, f), 'utf8');
const strip = s => s
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
/**
* card-share.js 是纯 ESM(无 import) —— 去掉 export 关键字后用 new Function 求值,
* 直接跑真实逻辑(不复制源码, 避免测试与实现漂移)。
* uni / getCurrentPages 以参数注入, 天然完成 mock。
*/
function loadCardShare(uni, getCurrentPages) {
const src = read('common/js/card-share.js').replace(/export\s+(const|function)\s/g, '$1 ');
const factory = new Function(
'uni', 'getCurrentPages',
src + `
return {
SHARE_CARD_STORAGE_KEY, SHARE_CARD_MEMORY_TTL,
rememberShareCardId, takeRememberedShareCardId, pickShareCardId,
routeQueryValue, appendPathParam, appendQueryParam, pathQueryPart
};`
);
return factory(uni, getCurrentPages);
}
function makeUni() {
const store = new Map();
return {
store,
setStorageSync(k, v) { store.set(k, v); },
getStorageSync(k) { return store.has(k) ? store.get(k) : ''; },
removeStorageSync(k) { store.delete(k); },
};
}
// =============================================================
// A. card-share.js 真实行为
// =============================================================
test('A1: 记住 → 取出 往返一致', () => {
const uni = makeUni();
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
assert.equal(rememberShareCardId('C_1_shop_20261013111612_9588EA'), 'C_1_shop_20261013111612_9588EA');
assert.equal(takeRememberedShareCardId(), 'C_1_shop_20261013111612_9588EA');
});
test('A2: 取出后即清除(下一次分享不串卡)', () => {
const uni = makeUni();
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
rememberShareCardId('C_A');
assert.equal(takeRememberedShareCardId(), 'C_A');
assert.equal(takeRememberedShareCardId(), '', '第二次取应为空');
});
test('A3: 空 card_id 不入记忆槽', () => {
const uni = makeUni();
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
assert.equal(rememberShareCardId(''), '');
assert.equal(rememberShareCardId(null), '');
assert.equal(takeRememberedShareCardId(), '');
});
test('A4: 记忆过期则不再用于分享(TTL)', () => {
const uni = makeUni();
const { rememberShareCardId, takeRememberedShareCardId } = loadCardShare(uni, () => []);
rememberShareCardId('C_OLD');
// 把时间戳改老(10 分钟前)
uni.store.set('_cardShareCardId', { card_id: 'C_OLD', at: Date.now() - 10 * 60 * 1000 });
assert.equal(takeRememberedShareCardId(), '', '过期记忆应被丢弃');
});
test('A5: pickShareCardId 优先取 dataset(发送给朋友)', () => {
const uni = makeUni();
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => []);
rememberShareCardId('C_FROM_STORAGE');
const opts = { from: 'button', target: { dataset: { cardId: 'C_FROM_DATASET' } } };
assert.equal(pickShareCardId(opts), 'C_FROM_DATASET');
});
test('A6: pickShareCardId 无 dataset 时回落 storage(朋友圈通道)', () => {
const uni = makeUni();
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => []);
rememberShareCardId('C_FROM_STORAGE');
assert.equal(pickShareCardId(null), 'C_FROM_STORAGE');
// storage 通道是"读一次即清"语义 → 第二次取前需重新记忆
rememberShareCardId('C_FROM_STORAGE');
assert.equal(pickShareCardId({ from: 'menu' }), 'C_FROM_STORAGE');
});
test('A6b: 第三级兜底 — storage 空且落地 query 带 card_id → 续传(右上角转发 S5-2)', () => {
// 场景: 访客经分享落地(query 带 card_id), 未点过任何分享按钮(storage 空),
// 直接右上角「···」转发 → 应自动续传落地页的源头卡
const uni = makeUni();
const { pickShareCardId } = loadCardShare(uni, () => ([{
route: 'pages_tool/index/diy',
options: { id: '3', card_id: 'C_LANDING' },
}]));
assert.equal(pickShareCardId({ from: 'menu' }), 'C_LANDING');
assert.equal(pickShareCardId(null), 'C_LANDING');
});
test('A6c: 三级优先级 dataset > storage > 落地 query', () => {
const uni = makeUni();
const { rememberShareCardId, pickShareCardId } = loadCardShare(uni, () => ([{
route: 'pages_tool/index/diy',
options: { card_id: 'C_LANDING' },
}]));
// query 在, 但 dataset 最准 → dataset 胜
assert.equal(pickShareCardId({ target: { dataset: { cardId: 'C_DATASET' } } }), 'C_DATASET');
// storage 在(刚点过按钮), dataset 无 → storage 胜(按钮意图更近)
rememberShareCardId('C_FROM_BTN');
assert.equal(pickShareCardId(null), 'C_FROM_BTN');
// 都没有 → 落地 query
assert.equal(pickShareCardId({ from: 'menu' }), 'C_LANDING');
});
test('A7: pickShareCardId 三路都没有 → 空', () => {
const uni = makeUni();
const { pickShareCardId } = loadCardShare(uni, () => []);
assert.equal(pickShareCardId({ from: 'menu' }), '');
assert.equal(pickShareCardId(null), '');
});
test('A8: routeQueryValue 从当前页 options 取值(分享落地页)', () => {
const { routeQueryValue } = loadCardShare(makeUni(), () => ([{
route: 'pages_tool/index/diy',
options: { id: '123', name: 'home', card_id: 'C_1_shop_x' },
}]));
assert.equal(routeQueryValue('card_id'), 'C_1_shop_x');
assert.equal(routeQueryValue('id'), '123');
assert.equal(routeQueryValue('not_exist'), '');
});
test('A9: routeQueryValue 兼容字符串形态 query', () => {
const { routeQueryValue } = loadCardShare(makeUni(), () => ([{
route: 'pages_tool/index/diy',
query: 'id=123&card_id=C_2',
}]));
assert.equal(routeQueryValue('card_id'), 'C_2');
});
test('A10: routeQueryValue 无页面 / 无 uni 时安全返回空', () => {
const { routeQueryValue } = loadCardShare(makeUni(), () => []);
assert.equal(routeQueryValue('card_id'), '');
});
test('A11: appendPathParam 拼 path(带/不带 query 两种形态)', () => {
const { appendPathParam } = loadCardShare(makeUni(), () => []);
assert.equal(
appendPathParam('/pages_tool/index/diy?id=123', 'card_id', 'C_1'),
'/pages_tool/index/diy?id=123&card_id=C_1'
);
assert.equal(
appendPathParam('/pages_tool/index/diy', 'card_id', 'C_1'),
'/pages_tool/index/diy?card_id=C_1'
);
assert.equal(appendPathParam('/pages_tool/index/diy?id=1', 'card_id', ''), '/pages_tool/index/diy?id=1');
});
test('A12: appendQueryParam 拼 query(朋友圈只认 query)', () => {
const { appendQueryParam } = loadCardShare(makeUni(), () => []);
assert.equal(appendQueryParam('id=123', 'card_id', 'C_1'), 'id=123&card_id=C_1');
assert.equal(appendQueryParam('', 'card_id', 'C_1'), 'card_id=C_1');
assert.equal(appendQueryParam('?id=123', 'card_id', 'C_1'), 'id=123&card_id=C_1');
});
test('A13: pathQueryPart 取 path 的 query 段', () => {
const { pathQueryPart } = loadCardShare(makeUni(), () => []);
assert.equal(pathQueryPart('/pages_tool/index/diy?id=1&name=x'), 'id=1&name=x');
assert.equal(pathQueryPart('/pages_tool/index/diy'), '');
});
// =============================================================
// B. DIY 页分享钩子(common/js/diy.js)
// =============================================================
/**
* 把 diy.js 的两个分享钩子抠出来**真跑**(diy.js 本体 import 了地图/配置等模块,
* node 里无法直接 import; 这里只抠钩子源码, 用 card-share.js 的真实实现注入)。
*/
function loadDiyShareHooks() {
const src = read('common/js/diy.js');
const start = src.indexOf('onShareAppMessage(options) {');
// 两个钩子是文件最后两个方法 → 一直取到导出对象的最后一个 '}' 之前
const end = src.lastIndexOf('}');
assert.ok(start > 0 && end > start, '未能定位 diy.js 的分享钩子');
const hooksSrc = src.slice(start, end);
const share = loadCardShare(makeUni(), () => []);
const factory = new Function(
'pickShareCardId', 'appendPathParam', 'appendQueryParam', 'pathQueryPart',
'return {' + hooksSrc + '};'
);
const hooks = factory(share.pickShareCardId, share.appendPathParam, share.appendQueryParam, share.pathQueryPart);
return { hooks, share };
}
/** 模拟一个 DIY 页实例的上下文 */
function makeDiyCtx() {
return {
mpShareData: {
appMessage: { title: '店铺名', path: '/pages_tool/index/diy?id=3', imageUrl: 'cover.png' },
timeLine: { title: '店铺名', query: '/pages_tool/index/diy?id=3', imageUrl: 'cover.png' },
},
$util: {
getCurrentRoute: () => ({ path: '/pages_tool/index/diy?id=3&name=home', query: 'id=3&name=home' }),
},
};
}
test('B1: diy.js 引入名片分享工具', () => {
const src = read('common/js/diy.js');
assert.match(src, /import\s*\{[^}]*pickShareCardId[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/,
'未引入 card-share.js 的 pickShareCardId');
});
test('B2: onShareAppMessage 接 options 并按 dataset/storage 取 card_id', () => {
const src = strip(read('common/js/diy.js'));
assert.match(src, /onShareAppMessage\(options\)\s*\{/,
'onShareAppMessage 未接事件参数(拿不到 dataset)');
assert.match(src, /pickShareCardId\(options\)/,
'onShareAppMessage 未从事件里取 card_id');
});
test('B3: onShareAppMessage 把 card_id 拼进分享 path', () => {
const src = strip(read('common/js/diy.js'));
assert.match(src, /appendPathParam\([^)]*'card_id',\s*cardId\)/,
'分享 path 未追加 card_id');
});
test('B4: onShareTimeline 走 storage 通道并把 card_id 拼进 query', () => {
const src = strip(read('common/js/diy.js'));
assert.match(src, /onShareTimeline\(\)\s*\{/);
assert.match(src, /pickShareCardId\(null\)/,
'onShareTimeline 未从 storage 取 card_id(它不接受事件参数)');
assert.match(src, /appendQueryParam\([^)]*'card_id',\s*cardId\)/,
'朋友圈 query 未追加 card_id');
});
test('B5: 拿不到 card_id 时仍回落原分享数据(向后兼容)', () => {
const src = strip(read('common/js/diy.js'));
assert.match(src, /return this\.mpShareData\?\.appMessage;/,
'未保留原有 appMessage 回落');
assert.match(src, /return base;/,
'未保留原有 timeLine 回落');
});
test('B6: 行为级 — 钩子真跑: dataset 命中 → path 带 card_id', () => {
const { hooks, share } = loadDiyShareHooks();
const ctx = makeDiyCtx();
const res = hooks.onShareAppMessage.call(ctx, {
from: 'button',
target: { dataset: { cardId: 'C_1_shop_x', cardName: '张三' } },
});
assert.equal(res.path, '/pages_tool/index/diy?id=3&name=home&card_id=C_1_shop_x',
'分享 path 未带上当前页 query + card_id');
assert.equal(res.title, '店铺名');
});
test('B7: 行为级 — 无 card_id 时原样返回后台配置的分享数据', () => {
const { hooks } = loadDiyShareHooks();
const ctx = makeDiyCtx();
assert.equal(hooks.onShareAppMessage.call(ctx, { from: 'menu' }), ctx.mpShareData.appMessage,
'无 card_id 时应原样返回 mpShareData.appMessage');
});
test('B8: 行为级 — 朋友圈钩子把 card_id 拼进 query', () => {
const { hooks, share } = loadDiyShareHooks();
const ctx = makeDiyCtx();
share.rememberShareCardId('C_1_shop_x');
const res = hooks.onShareTimeline.call(ctx);
assert.equal(res.query, 'id=3&card_id=C_1_shop_x',
'朋友圈 query 未带 card_id');
assert.equal(res.title, '店铺名');
});
test('B9: 行为级 — 朋友圈无 card_id 时原样回落', () => {
const { hooks } = loadDiyShareHooks();
const ctx = makeDiyCtx();
assert.equal(hooks.onShareTimeline.call(ctx), ctx.mpShareData.timeLine);
});
// =============================================================
// B+. H5 公众号分享链接补 card_id(setPublicShare, S10-1)
// =============================================================
/** 把 diy.js 的 setPublicShare 抠出来真跑(同 loadDiyShareHooks 思路) */
function loadDiySetPublicShare(share) {
const src = read('common/js/diy.js');
const start = src.indexOf('setPublicShare() {');
const end = src.indexOf('onPageScroll(e) {');
assert.ok(start > 0 && end > start, '未能定位 diy.js 的 setPublicShare');
// 剥注释后, 丢掉切片尾部的外层 methods 闭括号(setPublicShare 自己的 '}' 是倒数第二个)
let body = strip(src.slice(start, end));
body = body.slice(0, body.lastIndexOf('}')).trim();
const factory = new Function('routeQueryValue', 'appendPathParam', 'return {' + body + '};');
// 工厂返回 { setPublicShare: fn } 对象, 这里直接交出方法本体
return factory(share.routeQueryValue, share.appendPathParam).setPublicShare;
}
test('B10: diy.js 引入 routeQueryValue 且 setPublicShare 补 card_id', () => {
const src = read('common/js/diy.js');
assert.match(src, /import\s*\{[^}]*routeQueryValue[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/,
'未引入 card-share.js 的 routeQueryValue');
const stripped = strip(src);
const start = stripped.indexOf('setPublicShare()');
const end = stripped.indexOf('onPageScroll');
assert.ok(start > 0 && end > start, '未定位到 setPublicShare');
const seg = stripped.slice(start, end);
assert.match(seg, /routeQueryValue\('card_id'\)/, 'setPublicShare 未从落地 query 取 card_id');
assert.match(seg, /appendPathParam\(shareUrl, 'card_id', shareCardId\)/, '分享链接未追加 card_id');
});
test('B11: 行为级 — H5 落地带 card_id 时公众号分享链接续传', () => {
const uni = makeUni();
const share = loadCardShare(uni, () => ([{
route: 'pages_tool/index/diy',
options: { id: '3', card_id: 'C_1_shop_x' },
}]));
const setPublicShare = loadDiySetPublicShare(share);
let captured = null;
const ctx = {
$config: { h5Domain: 'https://h5.example.com' },
diyRoute: '/pages_tool/index/diy',
id: 3,
name: '',
$util: { setPublicShare: d => { captured = d; }, img: s => s || '' },
$langConfig: { getField: () => '标题' },
diyData: { global: {} },
siteInfo: null,
};
setPublicShare.call(ctx);
assert.equal(captured.link, 'https://h5.example.com/pages_tool/index/diy?id=3&card_id=C_1_shop_x',
'H5 分享链接未续传落地 card_id');
});
test('B12: 行为级 — 裸 Tab(无 card_id)时 H5 分享链接行为零变化', () => {
const uni = makeUni();
const share = loadCardShare(uni, () => ([]));
const setPublicShare = loadDiySetPublicShare(share);
let captured = null;
const ctx = {
$config: { h5Domain: 'https://h5.example.com' },
diyRoute: '/pages_tool/index/diy',
id: 3,
name: '',
$util: { setPublicShare: d => { captured = d; }, img: s => s || '' },
$langConfig: { getField: () => '标题' },
diyData: { global: {} },
siteInfo: null,
};
setPublicShare.call(ctx);
assert.equal(captured.link, 'https://h5.example.com/pages_tool/index/diy?id=3',
'无 card_id 时链接应保持原样(不得追加空参数)');
});
// =============================================================
// C. 名片基件的分享按钮(diy-card-mini.vue)
// =============================================================
test('C1: 小程序端分享按钮带 data-card-id + @tap 记忆', () => {
const src = strip(read('components-diy/diy-card-mini.vue'));
assert.match(src, /open-type="share"/, '分享按钮丢了 open-type=share');
assert.match(src, /:data-card-id="card && card\.card_id"/,
'分享按钮未带 data-card-id(dataset 通道依赖它)');
assert.match(src, /@tap="rememberShareCard"/,
'分享按钮未在 tap 时记住卡片(storage 通道依赖它)');
});
test('C2: diy-card-mini 引入 rememberShareCardId 并实现 rememberShareCard', () => {
const src = read('components-diy/diy-card-mini.vue');
assert.match(src, /import\s*\{[^}]*rememberShareCardId[^}]*\}\s*from\s*['"]@\/common\/js\/card-share\.js['"]/);
assert.match(src, /rememberShareCard\(\)\s*\{[\s\S]*?rememberShareCardId\(this\.card && this\.card\.card_id\)/);
});
// =============================================================
// D. 「我的名片」组件把分享带来的 card_id 回传后端(diy-card-self.vue)
// =============================================================
test('D1: diy-card-self 从路由 query 取 card_id 并传给 mycard', () => {
const src = strip(read('components-diy/diy-card-self.vue'));
assert.match(src, /routeQueryValue\('card_id'\)/,
'未从路由 query 取分享带来的 card_id');
assert.match(src, /card_id:\s*shareCardId/,
'请求参数未带 card_id');
});
test('D2: 分享卡优先于登录引导空态(fallbackMode=none 也照常取分享卡)', () => {
const src = strip(read('components-diy/diy-card-self.vue'));
assert.match(src, /!this\.hasToken && this\.fallbackMode === 'none' && !shareCardId/,
'登录引导早退条件未排除"有分享卡"的情况');
});
// =============================================================
// E. 全局兜底 mixin 的 dataset 分支仍在(其它页面继续受益)
// =============================================================
test('E1: mp-share-default 的 dataset.cardId 分支未被破坏', () => {
const src = strip(read('common/js/mp-share-default.js'));
assert.match(src, /options\.target\.dataset\.cardId/,
'全局兜底 mixin 的 dataset 分支丢了');
assert.match(src, /path:\s*'\/pages_tool\/card\/detail\?card_id=' \+ d\.cardId/);
});

View File

@@ -83,16 +83,21 @@ test('common: rowActOf 由 href 派生点击动作', async () => {
assert.equal(m.rowActOf(null), null);
});
test('common: 二维码四档归一化 + 尺寸表', async () => {
test('common: 二维码档位归一化 + 尺寸表(2.23: flow/head/corner 三档 + rows/bottom 存量别名)', async () => {
const m = await import('../components-diy/diy-card-common.js');
for (const p of ['rows', 'head', 'corner', 'bottom']) {
// 2.23 对外三档: flow(卡片流内) / head(头部右侧) / corner(右上角浮层)
for (const p of ['flow', 'head', 'corner']) {
assert.equal(m.normalizeQrPosition(p), p);
}
// 存量别名: rows/bottom 仍归一化到自身(存量实例不炸), 渲染上等价 flow
for (const p of ['rows', 'bottom']) {
assert.equal(m.normalizeQrPosition(p), p);
}
// 未配置/非法值 → rows(保守默认, 不炸)
assert.equal(m.normalizeQrPosition(''), 'rows');
assert.equal(m.normalizeQrPosition('sideways'), 'rows');
assert.equal(m.normalizeQrPosition(undefined), 'rows');
assert.deepEqual(Object.keys(m.QR_SIZES).sort(), ['bottom', 'corner', 'head', 'rows']);
assert.deepEqual(Object.keys(m.QR_SIZES).sort(), ['bottom', 'corner', 'flow', 'head', 'rows']);
});
// =============================================================
@@ -116,17 +121,17 @@ test('mini: 资料行可点(开关 + href 派生 + emit row-act)', () => {
assert.match(miniSrc, /onRowTap\(row\)\s*\{[\s\S]*?this\.\$emit\('row-act',\s*act\)/);
});
test('mini: 二维码四档渲染分支齐全', () => {
test('mini: 二维码渲染分支齐全(2.23: flow 流内 + head/corner 锚点 + rows/bottom 存量别名)', () => {
assert.match(miniSrc, /showQrRows\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'rows'/);
assert.match(miniSrc, /showQrBottom\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'bottom'/);
assert.match(miniSrc, /showQrHead\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'head'/);
assert.match(miniSrc, /showQrCorner\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'corner'/);
// 模板四个挂点
// 2.23 流内档: flow + 存量 rows/bottom 归并到同一个流内挂点(位置由区块顺序决定)
assert.match(miniSrc, /showQrFlow\(\)\s*\{[\s\S]*?this\.qrPos === 'flow' \|\| this\.qrPos === 'rows' \|\| this\.qrPos === 'bottom'/);
// 模板挂点: 流内(区块流) / 头部右侧 / 右上角浮层
assert.match(miniSrc, /v-if="blk === 'qr' && showQrFlow" class="mini-qr mini-qr-center"/);
assert.match(miniSrc, /v-if="showQrCorner" class="mini-qr mini-qr-corner"/);
assert.match(miniSrc, /v-if="showQrHead" class="mini-qr mini-qr-head"/);
// rows/bottom 档流内块级, 带 mini-qr-center 水平居中(2026-10-22: 修复二维码偏左)
assert.match(miniSrc, /v-if="showQrRows" class="mini-qr mini-qr-center"/);
assert.match(miniSrc, /v-if="showQrBottom" class="mini-qr mini-qr-center"/);
// 居中只加左右 auto 边距, 不碰 corner/head 档定位
assert.match(miniSrc, /&\.mini-qr-center \{\s*margin-left: auto;\s*margin-right: auto;\s*\}/);
// 总条件: 开关 + 有码源(qrSrc: 自定义图优先, 回落名片码)

View File

@@ -0,0 +1,139 @@
/**
* 名片 DIY 组件 ·「简介 / 社交媒体 / 标签 / 来源」契约测试(2.22, 2026-10-28)
*
* 背景: 名片 DIY 组件相对「名片基础信息」有字段遗漏(简介 bio 后端已下发但组件
* 不渲染、社交媒体 socials[] 整族缺失、标签/来源标注也没有)。
* 本次补齐, 且「简介」像资料区一样可独立成块 + 逐向微调。
*
* 覆盖:
* 1. 简介区块: 开关 + 独立成块判定 + 逐向间距(prefix=bio, 复用 blockCustomSpacing)
* 2. 社交/标签/来源: 字段读取与渲染
* 3. 地址细分: address 行用完整地址覆盖 + 邮编独立成行
* 4. 通用块样式工厂: blockIndependent / blockFinalStyle 的语义
* 5. 反例: 不得出现「简介写死 mock」「社交恒为空」这类假绿
*
* 跑法: node --test tests/diy-card-bio-social.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const DIY_DIR = path.resolve(__dirname, '../components-diy');
const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
// 先剥注释再断言 —— 注释里写「绝不能写 X」会被自判违规
const strip = s => s
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
const miniSrc = strip(read('diy-card-mini.vue'));
test('简介区块: 开关 + 独立成块 + 逐向间距(bio 前缀)', () => {
// 开关
assert.match(miniSrc, /showBioBlock\(\)\s*\{[\s\S]*?flagOn\(this\.c\.showBio, false\)/,
'缺少「显示简介」开关(flagOn(this.c.showBio))');
// 数据源: 名片简介
assert.match(miniSrc, /bioText\(\)\s*\{[\s\S]*?this\.card && this\.card\.bio/,
'简介文本未取 card.bio');
// 独立成块判定 + 逐向间距
assert.match(miniSrc, /bioFinalStyle\(\)\s*\{[\s\S]*?blockFinalStyle\(this\.c, 'bio'\)/,
'简介未走通用独立成块样式(blockFinalStyle(this.c, "bio"))');
assert.match(miniSrc, /function blockFinalStyle\(c, prefix\)/,
'缺少通用 blockFinalStyle 工厂');
assert.match(miniSrc, /function blockCustomSpacing\(c, prefix\)/,
'缺少通用逐向间距工厂(应复用而非新写一套)');
// 逐向字段走**通用 prefix 拼接**(C 端不写字面量, 与资料区/二维码同一套)
assert.match(miniSrc, /side\('MarginTop'\)|c\[prefix \+ 'MarginTop'\]/,
'通用逐向工厂未按 prefix 拼字段名(bio 应复用同一套)');
// 标题与行数
assert.match(miniSrc, /bioTitleShow\(\)/, '缺少简介标题开关');
assert.match(miniSrc, /bioMaxLines\(\)/, '缺少简介行数控制');
});
test('简介渲染: 模板消费 bioText, 不是写死文案', () => {
assert.match(miniSrc, /class="mini-bio"[\s\S]*?\{\{ bioText \}\}/,
'简介块未渲染 bioText');
// 反例: 不得出现写死的简介正文
assert.doesNotMatch(miniSrc, /这是一段示例简介|示例简介文案/,
'简介正文疑似写死');
});
test('社交媒体: 读取 card.socials 并渲染, 可独立成块', () => {
assert.match(miniSrc, /socialList\(\)\s*\{[\s\S]*?this\.card && Array\.isArray\(this\.card\.socials\)/,
'社交列表未取 card.socials');
assert.match(miniSrc, /showSocialBlock\(\)/, '缺少社交显示开关');
assert.match(miniSrc, /socialFinalStyle\(\)\s*\{[\s\S]*?blockFinalStyle\(this\.c, 'social'\)/,
'社交块未走通用独立成块样式');
assert.match(miniSrc, /class="mini-social"[\s\S]*?v-for="\(s, i\) in socialList"/,
'社交块未渲染列表');
// 反例: 不得把社交写死成空
assert.doesNotMatch(miniSrc, /socialList\(\)\s*\{\s*return \[\];/, '社交列表被写死为空');
});
test('名片标签 / 来源标注 / 过期提示', () => {
assert.match(miniSrc, /cardTags\(\)\s*\{[\s\S]*?this\.card && Array\.isArray\(this\.card\.tags\)/,
'标签未取 card.tags');
assert.match(miniSrc, /showTagsRow\(\)/, '缺少标签显示开关');
assert.match(miniSrc, /sourceName\(\)\s*\{[\s\S]*?this\.card && this\.card\.source_name/,
'来源标注未取 card.source_name');
assert.match(miniSrc, /showSourceRow\(\)/, '缺少来源显示开关');
assert.match(miniSrc, /cardExpired\(\)\s*\{[\s\S]*?this\.card && this\.card\.expired/,
'过期标记未取 card.expired');
assert.match(miniSrc, /class="mini-expired"/, '缺少过期遮罩元素');
});
test('地址细分: address 行用完整地址覆盖 + 邮编独立成行', () => {
assert.match(miniSrc, /decorateRows\(rows\)/, '缺少资料行装饰');
assert.match(miniSrc, /this\.card && this\.card\.address_full/, '未取完整地址');
assert.match(miniSrc, /this\.card && this\.card\.postal_code/, '未取邮编');
assert.match(miniSrc, /field === 'address'/, '未按 address 字段覆盖');
assert.match(miniSrc, /label: '邮编'/, '邮编未独立成行');
// rowsInfo 必须走 decorateRows(否则覆盖不生效)
assert.match(miniSrc, /rowsInfo\(\)\s*\{[\s\S]*?visibleRows\(this\.decorateRows\(this\.card\.rows\)/,
'rowsInfo 未走 decorateRows');
});
test('多语言: 请求带 lang 参数(后端按 _en 优先下发)', () => {
for (const f of ['diy-card-show.vue', 'diy-card-self.vue', 'diy-card-list.vue']) {
const src = read(f);
assert.match(src, /lang: uni\.getStorageSync\('lang'\) \|\| 'zh-cn'/,
f + ' 请求未带 lang 参数');
}
});
// ---- 2.24: 简介文字样式可配 + meta 区块独立成块 + 有效期展示 ----
test('2.24 简介样式: 消费 bioSize/bioColor/bioLineHeight(后端补 3 键后可配)', () => {
assert.match(miniSrc, /bioTextStyle\(\)\s*\{[\s\S]*?num\(c\.bioSize, 13\)/,
'简介字号未读 bioSize(回退 13)');
assert.match(miniSrc, /bioTextStyle\(\)\s*\{[\s\S]*?c\.bioColor \|\| c\.infoColor/,
'简介颜色未读 bioColor(应回退 infoColor)');
assert.match(miniSrc, /bioTextStyle\(\)\s*\{[\s\S]*?num\(c\.bioLineHeight, 22\)/,
'简介行高未读 bioLineHeight(回退 22)');
});
test('2.24 meta 区块: 与 bio/social 同款, 可独立成块 + 逐向间距', () => {
assert.match(miniSrc, /metaFinalStyle\(\)\s*\{[\s\S]*?blockFinalStyle\(this\.c, 'meta'\)/,
'meta 块未走通用独立成块样式(blockFinalStyle(this.c, "meta"))');
// 模板挂上 metaFinalStyle
assert.match(miniSrc, /class="mini-meta"[\s\S]*?:style="metaFinalStyle"/,
'meta 块模板未挂 metaFinalStyle');
// 区块流 token 含 meta(可参与排序)
assert.match(miniSrc, /BLOCK_TOKENS = \['bio', 'rows', 'acts', 'social', 'qr', 'meta'\]/,
'BLOCK_TOKENS 应含 meta token');
});
test('2.24 有效期: expire_at 自动展示「有效期至」(无独立开关)', () => {
assert.match(miniSrc, /showExpireRow\(\)\s*\{[\s\S]*?this\.card && num\(this\.card\.expire_at, 0\) > 0/,
'有效期行未按 card.expire_at>0 判定');
assert.match(miniSrc, /expireDateText\(\)\s*\{[\s\S]*?this\.card && this\.card\.expire_at/,
'缺少有效期日期格式化');
// 模板渲染「有效期至」且计入 meta 块可见性
assert.match(miniSrc, /有效期至 \{\{ expireDateText \}\}/, '模板未渲染「有效期至」');
assert.match(miniSrc, /showTagsRow \|\| showSourceRow \|\| showExpireRow/,
'meta 块可见性未纳入有效期行');
});

View File

@@ -0,0 +1,98 @@
/**
* 名片 DIY 组件 ·「区块顺序」契约测试(2.23, 2026-10-28)
*
* 背景: 简介 / 资料区 / 快捷操作 / 社交媒体 / 二维码 / 标签来源 六个「可独立成块」的
* 模块此前是固定顺序(且快捷操作有 above/below 开关、二维码有 rows/bottom 档位),
* 2.23 统一为**区块流**: 顺序存 c.blockOrder, C 端按序渲染, 面板用 ↑↓ 调整。
*
* 覆盖(锁三个踩过的坑):
* 1. 区块流顺序解析(blockSeq): 配置优先 + 缺失 token 补齐 + 缺省按历史位置推导
* 2. 模板结构: <template v-for> 内必须用**独立 v-if**(Vue2 的 v-else-if 链跨迭代串联)
* 且 **template 不能带 :key**(Vue2 报错 "cannot be keyed")
* 3. 位置开关下线: 快捷操作上/下由顺序推导; 二维码 head/corner 是锚点档, 不进流内
*
* 跑法: node --test tests/diy-card-block-order.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const DIY_DIR = path.resolve(__dirname, '../components-diy');
const read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
const strip = s => s
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
const miniSrc = strip(read('diy-card-mini.vue'));
test('区块流: 常量与顺序解析', () => {
assert.match(miniSrc, /const BLOCK_TOKENS = \['bio', 'rows', 'acts', 'social', 'qr', 'meta'\]/,
'缺少 BLOCK_TOKENS(六个可独立成块的模块)');
assert.match(miniSrc, /const DEFAULT_BLOCK_ORDER = 'bio,acts,rows,social,qr,meta'/,
'缺少默认顺序(快捷操作在资料区之上, 兼容历史 actsPosition=above)');
assert.match(miniSrc, /blockSeq\(\)\s*\{[\s\S]*?this\.c\.blockOrder/,
'blockSeq 未读 c.blockOrder');
// 缺省回落: 按历史 actsPosition 推导快捷操作在资料区上/下
assert.match(miniSrc, /if \(!list\.length\) \{[\s\S]*?normalizeActsPosition\(this\.c\.actsPosition\) === 'above'/,
'缺省顺序未按历史 actsPosition 推导');
// 未知 token 忽略 + 缺失 token 补齐
assert.match(miniSrc, /BLOCK_TOKENS\.indexOf\(token\) >= 0 && list\.indexOf\(token\) < 0/,
'未过滤未知/重复 token');
assert.match(miniSrc, /def\.forEach\(\(t\) => \{[\s\S]*?if \(at < 0\) list\.unshift\(t\); else list\.splice\(at, 0, t\);/,
'缺失 token 未按默认顺序补齐');
});
test('区块流: 位置开关由顺序推导(旧开关只作兜底)', () => {
assert.match(miniSrc, /actsAbove\(\)\s*\{\s*const i = this\.blockSeq\.indexOf\('acts'\);\s*const r = this\.blockSeq\.indexOf\('rows'\);[\s\S]*?return i < r;/,
'actsAbove 未由区块顺序推导');
// 二维码: head/corner 是锚点档, 其余(rows/bottom/flow)进流内
assert.match(miniSrc, /showQrFlow\(\)\s*\{[\s\S]*?'flow'[\s\S]*?'rows'[\s\S]*?'bottom'/,
'showQrFlow 未覆盖 flow/rows/bottom 三档');
// 右上角浮层让位: 仅当资料区是流内首块
assert.match(miniSrc, /if \(this\.blockSeq\[0\] !== 'rows'\) return \{\};/,
'corner 让位未按区块顺序判据收窄');
});
test('区块流模板: template v-for 内用独立 v-if, 且 template 不带 :key', () => {
// Vue2: <template v-for> 内 v-else-if 链会跨迭代串联 → 必须全部独立 v-if
assert.doesNotMatch(miniSrc, /v-else-if="blk/, 'template v-for 内不得使用 v-else-if 链');
// Vue2: <template> cannot be keyed → key 放在真实元素上
const tmpl = miniSrc.match(/<template v-for="blk in blockSeq"[^>]*>/g) || [];
assert.equal(tmpl.length, 1, '应只有一个区块流 template');
assert.doesNotMatch(tmpl[0] || '', /:key=/, 'template v-for 不得带 :key(Vue2 报错)');
// key 下移到真实元素(快捷操作有宫格/芯片两个元素 → 真实元素数 7)
const keyed = miniSrc.match(/v-if="blk === '(bio|acts|rows|qr|social|meta)'[^>]*:key="blk"/g) || [];
assert.ok(keyed.length >= 6, '六个块的真实元素都应带 :key="blk", 实际 ' + keyed.length);
for (const blk of ['bio', 'acts', 'rows', 'qr', 'social', 'meta']) {
assert.match(miniSrc, new RegExp('v-if="blk === \'' + blk + '\'[^>]*:key="blk"'),
'块 ' + blk + ' 的真实元素未带 :key');
}
});
test('区块流: 六个块都被渲染且样式各自独立', () => {
for (const [blk, cls] of [['bio', 'mini-bio'], ['acts', 'mini-acts'], ['rows', 'mini-rows'],
['social', 'mini-social'], ['qr', 'mini-qr'], ['meta', 'mini-meta']]) {
assert.match(miniSrc, new RegExp('v-if="blk === \'' + blk + '\''), '缺少块 ' + blk + ' 的渲染分支');
}
assert.match(miniSrc, /class="mini-bio" :style="bioFinalStyle"/, '简介块未绑定独立样式');
assert.match(miniSrc, /class="mini-social" :style="socialFinalStyle"/, '社交块未绑定独立样式');
});
test('区块流: 通用工厂复用于新块(不新写一套)', () => {
assert.match(miniSrc, /function blockIndependent\(c, prefix\)/, '缺少 blockIndependent 工厂');
assert.match(miniSrc, /function blockFinalStyle\(c, prefix\)/, '缺少 blockFinalStyle 工厂');
assert.match(miniSrc, /bioFinalStyle\(\)\s*\{\s*return blockFinalStyle\(this\.c, 'bio'\);/, '简介未走通用工厂');
assert.match(miniSrc, /socialFinalStyle\(\)\s*\{\s*return blockFinalStyle\(this\.c, 'social'\);/, '社交未走通用工厂');
});
test('二维码档位: 新增 flow(卡片流内)', () => {
const common = strip(read('diy-card-common.js'));
assert.match(common, /export const QR_POSITIONS = \['flow', 'rows', 'head', 'corner', 'bottom'\]/,
'QR_POSITIONS 缺 flow 档');
assert.match(common, /export const QR_SIZES = \{ flow: 240,/, 'QR_SIZES 缺 flow 尺寸');
});

View File

@@ -228,19 +228,44 @@ if (hasPanels) {
assert.ok(cfg.includes('"qrPriority":"card"'), 'config/diy_view.php 缺 qrPriority 默认值');
});
test('面板: 预览真实消费 qrPriority(4 处二维码挂点走分层 v-if)', () => {
test('面板: 预览真实消费 qrPriority(3 处二维码挂点走分层 v-if)', () => {
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
// 4 个挂点(head/corner/rows/bottom) 各一组分支:
// system 占位 + component 组件图 + component 名片图 + card 名片图 + card 组件图 + 兜底占位
const sys = (panel.match(/v-if="nc\.qrPriority === 'system'"/g) || []).length;
const comp = (panel.match(/v-else-if="nc\.qrPriority === 'component'/g) || []).length;
assert.equal(sys, 4, name + ' 面板 system 档分支数不对: ' + sys);
// component 档每挂点 2 支(组件图 + 名片图回落) = 8
assert.equal(comp, 8, name + ' 面板 component 档分支数不对: ' + comp);
// 2.23: 挂点由 4(head/corner/rows/bottom)合并为 3(head/corner/卡片流内) 各一组分支:
// system 真实系统码 + component 组件图 + component 名片图 + card 名片图 + card 组件图 + 兜底真实系统码
// ⚠ 计数只数**渲染分支**(v-if/v-else-if 挂在 .diy-card-qr__img 上的),
// 「当前实际生效」提示块里也有 nc.qrPriority === 'system' 等判定, 不能计入。
const sys = (panel.match(/class="diy-card-qr__img" v-if="nc\.qrPriority === 'system'"/g) || []).length;
const comp = (panel.match(/class="diy-card-qr__img diy-card-qr__img--custom" v-else-if="nc\.qrPriority === 'component'/g) || []).length;
assert.equal(sys, 3, name + ' 面板 system 档分支数不对: ' + sys);
// component 档每挂点 2 支(组件图 + 名片图回落) = 6
assert.equal(comp, 6, name + ' 面板 component 档分支数不对: ' + comp);
// card 档每挂点 2 支(名片图 + 组件图回落) = 6
const card = (panel.match(/class="diy-card-qr__img diy-card-qr__img--custom" v-else-if="nc\.qrPriority === 'card' &&/g) || []).length;
assert.equal(card, 6, name + ' 面板 card 档分支数不对: ' + card);
// 名片级图分支(每挂点 2 支: component 回落 + card 优先;
// 每支出现 2 次: v-else-if 条件 + style 内取值) = 16
// 每支出现 2 次: v-else-if 条件 + style 内取值) = 12
// + 「当前实际生效」提示块内 2 次 = 14
const cardImg = (panel.match(/nc\.tempData\.previewCard && nc\.tempData\.previewCard\.qr_image/g) || []).length;
assert.equal(cardImg, 16, name + ' 面板名片级图分支数不对: ' + cardImg);
assert.equal(cardImg, 14, name + ' 面板名片级图分支数不对: ' + cardImg);
// system 档与兜底档必须渲染真实系统码 qr_url(否则看不出真实出码方式)
// 2.23: 3 挂点 × (system 档 + 兜底档) = 6
const qrUrl = (panel.match(/nc\.tempData\.previewCard && nc\.tempData\.previewCard\.qr_url/g) || []).length;
assert.equal(qrUrl, 6, name + ' 面板系统码 URL 分支数不对: ' + qrUrl);
}
});
test('面板: 有「当前实际生效」提示(切三档时商家能看到结果差异)', () => {
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
assert.ok(panel.includes('当前实际生效'), name + ' 面板缺当前生效来源提示');
assert.ok(panel.includes('(微信小程序码)'), name + ' 面板提示未区分小程序码');
assert.ok(panel.includes('(普通二维码)'), name + ' 面板提示未区分普通码');
// 三档各自独立判定
for (const mode of ['system', 'component', 'card']) {
assert.ok(
panel.includes("nc.qrPriority === '" + mode + "'"),
name + ' 面板提示缺 ' + mode + ' 档判定'
);
}
}
});

View File

@@ -204,12 +204,16 @@ test('mini: 卡片四向外边距与头部盒模型配置完整消费', () => {
test('mini: C端盒模型计算值——历史默认兼容且自定义值逐项生效', async () => {
const m = await import('../components-diy/diy-card-common.js');
const share = await import('../common/js/card-share.js');
// 2026-10-28: mini 组件新增了 '@/common/js/card-share.js' 的 import(分享带名片 ID),
// 原来只剥离 './diy-card-common.js' 一条 → 每加一个 import 就崩一次;
// 改为**通用剥离所有 import 语句**, 被剥离的标识符改为按需注入。
const script = miniSrc.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import\s*\{[\s\S]*?\}\s*from\s*'\.\/diy-card-common\.js';/, '')
.replace(/^\s*import\s+[^;]+;\s*$/gm, '')
.replace('export default {', 'module.exports = {');
const names = Object.keys(m);
const names = [...Object.keys(m), ...Object.keys(share)];
const moduleBox = { exports: {} };
new Function('module', ...names, script)(moduleBox, ...names.map(k => m[k]));
new Function('module', ...names, script)(moduleBox, ...names.map(k => (m[k] !== undefined ? m[k] : share[k])));
const computed = moduleBox.exports.computed;
const util = { objToStyle: o => o };
const card = { theme_color: '#119DFF', gradient_end: '#0E81D1', gradient_angle: 135 };
@@ -276,8 +280,9 @@ test('mini: qrBlockStyle 四档外观分支(多彩只装饰外围, 码黑白保
assert.ok(!miniSrc.includes("qrStyle === 'plain' ? style.background"));
// 2026-10-23 用户定稿: 外观衬底只包二维码图(qrImgStyle), 「扫码查看完整名片」tip 在衬底外无蓝底
assert.match(miniSrc, /class="qr-img" :style="qrImgStyle"/);
// 2.23: 挂点由 4(head/corner/rows/bottom)合并为 3(head 双分支 / corner / 卡片流内)
const tipBinds = miniSrc.match(/class="qr-tip" :style="labelStyle"/g) || [];
assert.equal(tipBinds.length, 5, '五档 tip 仍独立(不进衬底; 含 theme 头部块), 实际 ' + tipBinds.length);
assert.equal(tipBinds.length, 4, '三挂点 tip 仍独立(不进衬底; 含 theme 头部块双分支), 实际 ' + tipBinds.length);
});
test('mini: 二维码尺寸取 resolveQrSize(自定义>0 优先, 否则档位自动)', () => {
@@ -295,9 +300,9 @@ test('mini: 微调位移 transform(不用 calc, WXSS 兼容)', () => {
test('mini: 扫码提示开关 + 四个挂点统一消费', () => {
assert.match(miniSrc, /showQrTip\(\)\s*\{\s*return flagOn\(this\.c\.qrTipShow,\s*true\)/);
// 二维码 tip 挂点: corner/rows/bottom 各 1 + head 档 2(head 双形态分支互斥渲染, 2.17)
// 2.23: tip 挂点 = corner 1 + 卡片流内 1 + head 档 2(head 双形态分支互斥渲染)
const tips = miniSrc.match(/<text v-if="showQrTip" class="qr-tip"/g) || [];
assert.equal(tips.length, 5, 'tip 挂点(4 档 + head 双分支各一), 实际 ' + tips.length);
assert.equal(tips.length, 4, 'tip 挂点(3 挂点 + head 双分支), 实际 ' + tips.length);
});
test('mini: cornerClearStyle 按实际尺寸反算让位(防遮挡, 纯数值不用 calc)', () => {
@@ -318,11 +323,12 @@ test('mini: cornerHeadStyle 头部水平让位按浮层实际宽度动态反算'
assert.match(miniSrc, /<view class="mini-head" :style="cornerHeadStyle">/);
});
test('mini: 二维码块样式绑定——corner/head 走 qrBlockStyle, rows/bottom 走 qrBlockFinalStyle(2.19.10 分卡合并)', () => {
test('mini: 二维码块样式绑定——corner/head 走 qrBlockStyle, 卡片流内走 qrBlockFinalStyle(2.23 合并 rows/bottom)', () => {
const direct = miniSrc.match(/:style="qrBlockStyle"/g) || [];
assert.equal(direct.length, 3, 'qrBlockStyle 直绑(corner 1 + head 双分支 2), 实际 ' + direct.length);
// 2.23: rows/bottom 两处合并为「卡片流内」一处(位置由区块顺序决定)
const final = miniSrc.match(/:style="qrBlockFinalStyle"/g) || [];
assert.equal(final.length, 2, 'qrBlockFinalStyle(rows/bottom 各 1), 实际 ' + final.length);
assert.equal(final.length, 1, 'qrBlockFinalStyle(卡片流内 1), 实际 ' + final.length);
});
// =============================================================