feat(diy-card): 4.0 头像开关 + 二维码四档外观/自定义尺寸/微调位移/提示开关

- common.js: QR_STYLES 四档枚举 + normalizeQrStyle(未知回落 card) +
  resolveQrSize(自定义 px×2>档位自动 rpx) + qrTransform(位移经
  translate, WXSS 不支持 calc)
- mini.vue 基件: 头像可隐藏(默认显示, 兼容历史配置); qrBlockStyle
  统一四档外观(plain 原图/card 白卡/theme 主题色/gradient 渐变,
  多彩只装饰外围衬底, 码黑白保可扫); cornerClearStyle 按实际尺寸
  反算让位(自定义大尺寸/关 tip 自动跟随); 扫码提示可关
- show/self: 6 新字段透传; ab-visual 测试 corner 断言改锚动态让位
- 新增 qr-style 契约测试 20 项(纯函数真值 + 基件 + 透传 + 两个
  design.html 面板控件绑定与预览消费 + 反例), 9 项变异验证全抓住
- 前端四测试文件共 66 项全绿; E2E 143 项; 三回归 79/41/56 全绿
This commit is contained in:
2026-10-21 12:28:18 +08:00
parent 8bdd9f1291
commit 805bbb9598
6 changed files with 352 additions and 38 deletions

View File

@@ -166,3 +166,29 @@ export function normalizeQrPosition(v) {
}
/** 各档位的二维码边长(rpx): 头部右侧紧凑, 浮层带衬底, 行下/底部大图便于扫 */
export const QR_SIZES = { rows: 180, head: 120, corner: 120, bottom: 180 };
/**
* 二维码外观样式(四档): plain 原图 / card 白卡描边(默认) / theme 主题色底 /
* gradient 渐变描边(码芯白底衬底, 保可扫)
* 多彩体现在装饰层(主题色/渐变), 码本身保持黑白 —— 可扫性优先
*/
export const QR_STYLES = ['plain', 'card', 'theme', 'gradient'];
export function normalizeQrStyle(v) {
const s = String(v || '').trim();
return QR_STYLES.indexOf(s) >= 0 ? s : 'card';
}
/** 二维码边长: 自定义 > 0 时用自定义(px), 否则按档位自动(rpx) */
export function resolveQrSize(position, custom) {
const n = Number(custom);
if (!isNaN(n) && n > 0) return n * 2;
return QR_SIZES[position] || QR_SIZES.rows;
}
/** 微调位移: 任一轴非零才输出 transform(WXSS 支持 translate; 不支持 calc) */
export function qrTransform(offsetX, offsetY) {
const x = Number(offsetX) || 0;
const y = Number(offsetY) || 0;
if (x === 0 && y === 0) return '';
return 'translate(' + (x * 2) + 'rpx, ' + (y * 2) + 'rpx)';
}

View File

@@ -39,14 +39,14 @@
<!-- Logo(可选, 右上; corner 档二维码占同一位, 自动避让) -->
<image v-if="showLogo && card.logo" class="mini-logo" :src="$util.img(card.logo)" mode="aspectFit" />
<!-- 二维码·右上角浮层(corner 档: 白底衬底保可扫, 资料行自动让位) -->
<view v-if="showQrCorner" class="mini-qr mini-qr-corner">
<image class="qr-img" :src="$util.img(card.qr_url)" :style="qrImgStyle" mode="aspectFit" />
<text class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
<!-- 二维码·右上角浮层(corner 档: 外观由 qrStyle 决定, 资料行自动让位) -->
<view v-if="showQrCorner" class="mini-qr mini-qr-corner" :style="qrBlockStyle">
<image class="qr-img" :src="$util.img(card.qr_url)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
<view class="mini-head">
<view class="mini-avatar" :style="avatarStyle">
<view v-if="showAvatarBlock" class="mini-avatar" :style="avatarStyle">
<image v-if="card.avatar" class="avatar-img" :src="$util.img(card.avatar)" mode="aspectFill" />
<text v-else class="avatar-initial">{{ initialOf(card.name) }}</text>
</view>
@@ -59,14 +59,14 @@
<text v-if="subLine" class="mini-sub" :style="subTextStyle">{{ subLine }}</text>
</view>
<!-- 二维码·头部右侧(head 档: 头像|信息|二维码 三段 flex, 零遮挡) -->
<view v-if="showQrHead" class="mini-qr mini-qr-head">
<image class="qr-img" :src="$util.img(card.qr_url)" :style="qrImgStyle" mode="aspectFit" />
<text class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
<view v-if="showQrHead" class="mini-qr mini-qr-head" :style="qrBlockStyle">
<image class="qr-img" :src="$util.img(card.qr_url)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
</view>
<!-- 资料行(可折叠; 有字形字段显图标, 无字形退化为文字标签) -->
<view v-if="showRowsBlock && shownRows.length" class="mini-rows">
<view v-if="showRowsBlock && shownRows.length" class="mini-rows" :style="cornerClearStyle">
<view v-for="(row, i) in shownRows" :key="i" class="mini-row"
:class="{ 'row-tappable': rowTapable(row) }" :hover-class="rowTapable(row) ? 'btn-press' : 'none'" @tap="onRowTap(row)">
<text v-if="rowIcon(row)" class="row-icon iconfont" :class="rowIcon(row)" :style="labelStyle"></text>
@@ -79,13 +79,13 @@
</view>
<!-- 二维码·资料行下方(rows 档, 默认) -->
<view v-if="showQrRows" class="mini-qr">
<image class="qr-img" :src="$util.img(card.qr_url)" :style="qrImgStyle" mode="aspectFit" />
<text class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
<view v-if="showQrRows" class="mini-qr" :style="qrBlockStyle">
<image class="qr-img" :src="$util.img(card.qr_url)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
<!-- 快捷操作(芯片, 最小触达 72rpx; 首个为主动作) -->
<view v-if="showActs && acts.length" class="mini-acts">
<view v-if="showActs && acts.length" class="mini-acts" :style="cornerClearStyle">
<view v-for="(act, i) in acts" :key="i" class="act-chip"
:style="i === 0 ? actPrimaryStyle : actGhostStyle" hover-class="btn-press" @tap.stop="onAct(act)">
<text class="act-text" :style="i === 0 ? actPrimaryTextStyle : actGhostTextStyle">{{ act.label }}</text>
@@ -93,9 +93,9 @@
</view>
<!-- 二维码·主按钮上方(bottom 档) -->
<view v-if="showQrBottom" class="mini-qr">
<image class="qr-img" :src="$util.img(card.qr_url)" :style="qrImgStyle" mode="aspectFit" />
<text class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
<view v-if="showQrBottom" class="mini-qr" :style="qrBlockStyle">
<image class="qr-img" :src="$util.img(card.qr_url)" mode="aspectFit" />
<text v-if="showQrTip" class="qr-tip" :style="labelStyle">{{ qrTip || '扫码查看完整名片' }}</text>
</view>
<!-- 查看完整名片 -->
@@ -111,7 +111,7 @@
// 消费名片自身设计资产: cover 封面 / logo / theme_color / 渐变末色与角度 / card_title
import {
CardTokens, px2rpx, flagOn, num, initialOf, avatarBg, themeGradient, visibleRows,
rowIconFor, rowActOf, normalizeQrPosition, QR_SIZES,
rowIconFor, rowActOf, normalizeQrPosition, normalizeQrStyle, resolveQrSize, qrTransform, QR_SIZES,
} from './diy-card-common.js';
export default {
@@ -163,13 +163,61 @@ export default {
qrPos() {
return normalizeQrPosition(this.c.qrPosition);
},
qrStyle() {
return normalizeQrStyle(this.c.qrStyle);
},
showQrTip() {
return flagOn(this.c.qrTipShow, true);
},
showQrRows() { return this.showQrBase && this.qrPos === 'rows'; },
showQrBottom() { return this.showQrBase && this.qrPos === 'bottom'; },
showQrHead() { return this.showQrBase && this.qrPos === 'head'; },
showQrCorner() { return this.showQrBase && this.qrPos === 'corner'; },
qrImgStyle() {
const s = QR_SIZES[this.qrPos];
return this.$util.objToStyle({ width: s + 'rpx', height: s + 'rpx' });
/** 头像开关(历史配置无此字段时默认显示) */
showAvatarBlock() {
return flagOn(this.c.showAvatar, true);
},
/**
* corner 档资料行/操作区让位量: 按二维码实际占高反算
* (浮层 top 20 + 块高[尺寸+样式衬底+tip] - 头部 104 + 间隙 10), 防遮挡;
* 自定义大尺寸/关 tip 时自动跟随(不用 calc, 纯数值)
*/
cornerClearStyle() {
if (this.qrPos !== 'corner' || !this.showQrBase) return {};
const size = resolveQrSize(this.qrPos, this.c.qrSize);
const pad = this.qrStyle === 'plain' ? 0 : (this.qrStyle === 'gradient' ? 6 : 10) * 2;
const tip = this.showQrTip ? 42 : 0;
const total = 20 + size + pad + tip;
return { marginTop: Math.max(24, total - 104 + 10) + 'rpx' };
},
/**
* 二维码块样式: 尺寸(自定义>档位自动) + 微调位移(transform, WXSS 支持;
* 不用 calc) + 外观(plain 原图 / card 白卡 / theme 主题色 / gradient 渐变)
*/
qrBlockStyle() {
const size = resolveQrSize(this.qrPos, this.c.qrSize);
const style = {
width: size + 'rpx',
height: size + 'rpx',
};
const transform = qrTransform(this.c.qrOffsetX, this.c.qrOffsetY);
if (transform) style.transform = transform;
const theme = (this.card && this.card.theme_color) || CardTokens.color.primary;
if (this.qrStyle === 'card') {
style.background = '#ffffff';
style.border = '2rpx solid #EEF0F3';
style.borderRadius = '12rpx';
style.padding = '10rpx';
} else if (this.qrStyle === 'theme') {
style.background = theme;
style.borderRadius = '12rpx';
style.padding = '10rpx';
} else if (this.qrStyle === 'gradient') {
style.background = themeGradient(this.card);
style.borderRadius = '16rpx';
style.padding = '6rpx';
}
return this.$util.objToStyle(style);
},
showDetailBtn() {
return flagOn(this.c.showDetailBtn, true);
@@ -503,9 +551,10 @@ export default {
flex-direction: column;
align-items: center;
/* 块尺寸由 qrBlockStyle 内联给出(自定义/档位), 图片填满内容盒 */
.qr-img {
width: 180rpx;
height: 180rpx;
width: 100%;
height: 100%;
display: block;
}
@@ -527,29 +576,21 @@ export default {
}
}
/* corner 档: 右上角浮层, 白底衬底保证封面/纯色上都可扫 */
/* corner 档: 右上角浮层(外观衬底由 qrStyle 内联决定, 此处只定位于层次) */
&.mini-qr-corner {
position: absolute;
top: 20rpx;
right: 20rpx;
z-index: 3;
margin-top: 0;
background: #ffffff;
border-radius: 12rpx;
padding: 10rpx;
box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.12);
}
}
/* corner 档: 浮层二维码(120rpx+衬底+tip)下探约 202rpx, 资料行/操作区让位防遮挡 */
/* corner 档: 头部右侧留出浮层宽度: WXSS 不支持 calc(), 用固定 padding-right
(浮层 120rpx + 右偏移 20rpx + 呼吸 20rpx ≈ 160, 取 180 富余);
资料行/操作区让位改由 cornerClearStyle 按实际尺寸动态算 */
&.qr-at-corner {
.mini-rows,
.mini-acts {
margin-top: 108rpx;
}
/* 头部右侧留出浮层宽度: WXSS 不支持 calc(), 用固定 padding-right
(浮层 120rpx + 右偏移 20rpx + 呼吸 20rpx ≈ 160, 取 180 富余) */
.mini-head {
padding-right: 180rpx;
}

View File

@@ -90,6 +90,13 @@ export default {
showRowIcons: v.showRowIcons,
rowTap: v.rowTap,
qrPosition: v.qrPosition,
// 3.1 头像开关 + 二维码样式/微调
showAvatar: v.showAvatar,
qrStyle: v.qrStyle,
qrSize: v.qrSize,
qrOffsetX: v.qrOffsetX,
qrOffsetY: v.qrOffsetY,
qrTipShow: v.qrTipShow,
};
},
boxStyle() {

View File

@@ -52,6 +52,13 @@ export default {
showRowIcons: v.showRowIcons,
rowTap: v.rowTap,
qrPosition: v.qrPosition,
// 3.1 头像开关 + 二维码样式/微调
showAvatar: v.showAvatar,
qrStyle: v.qrStyle,
qrSize: v.qrSize,
qrOffsetX: v.qrOffsetX,
qrOffsetY: v.qrOffsetY,
qrTipShow: v.qrTipShow,
};
},
},

View File

@@ -107,12 +107,17 @@ test('mini: 二维码四档渲染分支齐全', () => {
assert.match(miniSrc, /showQrBase\(\)\s*\{\s*return flagOn\(this\.c\.showQr,\s*false\) && !!\(this\.card && this\.card\.qr_url\)/);
});
test('mini: corner 档 Logo 避让 + 资料行让位样式', () => {
test('mini: corner 档 Logo 避让 + 资料行/操作区动态让位', () => {
assert.match(miniSrc, /showLogo\(\)\s*\{[\s\S]*?this\.qrPos !== 'corner'/);
assert.match(miniSrc, /:class="\{ 'qr-at-corner': qrPos === 'corner' \}"/);
assert.match(miniSrc, /&\.qr-at-corner \{[\s\S]*?\.mini-rows,\s*\.mini-acts\s*\{\s*margin-top: 108rpx;/);
// 浮层衬底(封面/纯色上都可扫)
assert.match(miniSrc, /&\.mini-qr-corner \{[\s\S]*?background: #ffffff;/);
// 头部固定让位(头像|信息|二维码 三段 flex, 信息区不被右上浮层压)
assert.match(miniSrc, /&\.qr-at-corner \{[\s\S]*?\.mini-head\s*\{\s*padding-right: 180rpx;/);
// 资料行/操作区让位 = cornerClearStyle 动态反算(自定义大尺寸/关 tip 自动跟随, 不用 calc)
assert.match(miniSrc, /:style="cornerClearStyle"/);
// 浮层 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 不写死');
});
// =============================================================

View File

@@ -0,0 +1,228 @@
/**
* 名片 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 都按同一口径反算(自定义大尺寸时让位自动跟随)
const hits = miniSrc.match(/resolveQrSize\(this\.qrPos, this\.c\.qrSize\)/g) || [];
assert.equal(hits.length, 2, 'qrBlockStyle + cornerClearStyle 两处, 实际 ' + 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: 四个二维码块统一绑 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'));
});