Files
lucky_shop/tests/diy-card-ab-visual.test.js
ZF sun a841af8f5b feat(diy-card): A+B 视觉升级(资料行图标/可点 + 二维码四档任意位)
配合后端 f031476, 组件内直接复刻参考图视觉, 功能仍归详情页(路径A):

- common.js: rowIconFor(field→iconfont glyph, 无字形退化空串) +
  rowActOf(href 派生 tel/mail/copy, 空 href 不可点) +
  normalizeQrPosition/QR_SIZES(四档归一化+尺寸表)
- mini.vue: 资料行图标列(与文字标签列同宽, 值列左缘对齐) +
  行点击 emit row-act; 二维码四档渲染:
  rows 行下(默认) / head 头部右侧(三段 flex 零遮挡) /
  corner 右上浮层(白底衬底+Logo 避让+资料行让位 108rpx) /
  bottom 主按钮上方; 二维码尺寸随档位(180/120/120/180)
- show/self: miniConfig 透传 showRowIcons/rowTap/qrPosition,
  模板挂 @row-act="doAct" 复用既有动作分发
- 新增 tests/diy-card-ab-visual.test.js 13 项(真函数+源码断言,
  7 项变异验证全抓住)

node --test tests/diy-card-ab-visual.test.js → 13/13
2026-10-21 11:44:37 +08:00

150 lines
7.4 KiB
JavaScript

/**
* 名片 DIY 组件 · A+B 视觉升级契约测试
*
* 背景: 路径A(组件卡跳详情页) + 路径B(组件内复刻参考图视觉) 组合方案:
* - 资料行图标(电话/微信/地址; 无字形字段退化文字标签, 与详情页同口径)
* - 资料行可点(href 派生: 拨号/邮箱/复制)
* - 二维码四档任意位(rows 行下 / head 头部右侧 / corner 右上浮层 / bottom 主按钮上)
* - corner 档 Logo 自动避让 + 资料行让位(防遮挡)
*
* 覆盖:
* 1. common.js 纯函数(动态 import 真值断言, 非正则)
* 2. mini.vue 模板/计算/方法契约(先剥注释再断言)
* 3. show/self 透传 + @row-act 接线
* 4. 反例: 虚构字段/不存在的档位必须查不到
*
* 跑法: node --test tests/diy-card-ab-visual.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 showSrc = strip(read('diy-card-show.vue'));
const selfSrc = strip(read('diy-card-self.vue'));
const miniSrc = strip(read('diy-card-mini.vue'));
// =============================================================
// 1. common.js 纯函数(真值断言)
// =============================================================
test('common: rowIconFor 字段→图标映射(无字形字段退化为空)', async () => {
const m = await import('../components-diy/diy-card-common.js');
assert.equal(m.rowIconFor('mobile'), 'icon-dianhua');
assert.equal(m.rowIconFor('phone'), 'icon-dianhua');
assert.equal(m.rowIconFor('wechat'), 'icon-weixin');
assert.equal(m.rowIconFor('address'), 'icon-dizhi');
// 无字形: 邮箱/网站/自定义/未知 → 空串(模板退化为文字标签)
assert.equal(m.rowIconFor('email'), '');
assert.equal(m.rowIconFor('website'), '');
assert.equal(m.rowIconFor('custom'), '');
assert.equal(m.rowIconFor(''), '');
assert.equal(m.rowIconFor(undefined), '');
});
test('common: rowActOf 由 href 派生点击动作', async () => {
const m = await import('../components-diy/diy-card-common.js');
assert.deepEqual(m.rowActOf({ field: 'phone', value: '22644212509', href: 'tel:22644212509' }), { type: 'tel', value: '22644212509' });
assert.deepEqual(m.rowActOf({ field: 'email', value: 'a@b.com', href: 'mailto:a@b.com' }), { type: 'mail', value: 'a@b.com' });
assert.deepEqual(m.rowActOf({ field: 'website', value: 'https://x.com', href: 'https://x.com' }), { type: 'copy', value: 'https://x.com' });
// 空 href(地址/自定义)或空值 → 不可点
assert.equal(m.rowActOf({ field: 'address', value: '上海', href: '' }), null);
assert.equal(m.rowActOf({ field: 'phone', value: '', href: 'tel:' }), null);
assert.equal(m.rowActOf(null), null);
});
test('common: 二维码四档归一化 + 尺寸表', async () => {
const m = await import('../components-diy/diy-card-common.js');
for (const p of ['rows', 'head', 'corner', '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']);
});
// =============================================================
// 2. mini.vue 基件契约
// =============================================================
test('mini: 资料行图标列 + 文字标签退化', () => {
assert.match(miniSrc, /<text v-if="rowIcon\(row\)" class="row-icon iconfont" :class="rowIcon\(row\)"/);
assert.match(miniSrc, /<text v-else class="row-label"/);
// rowIcon 受 showRowIcons 开关控制(默认开)
assert.match(miniSrc, /rowIcon\(row\)\s*\{\s*return flagOn\(this\.c\.showRowIcons,\s*true\)/);
});
test('mini: 资料行可点(开关 + href 派生 + emit row-act)', () => {
assert.match(miniSrc, /rowTapable\(row\)\s*\{\s*return flagOn\(this\.c\.rowTap,\s*true\)\s*&&\s*!!rowActOf\(row\)/);
assert.match(miniSrc, /:class="\{ 'row-tappable': rowTapable\(row\) \}"/);
assert.match(miniSrc, /@tap="onRowTap\(row\)"/);
assert.match(miniSrc, /onRowTap\(row\)\s*\{[\s\S]*?this\.\$emit\('row-act',\s*act\)/);
});
test('mini: 二维码四档渲染分支齐全', () => {
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'/);
// 模板四个挂点
assert.match(miniSrc, /v-if="showQrCorner" class="mini-qr mini-qr-corner"/);
assert.match(miniSrc, /v-if="showQrHead" class="mini-qr mini-qr-head"/);
assert.match(miniSrc, /v-if="showQrRows" class="mini-qr"/);
assert.match(miniSrc, /v-if="showQrBottom" class="mini-qr"/);
// 总条件仍要求名片有 qr_url
assert.match(miniSrc, /showQrBase\(\)\s*\{\s*return flagOn\(this\.c\.showQr,\s*false\) && !!\(this\.card && this\.card\.qr_url\)/);
});
test('mini: corner 档 Logo 避让 + 资料行让位样式', () => {
assert.match(miniSrc, /showLogo\(\)\s*\{[\s\S]*?this\.qrPos !== 'corner'/);
assert.match(miniSrc, /:class="\{ 'qr-at-corner': qrPos === 'corner' \}"/);
assert.match(miniSrc, /&\.qr-at-corner \{[\s\S]*?\.mini-rows,\s*\.mini-acts\s*\{\s*margin-top: 108rpx;/);
// 浮层衬底(封面/纯色上都可扫)
assert.match(miniSrc, /&\.mini-qr-corner \{[\s\S]*?background: #ffffff;/);
});
// =============================================================
// 3. show/self 透传 + 接线
// =============================================================
for (const [name, src] of [['show', showSrc], ['self', selfSrc]]) {
test(name + ': miniConfig 透传 A+B 三字段', () => {
assert.match(src, /showRowIcons:\s*v\.showRowIcons/);
assert.match(src, /rowTap:\s*v\.rowTap/);
assert.match(src, /qrPosition:\s*v\.qrPosition/);
});
test(name + ': 模板挂 @row-act="doAct"(行点击有接收方)', () => {
assert.match(src, /@row-act="doAct"/);
});
}
// =============================================================
// 反例(防恒真)
// =============================================================
test('反例: 不存在第五档 qrPosition 分支', () => {
assert.ok(!miniSrc.includes("qrPos === 'sideways'"));
assert.ok(!miniSrc.includes('showQrSideways'));
});
test('反例: 行图标不映射不存在的字段', async () => {
const m = await import('../components-diy/diy-card-common.js');
assert.equal(m.rowIconFor('nosuchfield'), '');
// 未知 scheme 的 href(后端实际只会下发 tel:/mailto:/安全 URL/空) → 复制兜底
assert.deepEqual(m.rowActOf({ field: 'nosuchfield', value: 'x', href: 'nosuch:x' }), { type: 'copy', value: 'x' });
// 真空 href 才不可点
assert.equal(m.rowActOf({ field: 'nosuchfield', value: 'x', href: '' }), null);
});