- 修复 P1: corner 档自定义 qrSize>120 时静态 padding-right:180rpx 让位不足, 浮层压住头部姓名区 → 新增 cornerHeadStyle 按浮层实际 宽度(尺寸+衬底+右偏移20+呼吸20)动态反算, 与 cornerClearStyle 垂直让位同口径; 废弃静态 CSS 值(负断言锁定) - 修复: self.vue 已登录首次加载三分支都不渲染(loading 且无 card) → 白屏闪烁; 改 v-else-if="loading || card" 挂上 mini 骨架屏 - 契约测试: 新增 cornerHeadStyle 断言 + resolveQrSize 三处口径 + 静态让位废弃负断言; 3 项变异验证全抓住; 四文件 67 项全绿
239 lines
12 KiB
JavaScript
239 lines
12 KiB
JavaScript
/**
|
||
* 名片 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'));
|
||
const showPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8'));
|
||
const selfPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8'));
|
||
|
||
// =============================================================
|
||
// 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');
|
||
});
|
||
|
||
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, /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 紧凑内边距
|
||
assert.match(miniSrc, /else if \(this\.qrStyle === 'gradient'\) \{\s+style\.background = themeGradient\(this\.card\);\s+style\.borderRadius = '16rpx';\s+style\.padding = '6rpx';/);
|
||
// plain 不进任何分支(无衬底, 即原图) —— 三个 if 之外不再设 background
|
||
assert.ok(!miniSrc.includes("qrStyle === 'plain' ? style.background"));
|
||
});
|
||
|
||
test('mini: 二维码尺寸取 resolveQrSize(自定义>0 优先, 否则档位自动)', () => {
|
||
// qrBlockStyle + cornerClearStyle(垂直让位) + cornerHeadStyle(水平让位) 三处同口径
|
||
// (自定义大尺寸时浮层占高与占宽都要动态跟随)
|
||
const hits = miniSrc.match(/resolveQrSize\(this\.qrPos, this\.c\.qrSize\)/g) || [];
|
||
assert.equal(hits.length, 3, 'qrBlockStyle + 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\)/);
|
||
// 四个二维码块(corner/head/rows/bottom)各有一个 tip, 都受开关控制
|
||
const tips = miniSrc.match(/<text v-if="showQrTip" class="qr-tip"/g) || [];
|
||
assert.equal(tips.length, 4, '四档二维码 tip 挂点, 实际 ' + 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 - 104 \+ 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, 4, 'corner/head/rows/bottom 四块, 实际 ' + binds.length);
|
||
});
|
||
|
||
// =============================================================
|
||
// 3. show/self 6 字段透传
|
||
// =============================================================
|
||
|
||
for (const [name, src] of [['show', showSrc], ['self', selfSrc]]) {
|
||
test(name + ': miniConfig 透传头像+二维码新六字段', () => {
|
||
assert.match(src, /showAvatar:\s*v\.showAvatar/);
|
||
assert.match(src, /qrStyle:\s*v\.qrStyle/);
|
||
assert.match(src, /qrSize:\s*v\.qrSize/);
|
||
assert.match(src, /qrOffsetX:\s*v\.qrOffsetX/);
|
||
assert.match(src, /qrOffsetY:\s*v\.qrOffsetY/);
|
||
assert.match(src, /qrTipShow:\s*v\.qrTipShow/);
|
||
});
|
||
}
|
||
|
||
// =============================================================
|
||
// 4. design.html 面板: 控件绑定 + 预览 mock 消费
|
||
// =============================================================
|
||
|
||
for (const [name, panel] of [['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('<option value="' + v + '">'), '缺选项 ' + v);
|
||
}
|
||
});
|
||
|
||
test(name + ' 面板: 预览 mock 消费新属性(属性变更预览同步)', () => {
|
||
// 头像开关
|
||
assert.ok(panel.includes('nc.showAvatar !== false'), '预览不消费 showAvatar');
|
||
// 二维码外观四档
|
||
assert.ok(panel.includes("nc.qrStyle === 'plain'"), '预览不消费 qrStyle');
|
||
// 自定义尺寸(0=自动: 三处默认 48/44/56 按档位)
|
||
assert.ok(panel.includes('nc.qrSize > 0 ? nc.qrSize : 48'), '预览不消费 qrSize(corner)');
|
||
assert.ok(panel.includes('nc.qrSize > 0 ? nc.qrSize : 44'), '预览不消费 qrSize(head)');
|
||
assert.ok(panel.includes('nc.qrSize > 0 ? nc.qrSize : 56'), '预览不消费 qrSize(rows/bottom)');
|
||
// 微调位移
|
||
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'));
|
||
});
|