/** * 名片 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(//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')) : ''; const BOX_STYLE_FIELDS = [ 'bandShow', 'bandBgMode', 'bandBg', 'wrapMarginTop', 'wrapMarginRight', 'wrapMarginBottom', 'wrapMarginLeft', 'headBgMode', 'headBg', 'headRadiusMode', 'headRadius', 'headSpacingCustom', 'headMarginTop', 'headMarginRight', 'headMarginBottom', 'headMarginLeft', 'headPaddingTop', 'headPaddingRight', 'headPaddingBottom', 'headPaddingLeft', ]; /** * 面板控件的字段绑定有三种形态(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: 顶部色带默认隐藏,背景支持同步名片设计或独立颜色', () => { assert.match(miniSrc, /class="mini-card-wrap"/); assert.match(miniSrc, /<\/view>/); assert.match(miniSrc, /bandShow\(\)\s*\{\s*return flagOn\(this\.c\.bandShow, false\)/); assert.match(miniSrc, /bandStyle\(\)\s*\{[\s\S]*?this\.c\.bandBgMode === 'custom'[\s\S]*?themeGradient\(this\.card\)/); assert.match(miniSrc, /class="mini-card fade-up" :style="wrapStyle"/); // 保留原色带造型,显隐和背景只改内联消费,不改几何结构。 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;/); assert.ok(!miniSrc.includes('mini-block')); }); test('mini: 卡片四向外边距与头部盒模型配置完整消费', () => { for (const side of ['Top', 'Right', 'Bottom', 'Left']) { assert.match(miniSrc, new RegExp('margin' + side + ': px2rpx\\(num\\(c\\.wrapMargin' + side + ', 0\\)\\)')); assert.match(miniSrc, new RegExp('c\\.headMargin' + side)); assert.match(miniSrc, new RegExp('c\\.headPadding' + side)); } assert.match(miniSrc, /headBoxStyle\(\)\s*\{[\s\S]*?c\.headBgMode === 'custom'[\s\S]*?this\.headTheme \? themeGradient\(this\.card\) : 'transparent'/); assert.match(miniSrc, /headThemeStyle\(\)\s*\{\s*return this\.headBoxStyle/); assert.match(miniSrc, /plainHeadFinalStyle\(\)\s*\{[\s\S]*?this\.cornerHeadStyle[\s\S]*?return \[this\.headBoxStyle, corner\]/); }); test('mini: C端盒模型计算值——历史默认兼容且自定义值逐项生效', async () => { const m = await import('../components-diy/diy-card-common.js'); const script = miniSrc.match(/