Files
lucky_shop/tests/diy-card-qr-style.test.js
ZF sun a26b9d6601 feat(diy-card): 快捷操作 2.17~2.19.1 — 5 动作/图标自定义/图层式管理/素材图标库/次序调整
- common.js: ACT_GRID_ICONS 结构化{glyph|css} + save 紫档 + WAP_LINE_ICONS(SVG 线稿
  data URI, 自写 b64ascii) + CARD_ICON_LIB 素材库 30 枚(与后端三源同步) +
  normalizeActVis/ActLabels/CustomActs/ActOrder + libIconBg 实时上色
- mini.vue: 宫格图标优先级 img > 素材库 > SVG 线稿 > 首字; acts computed 重构
  (actVis 显隐过滤 + actLabels 改名 + actOrder 次序 + order 外扩展类型尾接 +
  customActs 尾接 + 四 cfg 字段合并); 操作区双位置 + 独立成块样式 + qrSrc 自定义图
- show/self.vue: doAct 补 save 分支(goSaveContact 资料白名单下发) + miniConfig
  透传 actVis/actLabels/actOrder/customActs/qrImage 等 2.17~2.19 字段
- 测试: diy-card 7 套件 111 项全绿(style-v2 22 项含 svg 快照/素材库三源同步/
  template 配平/嵌套结构反例)
2026-10-24 08:48:28 +08:00

252 lines
13 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'));
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'));
/**
* 面板控件的字段绑定有三种形态(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');
});
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\)/);
// 二维码 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. 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 + ' 面板: 头像开关 + 二维码新六字段有控件绑定', () => {
for (const f of ['showAvatar', 'qrStyle', 'qrSize', 'qrOffsetX', 'qrOffsetY', 'qrTipShow']) {
assert.ok(bound(panel, f), '面板未绑定字段 ' + f);
}
// 二维码外观四档(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'));
});