Files
lucky_shop/tests/diy-card-qr-style.test.js
jing a08d0c2026 Merge remote-tracking branch 'origin/feat/camcard' into feat/camcard
# Conflicts:
#	components-diy/diy-card-mini.vue
#	components-diy/diy-card-show.vue
#	tests/diy-card-ab-visual.test.js
#	tests/diy-card-qr-style.test.js
2026-10-24 10:56:20 +08:00

461 lines
29 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 组件 · 4.0 头像开关 + 二维码多彩/微调 契约测试
*
* 背景: 参考图复刻的第二轮增强:
* - 头像可隐藏(关闭后头部只留姓名/职位, 信息密度可调)
* - 二维码四档外观: plain 原图 / card 白卡 / theme 主题色 / gradient 渐变
* (多彩只装饰外围衬底, 码本身黑白保可扫 —— 不改共享 qr 生成端点)
* - 自定义尺寸(0=按档位自动) + transform 微调位移(WXSS 不支持 calc, 用 translate)
* - 扫码提示文案可关
*
* 覆盖:
* 1. common.js 纯函数(动态 import 真值断言, 非正则)
* 2. mini.vue 基件契约(先剥注释再断言)
* 3. show/self 6 字段透传
* 4. 两个 design.html 面板: 控件绑定 + 预览 mock 消费(属性变更预览同步)
* 5. 反例: 虚构外观/虚构字段必须查不到(防恒真)
*
* 跑法: node --test tests/diy-card-qr-style.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 PANEL_DIR = path.resolve(__dirname, '../../backend/src/addon/card/component/view');
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'));
// 面板目录属 backend 仓库, 可能不在本工作区: 缺失时跳过面板断言(组件契约照常跑)
const hasPanels = fs.existsSync(path.join(PANEL_DIR, 'diy-card-self/design.html'));
const showPanel = hasPanels ? strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8')) : '';
const selfPanel = hasPanels ? strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8')) : '';
/**
* 面板控件的字段绑定有三种形态(2026-10-21 面板改版: 下拉框 → 可视化选项组,
* 数字框 → 滑块):
* ① v-model="nc.X" 输入框 / 开关
* ② nc.X = 'v' 可视化选项组(芯片)的赋值绑定
* ③ field: 'X' 滑块控件 :data="{field:'X'}"
* 断言的是「这个字段确实被某个控件绑定了」, 不限定控件形态 —— 契约是字段名, 不是控件类型。
*/
const bound = (src, field) => new RegExp(
'v-model(\\.[a-z]+)?="nc\\.' + field + '"'
+ '|\\bnc\\.' + field + "\\s*=\\s*['\"]"
+ '|field\\s*:\\s*[\'"]' + field + '[\'"]'
).test(src);
// =============================================================
// 1. common.js 纯函数(真值断言)
// =============================================================
test('common: QR_STYLES 四档外观枚举', async () => {
const m = await import('../components-diy/diy-card-common.js');
assert.deepEqual(m.QR_STYLES, ['plain', 'card', 'theme', 'gradient']);
});
test('common: normalizeQrStyle 合法直通, 未知/空回落 card(默认白卡)', async () => {
const m = await import('../components-diy/diy-card-common.js');
for (const s of ['plain', 'card', 'theme', 'gradient']) {
assert.equal(m.normalizeQrStyle(s), s);
}
// 未配置(存量装修页无此字段) → card, 与 diy_view.php 默认值同口径
assert.equal(m.normalizeQrStyle(''), 'card');
assert.equal(m.normalizeQrStyle(undefined), 'card');
assert.equal(m.normalizeQrStyle(null), 'card');
assert.equal(m.normalizeQrStyle('rainbow'), 'card');
// 面板只下发小写档位; 大写属未知值, 回落 card(保守, 不炸)
assert.equal(m.normalizeQrStyle('CARD'), 'card');
// 中文档位名别名(面板下拉若直接存中文档名, 归一化兜住, 否则 C 端永远回落 card)
assert.equal(m.normalizeQrStyle('原图'), 'plain');
assert.equal(m.normalizeQrStyle('白卡描边'), 'card');
assert.equal(m.normalizeQrStyle('主题色衬底'), 'theme');
assert.equal(m.normalizeQrStyle('渐变圆角'), 'gradient');
});
test('common: normalizeQrPosition 位置档中文别名兼容', async () => {
const m = await import('../components-diy/diy-card-common.js');
assert.equal(m.normalizeQrPosition('资料行下方'), 'rows');
assert.equal(m.normalizeQrPosition('头部右侧'), 'head');
assert.equal(m.normalizeQrPosition('右上角浮层'), 'corner');
assert.equal(m.normalizeQrPosition('主按钮上方'), 'bottom');
// 英文码直通 / 未知回落 rows
assert.equal(m.normalizeQrPosition('bottom'), 'bottom');
assert.equal(m.normalizeQrPosition('nope'), 'rows');
});
test('common: darkenColor 压暗生成可见渐变终止色', async () => {
const m = await import('../components-diy/diy-card-common.js');
// 纯色压暗 0.18: #119dff → #0e81d1
assert.equal(m.darkenColor('#119dff', 0.18), '#0e81d1');
// 比例越大约暗
assert.notEqual(m.darkenColor('#119dff', 0.4), m.darkenColor('#119dff', 0.18));
// 非法输入原样返回(不影响既有渲染)
assert.equal(m.darkenColor('#12', 0.18), '#12');
assert.equal(m.darkenColor('rgb(1,2,3)', 0.18), 'rgb(1,2,3)');
});
test('common: rowIconFor 资料行图标字段映射(含方案1邮箱预留 icon-youxiang)', async () => {
const m = await import('../components-diy/diy-card-common.js');
// 有字形的字段返回 iconfont 类
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 预留 icon-youxiang(需字库补充信封字形后方能显示), 多键名兜底
assert.equal(m.rowIconFor('email'), 'icon-youxiang');
assert.equal(m.rowIconFor('mail'), 'icon-youxiang');
assert.equal(m.rowIconFor('邮箱'), 'icon-youxiang');
// 网站(2026-10-22): 复用字库现成链条字形 icon-fuzhilianjie, 多键名兜底
assert.equal(m.rowIconFor('website'), 'icon-fuzhilianjie');
assert.equal(m.rowIconFor('link'), 'icon-fuzhilianjie');
assert.equal(m.rowIconFor('网站'), 'icon-fuzhilianjie');
assert.equal(m.rowIconFor('custom'), '');
assert.equal(m.rowIconFor(''), '');
});
test('common: iconfont.css 邮箱信封 SVG 覆盖(字库无字形期的降级实现)', () => {
const iconSrc = fs.readFileSync(path.resolve(__dirname, '../common/css/iconfont.css'), 'utf8');
// 覆盖规则存在且 content 置空(避免渲染豆腐块), 尺寸/对齐与 row-icon 槽位匹配
assert.match(iconSrc, /\.icon-youxiang:before \{[\s\S]*?content: "";[\s\S]*?width: 30rpx;[\s\S]*?height: 30rpx;/);
// 信封 SVG data-uri(描边风格, #999999 与资料行标签默认色一致)
assert.match(iconSrc, /\.icon-youxiang:before \{[\s\S]*?data:image\/svg\+xml,[\s\S]*?stroke='%23999999'[\s\S]*?%3E%3Crect x='2' y='4' width='20' height='16'[\s\S]*?center\/contain no-repeat/);
});
test('common: resolveQrSize 自定义>档位自动, 单位 px→rpx(×2)', async () => {
const m = await import('../components-diy/diy-card-common.js');
// 自定义尺寸(面板 px) × 2 = rpx
assert.equal(m.resolveQrSize('rows', 120), 240);
assert.equal(m.resolveQrSize('corner', 60), 120);
// 0 / 未配置 / 非法 → 按档位自动
assert.equal(m.resolveQrSize('rows', 0), m.QR_SIZES.rows);
assert.equal(m.resolveQrSize('head', 0), m.QR_SIZES.head);
assert.equal(m.resolveQrSize('corner', 0), m.QR_SIZES.corner);
assert.equal(m.resolveQrSize('bottom', 0), m.QR_SIZES.bottom);
assert.equal(m.resolveQrSize('rows', undefined), m.QR_SIZES.rows);
assert.equal(m.resolveQrSize('rows', -30), m.QR_SIZES.rows);
assert.equal(m.resolveQrSize('rows', 'abc'), m.QR_SIZES.rows);
// 未知档位 → rows 兜底
assert.equal(m.resolveQrSize('sideways', 0), m.QR_SIZES.rows);
// 字符串数字也要认(面板可能给字符串)
assert.equal(m.resolveQrSize('rows', '120'), 240);
});
test('common: qrTransform 位移(任一轴非零才输出; WXSS 支持 translate)', async () => {
const m = await import('../components-diy/diy-card-common.js');
assert.equal(m.qrTransform(0, 0), '');
assert.equal(m.qrTransform(undefined, undefined), '');
assert.equal(m.qrTransform(null, null), '');
assert.equal(m.qrTransform('', ''), '');
// px × 2 = rpx
assert.equal(m.qrTransform(10, -20), 'translate(20rpx, -40rpx)');
assert.equal(m.qrTransform(5, 0), 'translate(10rpx, 0rpx)');
assert.equal(m.qrTransform(0, 5), 'translate(0rpx, 10rpx)');
assert.equal(m.qrTransform(-50, 50), 'translate(-100rpx, 100rpx)');
// 字符串数字(面板 number 输入可能给字符串)
assert.equal(m.qrTransform('10', '20'), 'translate(20rpx, 40rpx)');
// 非数字 → 0
assert.equal(m.qrTransform('abc', 'xyz'), '');
});
// =============================================================
// 2. mini.vue 基件契约
// =============================================================
test('mini: 蓝色顶带版式(2026-10-23 与精选名片 show 同款: 自适应白卡高度 + 弧线距底 88rpx)', () => {
// 模板: 卡片包进 wrap, 顶带在最前
assert.match(miniSrc, /class="mini-card-wrap"/);
assert.match(miniSrc, /class="mini-band" :style="bandStyle"/);
assert.match(miniSrc, /class="mini-card fade-up" :style="wrapStyle"/);
// 计算属性: 顶带底色走主题渐变(与 show bandStyle 同口径)
assert.match(miniSrc, /bandStyle\(\)\s*\{\s*return this\.\$util\.objToStyle\(\{\s*background: themeGradient\(this\.card\)\s*\}\);/);
// CSS: wrap 撑露沿 + band 拉伸(top:-40rpx / bottom:88rpx) + 圆角(顶直底弧)
assert.match(miniSrc, /\.mini-card-wrap \{\s*position: relative;\s*margin-top: 40rpx;/);
assert.match(miniSrc, /\.mini-band \{\s*position: absolute;[\s\S]*?top: -40rpx;[\s\S]*?bottom: 88rpx;[\s\S]*?border-radius: 0 0 28rpx 28rpx;/);
// mini-block 方案不得回归
assert.ok(!miniSrc.includes('mini-block'));
});
test('mini: 企业徽标固定红底(参考图口径, 与头像底色解耦)', () => {
assert.match(miniSrc, /badgeStyle\(\)\s*\{\s*return this\.\$util\.objToStyle\(\{\s*background: '#FA5151'\s*\}\);/);
});
test('mini: 头像开关(默认显示, 关掉只留姓名/职位)', () => {
assert.match(miniSrc, /showAvatarBlock\(\)\s*\{\s*return flagOn\(this\.c\.showAvatar,\s*true\)/);
// 模板头像块受开关控制(无 v-else 占位 —— 关掉即消失, 头部信息自然左移)
assert.match(miniSrc, /<view v-if="showAvatarBlock" class="mini-avatar" :style="avatarStyle">/);
// 反例形态: 不允许出现常显头像(绕过开关)
assert.ok(!miniSrc.includes('<view class="mini-avatar"'));
});
test('mini: qrStyle 计算属性归一化', () => {
assert.match(miniSrc, /qrStyle\(\)\s*\{\s*return normalizeQrStyle\(this\.c\.qrStyle\)/);
});
test('mini: qrBlockStyle 四档外观分支(多彩只装饰外围, 码黑白保扫)', () => {
// card 白卡描边(分支内逐行收紧, 防跨分支 lazy 匹配造成假过)
assert.match(miniSrc, /if \(this\.qrStyle === 'card'\) \{\s+style\.background = '#ffffff';\s+style\.border = '2rpx solid #EEF0F3';\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/);
// theme 主题色衬底
assert.match(miniSrc, /else if \(this\.qrStyle === 'theme'\) \{\s+style\.background = theme;\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/);
// gradient 渐变 + 6rpx 紧凑内边距; 单主题色用 darkenColor 兜底终止色(可见明暗)
assert.match(miniSrc, /else if \(this\.qrStyle === 'gradient'\) \{\s+const end = \(this\.card && this\.card\.gradient_end\) \|\| darkenColor\(theme, 0\.18\);\s+style\.background = 'linear-gradient\(135deg, ' \+ theme \+ ' 0%, ' \+ end \+ ' 100%\)';\s+style\.borderRadius = '16rpx';\s+style\.padding = '6rpx';/);
// plain 不进任何分支(无衬底, 即原图) —— 三个 if 之外不再设 background
assert.ok(!miniSrc.includes("qrStyle === 'plain' ? style.background"));
// 2026-10-23 用户定稿: 外观衬底只包二维码图(qrImgStyle), 「扫码查看完整名片」tip 在衬底外无蓝底
assert.match(miniSrc, /class="qr-img" :style="qrImgStyle"/);
const tipBinds = miniSrc.match(/class="qr-tip" :style="labelStyle"/g) || [];
assert.equal(tipBinds.length, 5, '五档 tip 仍独立(不进衬底; 含 theme 头部块), 实际 ' + tipBinds.length);
});
test('mini: 二维码尺寸取 resolveQrSize(自定义>0 优先, 否则档位自动)', () => {
// qrBlockStyle(容器) + qrImgStyle(图, 外观衬底) + cornerClearStyle(垂直让位) + cornerHeadStyle(水平让位)
// (自定义大尺寸时浮层占高与占宽都要动态跟随)
const hits = miniSrc.match(/resolveQrSize\(this\.qrPos, this\.c\.qrSize\)/g) || [];
assert.equal(hits.length, 4, 'qrBlockStyle + qrImgStyle + cornerClearStyle + cornerHeadStyle 四处, 实际 ' + hits.length);
});
test('mini: 微调位移 transform(不用 calc, WXSS 兼容)', () => {
assert.match(miniSrc, /const transform = qrTransform\(this\.c\.qrOffsetX, this\.c\.qrOffsetY\);\s*if \(transform\) style\.transform = transform;/);
// 位移零值不输出 transform(不污染默认布局)
assert.match(miniSrc, /if \(transform\) style\.transform = transform;/);
});
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)
const tips = miniSrc.match(/<text v-if="showQrTip" class="qr-tip"/g) || [];
assert.equal(tips.length, 5, 'tip 挂点(4 档 + head 双分支各一), 实际 ' + tips.length);
});
test('mini: cornerClearStyle 按实际尺寸反算让位(防遮挡, 纯数值不用 calc)', () => {
assert.match(miniSrc, /cornerClearStyle\(\)\s*\{[\s\S]*?if \(this\.qrPos !== 'corner' \|\| !this\.showQrBase\) return \{\};/);
// 让位量 = 浮层 top 20 + 块高[尺寸+衬底+tip] - 头部 104 + 间隙 10; 下限 24
assert.match(miniSrc, /Math\.max\(24, total - headHeight \+ 10\)/);
// 衬底与 tip 参与计算(关 tip 时让位自动收窄)
assert.match(miniSrc, /const tip = this\.showQrTip \? 42 : 0;/);
});
test('mini: cornerHeadStyle 头部水平让位按浮层实际宽度动态反算', () => {
// 审计修复: 静态 180rpx 在自定义 qrSize>120 时不足 → 头部让位动态化
assert.match(miniSrc, /cornerHeadStyle\(\)\s*\{[\s\S]*?if \(this\.qrPos !== 'corner' \|\| !this\.showQrBase\) return \{\};/);
// 浮层宽[尺寸+衬底] + 右偏移 20 + 呼吸 20
assert.match(miniSrc, /paddingRight: \(size \+ pad \+ 40\) \+ 'rpx'/);
// 模板头部绑定了动态让位(浮层不存在时输出空对象不占位)
assert.match(miniSrc, /<view class="mini-head" :style="cornerHeadStyle">/);
});
test('mini: 四个二维码块统一绑 qrBlockStyle(尺寸/位移/外观单一事实来源)', () => {
const binds = miniSrc.match(/:style="qrBlockStyle"/g) || [];
assert.equal(binds.length, 5, 'qrBlockStyle 绑定(corner/rows/bottom 各 1 + head 双分支各一), 实际 ' + binds.length);
});
// =============================================================
// 3. self miniConfig 透传 / show 自绘版式二维码契约
// =============================================================
test('self: miniConfig 透传头像+二维码新六字段', () => {
assert.match(selfSrc, /showAvatar:\s*v\.showAvatar/);
assert.match(selfSrc, /qrStyle:\s*v\.qrStyle/);
assert.match(selfSrc, /qrSize:\s*v\.qrSize/);
assert.match(selfSrc, /qrOffsetX:\s*v\.qrOffsetX/);
assert.match(selfSrc, /qrOffsetY:\s*v\.qrOffsetY/);
assert.match(selfSrc, /qrTipShow:\s*v\.qrTipShow/);
});
test('self: 「我的名片」角标与未登录提示条均已移除(2026-10-23 用户定稿)', () => {
// 角标按 2026-10-23 参考图删除, 反例防回归
assert.ok(!selfSrc.includes('self-badge') && !selfSrc.includes('slot="badge"'));
// 未登录提示条(「登录后即可展示我的名片·去看看名片」)按用户要求整条移除, 反例防回归
assert.ok(!selfSrc.includes('self-login-bar') && !selfSrc.includes('loginBarTip'));
// 空态分支接管提示条删除后的条件(非引导模式 + 无卡 + 非加载)
assert.match(selfSrc, /v-else-if="!loading && !card" class="self-empty"/);
});
test('show: 头像开关(面板「显示头像」, 默认显示, 关后头部只留姓名/职位)', () => {
assert.match(showSrc, /showAvatarBlock\(\)\s*\{\s*return flagOn\(this\.c\.showAvatar,\s*true\)/);
assert.match(showSrc, /<view v-if="showAvatarBlock" class="card-avatar" :style="avatarStyle">/);
// 反例形态: 不允许出现常显头像(绕过开关)
assert.ok(!showSrc.includes('<view class="card-avatar"'));
});
test('show: 快捷操作开关(面板「显示快捷操作」, 默认显示, 关后芯片整块隐藏)', () => {
assert.match(showSrc, /showActsBlock\(\)\s*\{\s*return flagOn\(this\.c\.showQuick,\s*true\)/);
assert.match(showSrc, /v-if="showActsBlock && acts\.length" class="card-acts"/);
// 芯片点击直连 doAct(自绘版式不再经 $emit('act') 中转)
assert.match(showSrc, /@tap\.stop="doAct\(act\)"/);
});
test('show: 二维码位置四档(与面板下拉/mini 基件同口径, 未配置回落 rows)', () => {
assert.match(showSrc, /qrPos\(\)\s*\{\s*return normalizeQrPosition\(this\.c\.qrPosition\)/);
// 四档各自的条件渲染块
assert.match(showSrc, /showQrHead\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'head';\s*\}/);
assert.match(showSrc, /showQrRows\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'rows';\s*\}/);
assert.match(showSrc, /showQrCorner\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'corner';\s*\}/);
assert.match(showSrc, /showQrBottom\(\)\s*\{\s*return this\.showQrBase && this\.qrPos === 'bottom';\s*\}/);
// 模板四个位置都有落点(头部/资料行下/浮层/主按钮上方)
assert.match(showSrc, /v-if="showQrHead" class="card-qr"/);
assert.match(showSrc, /v-if="showQrRows" class="qr-center"/);
assert.match(showSrc, /v-if="showQrCorner" class="card-qr-corner"/);
assert.match(showSrc, /v-if="showQrBottom" class="qr-center qr-center-bottom"/);
// 微调位移(qrOffsetX/Y → transform)
assert.match(showSrc, /qrTransform\(this\.c\.qrOffsetX,\s*this\.c\.qrOffsetY\)/);
});
test('show: 封面图作背景(面板「名片设计资产 → 封面图作背景」, 默认关, 未配封面图不显示)', () => { assert.match(showSrc, /showCover\(\)\s*\{\s*return flagOn\(this\.c\.useCover,\s*false\) && !!\(this\.card && this\.card\.cover\)/);
// 开启后白卡底变透明, 封面图铺满整卡 + 压暗遮罩(mini 基件同款参数)
assert.match(showSrc, /background:\s*this\.showCover \? 'transparent' : \(c\.bgColor \|\| CardTokens\.color\.bg\)/);
assert.match(showSrc, /<image v-if="showCover" class="card-cover" :src="\$util\.img\(card\.cover\)" mode="aspectFill" \/>/);
assert.match(showSrc, /<view v-if="showCover" class="card-cover-scrim"><\/view>/);
assert.match(showSrc, /background: rgba\(15, 23, 42, 0\.45\);/);
});
test('show: 主题渐变饰条(面板「名片设计资产 → 主题渐变饰条」, 默认关)', () => {
assert.match(showSrc, /showGradBar\(\)\s*\{\s*return flagOn\(this\.c\.showGradient,\s*false\)/);
// 饰条底色走名片主题渐变
assert.match(showSrc, /gradBarStyle\(\)\s*\{\s*return this\.\$util\.objToStyle\(\{\s*background: themeGradient\(this\.card\),\s*\}\);/);
// 模板: 卡片顶部横条 + 细高圆角样式
assert.match(showSrc, /<view v-if="showGradBar" class="card-grad-bar" :style="gradBarStyle"><\/view>/);
assert.match(showSrc, /\.card-grad-bar \{[\s\S]*?height: 14rpx;[\s\S]*?border-radius: 8rpx;/);
});
test('show: 名片 Logo 开关(面板「名片设计资产 → 名片 Logo」, 默认关; 未配 Logo 不显示; 头部档二维码同位避让)', () => {
assert.match(showSrc, /showLogo\(\)\s*\{\s*return flagOn\(this\.c\.showLogo,\s*false\) && !!\(this\.card && this\.card\.logo\) && !this\.showQrHead;/);
// 模板: 头部右侧 Logo 图
assert.match(showSrc, /<image v-if="showLogo" class="card-logo" :src="\$util\.img\(card\.logo\)" mode="aspectFit" \/>/);
// 样式: 头部右侧小图, margin-left:auto 推到行尾
assert.match(showSrc, /\.card-logo \{[\s\S]*?width: 96rpx;[\s\S]*?margin-left: auto;/);
});
test('show: 资料行图标开关(面板「名片信息展示 → 资料行图标」, 默认开; 关后退化文字标签)', () => {
// 与 mini 基件 rowIcon 同口径: 开关 + 字段字形双重判定
assert.match(showSrc, /rowIcon\(row\)\s*\{\s*return flagOn\(this\.c\.showRowIcons,\s*true\) \? rowIconFor\(row && row\.field\) : '';/);
// 模板: 有图标显图标, 无图标退化文字标签
assert.match(showSrc, /<text v-if="rowIcon\(row\)" class="row-icon iconfont" :class="rowIcon\(row\)" :style="labelStyle"><\/text>/);
assert.match(showSrc, /<text v-else class="row-label" :style="labelStyle">\{\{ row\.label \}\}<\/text>/);
});
test('show: 发名片按钮开关(面板「底部按钮 → 发送名片按钮」, 默认显示; 文案消费 detailBtnText)', () => {
assert.match(showSrc, /showSendBtn\(\)\s*\{\s*return flagOn\(this\.c\.showDetailBtn,\s*true\)/);
assert.match(showSrc, /v-if="showSendBtn" class="show-send"/);
// 点击弹分享抽屉(与二维码块同款 openCardShare, 2026-10-22 起)
assert.match(showSrc, /@tap="openCardShare">\s*<text class="show-send-text">/);
assert.ok(!showSrc.includes('onSend'));
// 按钮文案: 面板「按钮文案」可配, 默认「发送名片」
assert.match(showSrc, /sendBtnText\(\)\s*\{\s*return this\.c\.detailBtnText \|\| '发送名片';/);
assert.match(showSrc, /<text class="show-send-text">\{\{ sendBtnText \}\}<\/text>/);
// 反例形态: 不允许硬编码文案(绕过面板配置)
assert.ok(!showSrc.includes('<text class="show-send-text">发名片</text>'));
});
test('show: 二维码外观四档(plain 原图 / card 白卡描边 / theme 主题色 / gradient 渐变, 与 mini 同口径)', () => {
assert.match(showSrc, /qrStyle\(\)\s*\{\s*return normalizeQrStyle\(this\.c\.qrStyle\)/);
// 四个分支逐行收紧, 防跨分支 lazy 匹配造成假过(与 mini qrBlockStyle 同款值)
assert.match(showSrc, /if \(this\.qrStyle === 'card'\) \{\s+style\.background = '#ffffff';\s+style\.border = '2rpx solid #EEF0F3';\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/);
assert.match(showSrc, /else if \(this\.qrStyle === 'theme'\) \{\s+style\.background = theme;\s+style\.borderRadius = '12rpx';\s+style\.padding = '10rpx';/);
// gradient 分支: 单主题色时也要可见明暗渐变(用 darkenColor 兜底终止色), 不能退化成纯色
// (2026-10-23 用户定稿: 外观分支移到 qrImgStyle, theme 在顶部声明一次)
assert.match(showSrc, /else if \(this\.qrStyle === 'gradient'\) \{\s+const end = \(this\.card && this\.card\.gradient_end\) \|\| darkenColor\(theme, 0\.18\);\s+style\.background = 'linear-gradient\(135deg, ' \+ theme \+ ' 0%, ' \+ end \+ ' 100%\)';\s+style\.borderRadius = '16rpx';\s+style\.padding = '6rpx';/);
// plain 不进任何分支(无衬底, 即原图)
assert.ok(!showSrc.includes("qrStyle === 'plain' ? style.background"));
// 衬底只包二维码图(qrImgStyle 绑在 image 上), 「扫码查看完整名片」tip 在衬底外(蓝底不包文字)
assert.equal((showSrc.match(/class="qr-img(-c)?" :style="qrImgStyle"/g) || []).length, 4, '四档 image 均绑 qrImgStyle');
// 四个位置档共用 qrBlockStyle(单字符串绑定; 仅剩微调位移)
assert.equal((showSrc.match(/:style="qrBlockStyle"/g) || []).length, 4);
// 反例: uni-app 运行时静默丢弃 :style="[字符串, 字符串]" 数组绑定
// (2026-10-22 线上问题: 微调/外观在 C 端全部失效的根因), 禁止回归
assert.ok(!showSrc.includes(':style="[qrBlockStyle'));
// 微调位移并入 qrBlockStyle 同一对象(qrTransform 引用仍在计算属性内)
assert.match(showSrc, /const t = qrTransform\(this\.c\.qrOffsetX,\s*this\.c\.qrOffsetY\);\s*if \(t\) style\.transform = t;/);
});
test('show: 自绘版式自带小二维码(开关可关 + 点击进详情, 尺寸/提示文字面板可调)', () => {
assert.match(showSrc, /class="card-qr"/);
assert.match(showSrc, /\$util\.img\(card\.qr_url\)/);
// 二维码块受面板「显示二维码」开关控制(与头像/资料行/快捷操作同口径: flagOn 默认显示)
assert.match(showSrc, /showQrBlock\(\)\s*\{\s*return flagOn\(this\.c\.showQr,\s*true\)/);
// 开关 + 位置统一收敛在 showQrBase / showQrHead 等分档计算属性里(模板只消费分档结果)
assert.match(showSrc, /showQrBase\(\)\s*\{\s*return this\.showQrBlock && !!\(this\.card && this\.card\.qr_url\)/);
// 提示文字消费面板 qrTip, 未配置回落「扫码查看完整名片」(与 mini 基件同口径)
assert.match(showSrc, /qrTipText\(\)\s*\{\s*return this\.c\.qrTip \|\| '扫码查看完整名片';/);
// 点二维码弹「分享名片」抽屉(复刻 detail.vue: 码 + 链接 + 复制链接/浏览器分享/保存二维码)
assert.match(showSrc, /@tap\.stop="openCardShare"/);
assert.match(showSrc, /v-if="showShare" @tap="closeShare"/);
assert.match(showSrc, /class="cs-sheet" :class="\{ 'cs-sheet-show': showShare \}"/);
assert.match(showSrc, /<view class="ss-title">分享名片<\/view>/);
assert.match(showSrc, /客户打开链接即可查看名片并留言留资/);
assert.match(showSrc, /copyCardLink\(\)\s*\{\s*const url = \(this\.card && this\.card\.wap_url\) \|\| '';/);
// 尺寸走 resolveQrSize: 面板 qrSize>0 用自定义, 0/空 = 按位置档位自动(rows/bottom 180, head/corner 120)。
// 反例形态: 不允许 num(qrSize, 48) 兜底 —— 面板默认 0 是合法数字, 会算出 0rpx 宽高,
// 二维码图渲染成 0 尺寸「看不见」(2026-10-21 线上问题)
assert.match(showSrc, /qrSide\(\)\s*\{\s*return resolveQrSize\(this\.qrPos,\s*this\.c\.qrSize\)/);
assert.ok(!showSrc.includes("num(this.c.qrSize"));
});
// =============================================================
// 4. design.html 面板: 控件绑定 + 预览 mock 消费
// =============================================================
for (const [name, panel] of hasPanels ? [['show', showPanel], ['self', selfPanel]] : []) {
test(name + ' 面板: 头像开关 + 二维码新六字段有 v-model 绑定', () => {
assert.match(panel, /v-model="nc\.showAvatar"/);
assert.match(panel, /v-model="nc\.qrStyle"/);
assert.match(panel, /v-model\.number="nc\.qrSize"/);
assert.match(panel, /v-model\.number="nc\.qrOffsetX"/);
assert.match(panel, /v-model\.number="nc\.qrOffsetY"/);
assert.match(panel, /v-model="nc\.qrTipShow"/);
// 二维码外观四选项(plain/card/theme/gradient)
for (const v of ['plain', 'card', 'theme', 'gradient']) {
assert.ok(
panel.includes("nc.qrStyle = '" + v + "'") || panel.includes('<option value="' + v + '">'),
'缺选项 ' + v
);
}
});
test(name + ' 面板: 预览 mock 消费新属性(属性变更预览同步)', () => {
// 头像开关
assert.ok(panel.includes('nc.showAvatar !== false'), '预览不消费 showAvatar');
// 二维码外观四档
assert.ok(panel.includes("nc.qrStyle === 'plain'"), '预览不消费 qrStyle');
// 自定义尺寸: 只锁「自定义值优先于档位自动」这层语义, 不锁死具体像素值
// (档位默认值是可调的设计参数, 锁死会让改一次尺寸就得改一次测试)
assert.ok(panel.includes('nc.qrSize > 0 ? nc.qrSize'), '预览不消费 qrSize');
// 微调位移
assert.ok(panel.includes('nc.qrOffsetX || 0'), '预览不消费 qrOffsetX');
assert.ok(panel.includes('nc.qrOffsetY || 0'), '预览不消费 qrOffsetY');
// 提示开关
assert.ok(panel.includes('nc.qrTipShow !== false'), '预览不消费 qrTipShow');
});
}
// =============================================================
// 反例(防恒真)
// =============================================================
test('反例: 不存在第五档二维码外观', () => {
assert.ok(!miniSrc.includes("qrStyle === 'rainbow'"));
assert.ok(!miniSrc.includes("qrStyle === 'neon'"));
// 面板同理
assert.ok(!showPanel.includes("nc.qrStyle == 'rainbow'"));
assert.ok(!selfPanel.includes("nc.qrStyle == 'rainbow'"));
});
test('反例: 虚构字段绝不出现在面板/基件', () => {
assert.ok(!showPanel.includes('nc.qrMagic'));
assert.ok(!selfPanel.includes('nc.qrMagic'));
assert.ok(!miniSrc.includes('c.qrMagic'));
assert.ok(!showSrc.includes('v.qrMagic'));
assert.ok(!selfSrc.includes('v.qrMagic'));
});