- mini.vue: 8 detail 字段消费(三列行/复制范围外观/QR 多行/URL 行/hero 徽章) + rowsBlockStyle/qrBlockCustomStyle computed(与操作区 actsBlockStyle 同款模式) + rowsFinalStyle/qrBlockFinalStyle 合并独立块样式 - show.vue/self.vue: miniConfig 透传 14 字段(8 detail + 6 独立样式) - 测试 ×3: 2.19.10 契约锁(配置层/透传/面板/预览消费/反例: layoutCards 已移除) 前端 7 套 123 项全绿
206 lines
12 KiB
JavaScript
206 lines
12 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');
|
|
// 邮箱: 方案1(2026-10-22)预留 icon-youxiang, 需字库补充信封字形后方能显示, 多键名兜底
|
|
assert.equal(m.rowIconFor('email'), 'icon-youxiang');
|
|
assert.equal(m.rowIconFor('mail'), 'icon-youxiang');
|
|
// 网站(2026-10-22): 复用链条字形 icon-fuzhilianjie; 自定义/未知 → 空串(退化为文字标签)
|
|
assert.equal(m.rowIconFor('website'), 'icon-fuzhilianjie');
|
|
assert.equal(m.rowIconFor('custom'), '');
|
|
assert.equal(m.rowIconFor(''), '');
|
|
assert.equal(m.rowIconFor(undefined), '');
|
|
// 2.17.1: rowCssIconFor —— 邮箱/网站走 CSS 描边图标(信封/线框地球), 口径互补
|
|
assert.equal(m.rowCssIconFor('email'), 'mail');
|
|
assert.equal(m.rowCssIconFor('website'), 'web');
|
|
assert.equal(m.rowCssIconFor('mobile'), '', '有 iconfont 字形的字段不走 CSS 档');
|
|
assert.equal(m.rowCssIconFor(''), '');
|
|
assert.equal(m.rowCssIconFor(undefined), '');
|
|
// 2.18.1: 宫格图标 = WAP_LINE_ICONS(与 wap detail 快捷操作同源 SVG 线稿), wx/save 固定色
|
|
assert.equal(m.WAP_LINE_ICONS.tel.mode, 'stroke');
|
|
assert.equal(m.WAP_LINE_ICONS.wx.mode, 'fill');
|
|
assert.equal(m.WAP_LINE_ICONS.wx.color, '#07C160', 'wx 微信绿固定色');
|
|
assert.equal(m.WAP_LINE_ICONS.save.color, '#FF9500', 'save 通讯录橙固定色');
|
|
assert.equal(m.WAP_LINE_ICONS.mail.mode, 'stroke');
|
|
assert.equal(m.WAP_LINE_ICONS.web.mode, 'stroke');
|
|
assert.ok(m.svgLineIconBg('unknown', '#000') === '', '未知类型空串');
|
|
assert.ok(m.svgLineIconBg('tel', '#0F6E56').indexOf('base64,') > 0, 'data URI base64 形态');
|
|
// 宫格 preset 补 save 紫色档
|
|
assert.deepEqual(m.ACT_GRID_PRESETS.save, { bg: '#F0E9FB', fg: '#5B3DA8' });
|
|
});
|
|
|
|
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' });
|
|
// 网站行: http(s) 派生 web(点击经 web-view 承载页跳转, 2026-10-23 用户需求)
|
|
assert.deepEqual(m.rowActOf({ field: 'website', value: 'https://x.com', href: 'https://x.com' }), { type: 'web', 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: 资料行图标列 + 文字标签退化', () => {
|
|
// 2.17.1: 行首新增 CSS 描边图标档(邮箱/网站), iconfont 档退为 v-else-if
|
|
assert.match(miniSrc, /<view v-if="rowCssIcon\(row\)" class="row-css-wrap">/);
|
|
assert.match(miniSrc, /<text v-else-if="rowIcon\(row\)" class="row-icon iconfont" :class="rowIcon\(row\)"/);
|
|
assert.match(miniSrc, /<text v-if="showRowLabels \|\| \(!rowCssIcon\(row\) && !rowIcon\(row\)\)" class="row-label"/);
|
|
// rowIcon 受 showRowIcons 开关控制(默认开); CSS 档同受此开关
|
|
assert.match(miniSrc, /rowIcon\(row\)\s*\{\s*return flagOn\(this\.c\.showRowIcons,\s*true\)/);
|
|
assert.match(miniSrc, /rowCssIcon\(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\), 'row-3col': showRowLabels \}"/);
|
|
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"/);
|
|
// 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: 自定义图优先, 回落名片码)
|
|
assert.match(miniSrc, /showQrBase\(\)\s*\{\s*return flagOn\(this\.c\.showQr,\s*false\) && !!this\.qrSrc/);
|
|
// 统一码源 qrSrc: 自定义图优先, 回落名片码(2.18)
|
|
assert.match(miniSrc, /qrSrc\(\)\s*\{[\s\S]*?String\(this\.c\.qrImage \|\| ''\)\.trim\(\)[\s\S]*?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' \}"/);
|
|
// 头部水平让位 = cornerHeadStyle 动态反算(审计修复: 静态 180rpx 在自定义大尺寸下不足)
|
|
assert.match(miniSrc, /<view class="mini-head" :style="cornerHeadStyle">/);
|
|
// 资料行/操作区让位 = cornerClearStyle 动态反算(自定义大尺寸/关 tip 自动跟随, 不用 calc)
|
|
// 2.18: 让位经 rowsClearStyle 分流(操作区在上方时资料行不重复位移); 操作区让位并入
|
|
// actsBlockFinalStyle 单绑定(2026-10-24: uni-app 静默丢弃数组绑定, 操作区独立样式曾整体失效)
|
|
assert.match(miniSrc, /rowsClearStyle\(\)\s*\{[\s\S]*?this\.actsAbove \? \{\} : this\.cornerClearStyle/);
|
|
assert.match(miniSrc, /:style="actsBlockFinalStyle"/);
|
|
assert.doesNotMatch(miniSrc, /:style="\[actsBlockStyle/);
|
|
// 静态 CSS 让位必须已移除(动态值才是单一事实来源)
|
|
assert.ok(!/padding-right: 180rpx;/.test(miniSrc), '静态 180rpx 让位应废弃, 改由 cornerHeadStyle 动态给出');
|
|
// 浮层 CSS 只定位于层次; 视觉衬底(白卡等)由 qrBlockStyle 内联决定
|
|
assert.match(miniSrc, /&\.mini-qr-corner \{\s*position: absolute;\s*top: 20rpx;\s*right: 20rpx;\s*z-index: 3;\s*margin-top: 0;\s*box-shadow:/);
|
|
assert.ok(!/&\.mini-qr-corner \{[\s\S]*?background: #ffffff;/.test(miniSrc),
|
|
'白底衬底应移入 qrBlockStyle(多彩外观时不再是白底), CSS 不写死');
|
|
});
|
|
|
|
// =============================================================
|
|
// 3. self miniConfig 透传 + 接线 / show 自绘资料行
|
|
// =============================================================
|
|
|
|
test('self: miniConfig 透传 A+B 三字段', () => {
|
|
assert.match(selfSrc, /showRowIcons:\s*v\.showRowIcons/);
|
|
assert.match(selfSrc, /rowTap:\s*v\.rowTap/);
|
|
assert.match(selfSrc, /qrPosition:\s*v\.qrPosition/);
|
|
});
|
|
|
|
test('self: miniConfig 透传 2.19.10 detail 对齐 9 字段', () => {
|
|
for (const f of ['showRowLabels', 'copyBtnScope', 'copyBtnLook', 'qrTipEm', 'qrTipSub', 'qrUrlShow', 'badgeShow', 'badgeText', 'rowsBlockBg', 'rowsBlockRadius', 'rowsBlockPad', 'qrBlockBg', 'qrBlockRadius', 'qrBlockPad']) {
|
|
assert.match(selfSrc, new RegExp(f + ':\\s*v\\.' + f + ','), 'self 透传 ' + f);
|
|
}
|
|
});
|
|
|
|
test('show: miniConfig 透传 2.19.10 detail 对齐 9 字段', () => {
|
|
for (const f of ['showRowLabels', 'copyBtnScope', 'copyBtnLook', 'qrTipEm', 'qrTipSub', 'qrUrlShow', 'badgeShow', 'badgeText', 'rowsBlockBg', 'rowsBlockRadius', 'rowsBlockPad', 'qrBlockBg', 'qrBlockRadius', 'qrBlockPad']) {
|
|
assert.match(showSrc, new RegExp(f + ':\\s*v\\.' + f + ','), 'show 透传 ' + f);
|
|
}
|
|
});
|
|
|
|
test('self: 模板挂 @row-act="doAct"(行点击有接收方)', () => {
|
|
assert.match(selfSrc, /@row-act="doAct"/);
|
|
});
|
|
|
|
test('show: 资料行/行图标/行点击走基件透传(方案A: 渲染由 mini 侧行测试覆盖)', () => {
|
|
assert.match(showSrc, /showRows:\s*v\.showRows,/);
|
|
assert.match(showSrc, /showRowIcons:\s*v\.showRowIcons,/);
|
|
assert.match(showSrc, /rowTap:\s*v\.rowTap,/);
|
|
});
|
|
|
|
// =============================================================
|
|
// 反例(防恒真)
|
|
// =============================================================
|
|
|
|
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);
|
|
});
|