Files
lucky_shop/tests/diy-card-qr-image-source.test.js
ZF sun 5e6eec166a feat(card): 名片区块流与基础信息补齐(2.22/2.23) + 分享带名片ID
- 组件支持六个「可独立成块」模块任意排序(区块流): 资料行/快捷操作/社交/二维码/
  标签来源的位置可调, 头部恒最上、发名片恒最下; 二维码位置档位归并为 flow。
- 补齐「名片基础信息」遗漏字段: 简介区块、社交区块、名片附加信息(过期遮罩/
  地址细分/来源), 并与 wap 端展示口径对齐。
- 小程序分享给游客时携带名片 ID: 分享 path 追加 card_id, 访客打开「我的名片」
  即看到分享者那张卡。新增 common/js/card-share.js 统一处理「取卡ID / 拼参数」
  (dataset 通道 + storage 通道兜底, 朋友圈场景靠 storage)。
2026-10-28 16:37:12 +08:00

325 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 名片 DIY 组件 · 名片级自定义二维码(card.qr_image)契约测试
*
* 背景(2.20):
* 此前二维码只有两个来源 —— 组件级自定义图(c.qrImage)与系统生成码(card.qr_url)。
* 2.20 起每张名片(员工名片/企业名片)可各自持有一张自定义二维码图片,
* DIY 组件按三层优先级取用:
* 1) 名片级 card.qr_image ← 每张名片各自一张(「我的名片」登录后即见本人那张)
* 2) 组件级 c.qrImage ← 装修期统一指定(名片自己没设图时生效)
* 3) 系统码 card.qr_url ← 兜底(原有行为)
*
* 覆盖:
* 1. mini 基件 qrSrc 按上述优先级取值(源码契约 + 真实求值双证)
* 2. 组件级 qrImage 仍完整保留(未被名片级顶掉)
* 3. 反例: 虚构字段不得出现(防恒真)
*
* 跑法: node --test tests/diy-card-qr-image-source.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'));
/**
* 从 mini.vue 抽出 qrSrc 的真实函数体并求值 —— 不靠正则猜逻辑,
* 避免「正则匹配到注释/字符串就假绿」。
*
* 函数体内部用的是 this.c / this.card, 故以 ctx 为 this 求值。
*/
function extractQrSrc(src) {
const m = src.match(/qrSrc\(\)\s*\{([\s\S]*?)\n\t\t\}/);
assert.ok(m, 'mini.vue 找不到 qrSrc 计算属性');
return m[1];
}
/** 用构造器求值 qrSrc 函数体(只依赖 card/c 两个入参) */
function evalQrSrc(card, config) {
const body = extractQrSrc(miniSrc);
// eslint-disable-next-line no-new-func
const fn = new Function('card', 'c', body);
// ctx 需提供 qrPriority 计算属性(Vue 里 qrSrc 依赖 this.qrPriority)
const ctx = {
card: card,
c: config,
get qrPriority() { return evalQrPriority(config); },
};
return fn.call(ctx, card, config);
}
/** 抽 qrPriority 计算属性体并求值(模式白名单化) */
function evalQrPriority(config) {
const m = miniSrc.match(/qrPriority\(\)\s*\{([\s\S]*?)\n\t\t\}/);
assert.ok(m, 'mini.vue 找不到 qrPriority 计算属性');
// eslint-disable-next-line no-new-func
const fn = new Function('c', m[1]);
const ctx = { c: config };
return fn.call(ctx, config);
}
test('mini: qrSrc 源码按「名片级 > 组件级 > 系统码」取用', () => {
const body = extractQrSrc(miniSrc);
// 名片级: card.qr_image
assert.ok(/card\.qr_image/.test(body), 'qrSrc 未消费名片级 card.qr_image');
// 组件级: c.qrImage
assert.ok(/c\.qrImage/.test(body), 'qrSrc 未保留组件级 c.qrImage');
// 系统码兜底: card.qr_url
assert.ok(/card\.qr_url/.test(body), 'qrSrc 未保留系统码兜底 card.qr_url');
});
test('mini: qrSrc 真实求值 —— 名片级优先于组件级', () => {
const got = evalQrSrc(
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
{ qrImage: '/upload/component.png' }
);
assert.equal(got, '/upload/own.png');
});
test('mini: qrSrc 真实求值 —— 名片级为空时回落组件级', () => {
const got = evalQrSrc(
{ qr_image: '', qr_url: 'http://x/q' },
{ qrImage: '/upload/component.png' }
);
assert.equal(got, '/upload/component.png');
});
test('mini: qrSrc 真实求值 —— 两级皆空回落系统生成码(原行为)', () => {
const got = evalQrSrc(
{ qr_image: '', qr_url: 'http://x/q' },
{ qrImage: '' }
);
assert.equal(got, 'http://x/q');
});
test('mini: qrSrc 真实求值 —— 名片级优先(组件级也配了图时)', () => {
// 「我的名片」组件: 员工登录后应看到属于该员工自己名片的那张图,
// 即使装修时组件级也配了图 —— 名片级更贴近实体, 优先。
const got = evalQrSrc(
{ qr_image: '/upload/employee-own.png', qr_url: 'http://x/q' },
{ qrImage: '/upload/shop-level.png' }
);
assert.equal(got, '/upload/employee-own.png');
});
test('mini: qrSrc 真实求值 —— card 为 null 不抛异常(未登录空态)', () => {
const got = evalQrSrc(null, { qrImage: '/upload/component.png' });
assert.equal(got, '/upload/component.png');
});
// =============================================================
// 2.20.1 优先级模式(用户在 DIY 组件面板显式选择)
// =============================================================
test('mini: qrPriority 白名单化 —— 缺失/非法值回落 card', () => {
assert.equal(evalQrPriority({}), 'card');
assert.equal(evalQrPriority({ qrPriority: '' }), 'card');
assert.equal(evalQrPriority({ qrPriority: 'evil' }), 'card');
assert.equal(evalQrPriority({ qrPriority: 'component' }), 'component');
assert.equal(evalQrPriority({ qrPriority: 'system' }), 'system');
});
test('mini: mode=card 名片级优先(默认; 员工登录看到自己那张)', () => {
const got = evalQrSrc(
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
{ qrImage: '/upload/component.png', qrPriority: 'card' }
);
assert.equal(got, '/upload/own.png');
});
test('mini: mode=card 名片级为空则用组件级', () => {
const got = evalQrSrc(
{ qr_image: '', qr_url: 'http://x/q' },
{ qrImage: '/upload/component.png', qrPriority: 'card' }
);
assert.equal(got, '/upload/component.png');
});
test('mini: mode=component 组件级优先于名片级', () => {
const got = evalQrSrc(
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
{ qrImage: '/upload/component.png', qrPriority: 'component' }
);
assert.equal(got, '/upload/component.png');
});
test('mini: mode=component 组件级为空则回落名片级', () => {
const got = evalQrSrc(
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
{ qrImage: '', qrPriority: 'component' }
);
assert.equal(got, '/upload/own.png');
});
test('mini: mode=system 只用系统生成码(忽略全部自定义图)', () => {
const got = evalQrSrc(
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
{ qrImage: '/upload/component.png', qrPriority: 'system' }
);
assert.equal(got, 'http://x/q');
});
test('mini: mode=system 名片级有图也忽略', () => {
const got = evalQrSrc(
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
{ qrImage: '', qrPriority: 'system' }
);
assert.equal(got, 'http://x/q');
});
test('mini: 未设置 qrPriority(存量实例)等价于 card', () => {
const withMode = evalQrSrc(
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
{ qrImage: '/upload/component.png', qrPriority: 'card' }
);
const withoutMode = evalQrSrc(
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
{ qrImage: '/upload/component.png' }
);
assert.equal(withoutMode, withMode);
});
// =============================================================
// 面板契约(两个组件的 design.html 都要有来源优先级控件)
// =============================================================
const PANEL_DIR = path.resolve(__dirname, '../../backend/src/addon/card/component/view');
const hasPanels = fs.existsSync(path.join(PANEL_DIR, 'diy-card-self/design.html'));
if (hasPanels) {
const selfPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8'));
const showPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8'));
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
test('面板(' + name + '): 二维码来源优先级三档控件均存在', () => {
for (const v of ['card', 'component', 'system']) {
assert.ok(
panel.includes("nc.qrPriority = '" + v + "'"),
name + ' 面板缺优先级选项 ' + v
);
}
});
test('面板(' + name + '): 来源控件在「显示二维码」打开时才显示', () => {
// 控件所在容器必须带 v-show="nc.showQr"
const idx = panel.indexOf("nc.qrPriority = 'card'");
assert.ok(idx > 0, name + ' 面板找不到来源控件');
const head = panel.slice(Math.max(0, idx - 1200), idx);
assert.ok(head.includes('v-show="nc.showQr"'), name + ' 面板来源控件未随显示二维码开关联动');
});
}
test('面板: 优先级默认值与 config 注册表一致(card)', () => {
// config/diy_view.php 的 util 段默认值
const cfgPath = path.resolve(__dirname, '../../backend/src/addon/card/config/diy_view.php');
const cfg = fs.readFileSync(cfgPath, 'utf8');
assert.ok(cfg.includes('"qrPriority":"card"'), 'config/diy_view.php 缺 qrPriority 默认值');
});
test('面板: 预览真实消费 qrPriority(3 处二维码挂点走分层 v-if)', () => {
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
// 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 内取值) = 12
// + 「当前实际生效」提示块内 2 次 = 14
const cardImg = (panel.match(/nc\.tempData\.previewCard && nc\.tempData\.previewCard\.qr_image/g) || []).length;
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 + ' 档判定'
);
}
}
});
test('面板: 预览不得调用 window 全局函数(Vue2 模板作用域不可见)', () => {
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
assert.ok(
!/cardQrPreviewSrc\s*\(/.test(panel),
name + ' 面板仍调用 window 全局函数 —— Vue2 模板会报「not defined on the instance」'
);
}
});
// 2026-10-27: 「快捷操作管理」图层行的 color input 空值报错
// 图层行 v-for 数组曾只有 {k,ch}(无 bg/fg), 而同行 :value 引用 a.fg/a.bg
// → undefined → Vue 写 elm.value='' → Chrome CSS color 格式告警
test('面板: 图层行 v-for 数组含 bg/fg(避免 color input 空值)', () => {
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
const m = panel.match(/v-for="a in (\[\{k:'tel'[\s\S]*?\}\])"/);
assert.ok(m, name + ' 面板找不到图层行 v-for 数组');
const arr = m[1];
const items = arr.match(/\{k:'[a-z]+'/g) || [];
assert.equal(items.length, 5, name + ' 面板图层行数组项数不对: ' + items.length);
const bgCount = (arr.match(/bg:'#[0-9A-Fa-f]{6}'/g) || []).length;
const fgCount = (arr.match(/fg:'#[0-9A-Fa-f]{6}'/g) || []).length;
assert.equal(bgCount, 5, name + ' 面板图层行数组缺 bg: ' + bgCount);
assert.equal(fgCount, 5, name + ' 面板图层行数组缺 fg: ' + fgCount);
}
});
test('面板: 图层行 color input 的 :value 不含未定义引用', () => {
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
const colorBind = panel.match(/data-field="color"[^>]*:value="([^"]*)"/);
const bgBind = panel.match(/data-field="bg"[^>]*:value="([^"]*)"/);
assert.ok(colorBind, name + ' 面板找不到图层行 color 绑定');
assert.ok(bgBind, name + ' 面板找不到图层行 bg 绑定');
for (const [label, expr] of [['color', colorBind[1]], ['bg', bgBind[1]]]) {
const refs = (expr.match(/\ba\.([a-z]+)/g) || []).map(x => x.slice(2));
for (const r of Array.from(new Set(refs))) {
assert.ok(
['k', 'ch', 'lb', 'bg', 'fg'].includes(r),
name + ' 面板图层行 ' + label + ' 绑定引用了数组里不存在的 a.' + r
);
}
}
}
});
}
// =============================================================
// 反例(防恒真)
// =============================================================
test('反例: 虚构字段绝不出现在基件', () => {
assert.ok(!miniSrc.includes('c.qrMagic'));
assert.ok(!miniSrc.includes('card.qrMagic'));
});