Files
lucky_shop/tests/diy-card-font-size.test.js
ZF sun 0c0a6a1cb8 feat(card-diy): 四组件字号定制化消费 + 契约测试(12 项)
- entry: 主/副标题与按钮字号内联消费(titleSize/subSize/btnSize, px→rpx ×2),
  文字箭头拆 btnArrowStyle(比按钮大一档=28rpx)
- list: 拆 gridNameStyle/scrollNameStyle/metaStyle/scrollMetaStyle, 横滑与
  人数徽标按 -1px 派生保持历史渲染; infoStyle 保持纯色(箭头/空态共用)
- mini(Show/Self 基件): name/cardTitle/sub/label/value/act/btn 七项字号内联,
  拆 cardTitleStyle/rowLabelStyle/detailTextStyle; labelStyle 保持纯色给图标/qr tip
- self: miniConfig 透传 7 个字号字段
- 新增 tests/diy-card-font-size.test.js: 三层口径契约(diy_view 默认值 ↔
  design.html 面板/重置 ↔ vue 消费) + 派生关系 + 颜色 value 兜底 + 反例自测

验证: 契约 12/12 绿; diy-card 全套测试 0 失败; E2E 截图目检通过
2026-10-23 14:21:36 +08:00

216 lines
11 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 组件 · 字号定制化三层口径契约测试
*
* 锁死: diy_view.php 默认值 ↔ design.html 面板(slide+预览消费) ↔ C 端 vue 消费
* 口径: 字号字段一律 px 配置, C 端渲染 ×2 转 rpx(px2rpx); 默认值 = 历史硬编码值
*
* 派生关系(保证默认渲染与历史一致):
* - list 横滑竖卡: 姓名/信息比网格小一档(-1px)
* - list 人数徽标: 比信息行小一档(-1px); 「查看全部」= 信息字号
* - entry 文字箭头的箭头: 比按钮文字大一档(+2px)
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BE = resolve(__dirname, '../../backend/src/addon/card');
const FE = resolve(__dirname, '../components-diy');
/** 三层口径定义: 字段 → [默认值(px), 消费侧期望字面量] */
const CONTRACT = {
entry: {
defaults: { titleSize: 16, subSize: 12, btnSize: 12 },
colors: { bgColorStart: '#2F6BFF', bgColorEnd: '#1E40AF', textColor: '#FFFFFF', accentColor: '#FFEB3B' },
layout: { borderRadius: 12, padding: 16 },
vueRead: 'this.v.',
vueFile: 'diy-card-entry.vue',
},
list: {
defaults: { titleSize: 16, nameSize: 14, infoSize: 11 },
colors: { bgColor: '#FFFFFF', titleColor: '#333333', nameColor: '#1F2329', infoColor: '#888888' },
layout: { borderRadius: 12, padding: 12, gap: 10 },
vueRead: 'this.v.',
vueFile: 'diy-card-list.vue',
},
show: {
defaults: { nameSize: 17, cardTitleSize: 13, subSize: 12, labelSize: 12, valueSize: 12, actSize: 12, btnSize: 13 },
colors: { bgColor: '#FFFFFF', nameColor: '#1F2329', infoColor: '#888888', labelColor: '#999999' },
layout: { borderRadius: 16, padding: 16 },
vueRead: 'this.c.',
vueFile: 'diy-card-mini.vue',
},
self: {
defaults: { nameSize: 17, cardTitleSize: 13, subSize: 12, labelSize: 12, valueSize: 12, actSize: 12, btnSize: 13 },
colors: { bgColor: '#FFFFFF', nameColor: '#1F2329', infoColor: '#888888', labelColor: '#999999' },
layout: { borderRadius: 16, padding: 16 },
vueRead: 'this.c.',
vueFile: 'diy-card-mini.vue',
},
};
/** 从 diy_view.php 提取组件 value JSON(容忍单引号 PHP 串内的转义) */
const PHP_NAMES = { entry: 'DiyCardEntry', list: 'DiyCardList', show: 'DiyCardShow', self: 'DiyCardSelf' };
function readValueJson(component) {
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
const re = new RegExp("'name'\\s*=>\\s*'" + PHP_NAMES[component] + "'[\\s\\S]*?'value'\\s*=>\\s*'([^\']+)'");
const m = php.match(re);
assert.ok(m, 'diy_view.php 中找到组件 ' + PHP_NAMES[component]);
return JSON.parse(m[1]);
}
test('diy_view.php 四组件默认值包含全部字号字段且等于历史渲染值', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const value = readValueJson(comp);
for (const [field, dft] of Object.entries(cfg.defaults)) {
assert.equal(Number(value[field]), dft, comp + '.' + field + ' 默认值应为 ' + dft);
}
}
});
test('diy_view.php 配色/圆角间距默认值与契约一致(重置按钮的最终事实来源)', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const value = readValueJson(comp);
for (const [field, dft] of Object.entries(cfg.colors)) {
assert.equal(String(value[field]).toUpperCase(), dft.toUpperCase(), comp + '.' + field + ' 颜色默认值应为 ' + dft);
}
for (const [field, dft] of Object.entries(cfg.layout)) {
assert.equal(Number(value[field]), dft, comp + '.' + field + ' 默认值应为 ' + dft);
}
}
});
test('design.html 样式面板提供一键恢复默认, 重置字段与默认值逐一对应', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
assert.ok(html.includes('diy-card-reset'), comp + ' 样式面板缺少恢复默认按钮');
const btn = html.match(/<button type="button" class="diy-card-reset" @click="([^"]+)"/);
assert.ok(btn, comp + ' 重置按钮缺少内联 @click 赋值');
const click = btn[1];
// 字号(px 数值)
for (const [field, dft] of Object.entries(cfg.defaults)) {
assert.ok(click.includes('nc.' + field + '=' + dft), comp + ' 重置缺少 ' + field + '=' + dft);
}
// 颜色(带引号)
for (const [field, dft] of Object.entries(cfg.colors)) {
assert.ok(click.includes("nc." + field + "='" + dft + "'"), comp + ' 重置缺少 ' + field + "='" + dft + "'");
}
// 圆角/间距(px 数值)
for (const [field, dft] of Object.entries(cfg.layout)) {
assert.ok(click.includes('nc.' + field + '=' + dft), comp + ' 重置缺少 ' + field + '=' + dft);
}
}
});
test('四个 design.html 面板: 每个字号字段都有 slide 控件', () => {
for (const comp of ['diy-card-entry', 'diy-card-list', 'diy-card-show', 'diy-card-self']) {
const html = readFileSync(resolve(BE, 'component/view/' + comp + '/design.html'), 'utf8');
const fields = Object.keys(CONTRACT[comp.replace('diy-card-', '')].defaults);
for (const field of fields) {
assert.match(html, new RegExp("<slide :data=\"\\{ field: '" + field + "'"), comp + ' 面板缺少 ' + field + ' 的 slide');
}
}
});
test('design.html 预览必须消费字号字段(防「面板可配预览不动」漂移)', () => {
const expectPreview = {
'diy-card-entry': ['nc.titleSize', 'nc.subSize', 'nc.btnSize'],
'diy-card-list': ['nc.titleSize', 'nc.nameSize', 'nc.infoSize'],
'diy-card-show': ['nc.nameSize', 'nc.subSize', 'nc.labelSize', 'nc.valueSize', 'nc.actSize', 'nc.btnSize'],
'diy-card-self': ['nc.nameSize', 'nc.subSize', 'nc.labelSize', 'nc.valueSize', 'nc.actSize', 'nc.btnSize'],
};
for (const [comp, tokens] of Object.entries(expectPreview)) {
const html = readFileSync(resolve(BE, 'component/view/' + comp + '/design.html'), 'utf8');
for (const token of tokens) {
assert.ok(html.includes(token), comp + ' 预览未消费 ' + token);
}
}
});
test('C 端 vue 按默认值消费字号字段(px2rpx 渲染)', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const src = readFileSync(resolve(FE, cfg.vueFile), 'utf8');
for (const [field, dft] of Object.entries(cfg.defaults)) {
const literal = 'num(' + cfg.vueRead + field + ', ' + dft + ')';
assert.ok(src.includes(literal), cfg.vueFile + ' 缺少消费 ' + literal);
}
}
});
test('self.vue miniConfig 透传全部字号字段到基件', () => {
const src = readFileSync(resolve(FE, 'diy-card-self.vue'), 'utf8');
for (const field of Object.keys(CONTRACT.self.defaults)) {
assert.ok(src.includes(field + ': v.' + field), 'miniConfig 缺少透传 ' + field);
}
});
test('派生关系: 横滑/徽标 -1 档, 箭头 +1 档(默认渲染与历史一致)', () => {
const list = readFileSync(resolve(FE, 'diy-card-list.vue'), 'utf8');
assert.ok(list.includes("num(this.v.nameSize, 14) - 1"), 'list 横滑姓名应比网格小 1px');
assert.ok(list.includes("num(this.v.infoSize, 11) - 1"), 'list 横滑信息/人数徽标应比网格小 1px');
const entry = readFileSync(resolve(FE, 'diy-card-entry.vue'), 'utf8');
assert.ok(entry.includes("num(this.v.btnSize, 12) + 2"), 'entry 箭头应比按钮文字大 2px(28rpx)');
});
test('design.html 每个 slide 控件都带 default 且与 diy_view.php 注册值一致(逐项重置口径)', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
const value = readValueJson(comp);
// 所有 <slide> 都必须携带 default(含内容页的 maxRows/qrSize)
const slides = html.match(/<slide[^>]*>/g) || [];
assert.ok(slides.length > 0, comp + ' 应存在 slide 控件');
for (const tag of slides) {
const fm = tag.match(/field:\s*'([A-Za-z0-9]+)'/);
const dm = tag.match(/default:\s*(-?\d+)/);
assert.ok(fm && dm, comp + ' slide 缺 field 或 default: ' + tag);
const field = fm[1];
const dft = Number(dm[1]);
assert.equal(
Number(value[field]), dft,
comp + '.' + field + ' slide default=' + dft + ' 应与 diy_view.php 值 ' + value[field] + ' 一致'
);
}
// 契约中全部字号字段的 slide 必须存在(防漏配)
const allFields = slides.map(t => (t.match(/field:\s*'([A-Za-z0-9]+)'/) || [])[1]);
for (const field of Object.keys(cfg.defaults)) {
assert.ok(allFields.includes(field), comp + ' 缺少 ' + field + ' 的 slide(default 参数)');
}
}
});
test('design.html 颜色控件带 value 兜底(= defaultColor, 防存量行缺字段回落 #303133)', () => {
for (const [comp, cfg] of Object.entries(CONTRACT)) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
const tags = html.match(/<color :data="\{[^}]+\}"><\/color>/g) || [];
assert.ok(tags.length >= Object.keys(cfg.colors).length, comp + ' color 控件数量不足');
for (const [field, dft] of Object.entries(cfg.colors)) {
const tag = tags.find(t => t.includes("field: '" + field + "'"));
assert.ok(tag, comp + ' 缺少 ' + field + ' 的 color 控件');
assert.ok(tag.includes("defaultColor: '" + dft + "'"), comp + '.' + field + ' defaultColor 应为 ' + dft);
assert.ok(tag.includes("value: '" + dft + "'"), comp + '.' + field + ' 缺 value 兜底(应为 ' + dft + ')');
}
}
});
test('核心 slide 组件支持 opt-in 逐项重置(不传 default 的调用方零影响)', () => {
const js = readFileSync(resolve(BE, '../../public/static/ext/diyview/js/components.js'), 'utf8');
assert.ok(js.includes('v-if="canReset"'), 'slide 模板缺少 canReset 条件渲染');
assert.ok(js.includes('resetDefault'), 'slide 缺少 resetDefault 方法');
// canReset 只能由 default 参数打开
assert.ok(js.match(/compData\.default !== undefined[\s\S]{0,60}canReset = true/), 'canReset 开关未绑定 default 参数');
// 颜色控件的平台自带重置仍在(逐项重置的颜色口径)
assert.ok(js.includes('class="color-selector-reset text-color" @click="reset()"'), 'color 组件内置重置丢失');
});
test('反例自测: 契约定义缺字段时测试必须失败', () => {
// 校验 readValueJson 真的在解析 diy_view.php 而非恒真
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
assert.ok(!php.includes('"titleSizeNonExist"'), '探针字段不应存在(存在则本测试失效)');
for (const comp of Object.keys(CONTRACT)) {
const value = readValueJson(comp);
assert.equal(value.titleSizeNonExist, undefined, comp + ' 不应有探针字段');
}
});