This commit is contained in:
2026-10-27 16:53:49 +08:00
parent c6367822dc
commit b8a1a916db
4 changed files with 0 additions and 1506 deletions

View File

@@ -1,186 +0,0 @@
/**
* 名片云 · 商家端(admin)页面测试
*
* 背景:
* 商家端 7 个页面中, 后台接口先就绪的是 contact/* 与 lead/*(以及 card/lists),
* 因此本轮先上三个页面(其余 5 页等后台接口, 见 CARD_API_REFERENCE.md 缺口):
* - admin/manage 名片管理(基础版: 搜索/详情/编辑, 详情编辑复用员工端页)
* - admin/contacts 人脉管理(列表/搜索/来源筛选/编辑/转线索/删除)
* - admin/leads 潜在客户(状态筛选/列表/更新跟进状态)
* 本测试固化商家端契约: 注册/收口/鸿蒙安全导航/复用回跳。
*
* 跑法:
* node --test tests/card-admin.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const ADMIN_DIR = path.join(ROOT, 'pages_tool/card/admin');
const readAdmin = f => fs.readFileSync(path.join(ADMIN_DIR, f), 'utf8');
const readCard = f => fs.readFileSync(path.join(ROOT, 'pages_tool/card', f), 'utf8');
const pagesSrc = fs.readFileSync(path.join(ROOT, 'pages.json'), 'utf8');
const utilSrc = fs.readFileSync(path.join(ROOT, 'common/js/util.js'), 'utf8');
// pages.json 含 uni-app 合法 // 注释, 剥后解析
const stripped = pagesSrc.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1');
const pagesJson = JSON.parse(stripped);
const ADMIN_PAGES = {
'card/admin/manage': '名片管理',
'card/admin/contacts': '人脉管理',
'card/admin/leads': '潜在客户'
};
/** 取 pages_tool 分包已注册路径 */
function toolPaths() {
const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool');
assert.ok(sub, 'pages_tool 分包应存在');
return (sub.pages || []).map(p => p.path);
}
// =============================================================
// 1. 页面注册
// =============================================================
test('注册: 商家端 3 页已注册进 pages.json 的 pages_tool 分包', () => {
const paths = toolPaths();
for (const p of Object.keys(ADMIN_PAGES)) {
assert.ok(paths.includes(p), `${p} 未注册: ${paths.join(',')}`);
}
});
test('注册: 商家端页面标题正确且不在 TabBar', () => {
const sub = (pagesJson.subPackages || []).find(sp => sp.root === 'pages_tool');
for (const [p, title] of Object.entries(ADMIN_PAGES)) {
const page = (sub.pages || []).find(x => x.path === p);
assert.ok(page, `${p} 应存在`);
assert.equal(page.style.navigationBarTitleText, title, `${p} 标题应为「${title}」`);
// 名片云全部页面都不是 tabBar 根页
assert.ok(page.style.enablePullDownRefresh === true, `${p} 应开启下拉刷新`);
}
const tabPages = (pagesJson.tabBar && pagesJson.tabBar.list ? pagesJson.tabBar.list : []).map(p => p.pagePath);
for (const p of Object.keys(ADMIN_PAGES)) {
assert.ok(!tabPages.some(tp => tp.includes(p)), `TabBar 不应含 ${p}`);
}
});
test('注册: PAGE_KEY_MAP 含商家端 3 页且与 pages.json 一致', () => {
const map = {
PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage',
PAGE_CARD_ADMIN_CONTACTS: '/pages_tool/card/admin/contacts',
PAGE_CARD_ADMIN_LEADS: '/pages_tool/card/admin/leads'
};
for (const [key, val] of Object.entries(map)) {
const m = utilSrc.match(new RegExp(`${key}:\\s*'([^']+)'`));
assert.ok(m, `PAGE_KEY_MAP 应有 ${key}`);
assert.equal(m[1], val);
assert.ok(toolPaths().includes(val.slice('/pages_tool/'.length)),
`${val} 未在 pages.json 注册`);
}
// 防回归: TAB_BAR_KEYS 不得被误加 admin 路径
const tabBlock = utilSrc.match(/TAB_BAR_KEYS\s*=\s*\{[\s\S]*?\}/);
assert.ok(tabBlock, 'TAB_BAR_KEYS 应存在');
assert.ok(!/admin/.test(tabBlock[0]), 'TAB_BAR_KEYS 不应含 admin 路径');
});
// =============================================================
// 2. API 收口(与 card-api 测试同一契约, 覆盖新页面)
// =============================================================
test('收口: 商家端 3 页零 /card/api/ 字面量且走 this.$cardApi', () => {
for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) {
const src = readAdmin(f);
assert.ok(!/['"]\/card\/api\//.test(src), `${f} 仍直连 /card/api/ 字面量`);
assert.ok(/this\.\$cardApi\./.test(src), `${f} 未使用 this.$cardApi`);
}
});
test('收口: 商家端页面从 card-api.js 导入共享字典', () => {
assert.ok(/import\s*\{\s*CONTACT_SOURCES\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('contacts.vue')),
'contacts.vue 应导入 CONTACT_SOURCES');
assert.ok(/import\s*\{\s*LEAD_STATUS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(readAdmin('leads.vue')),
'leads.vue 应导入 LEAD_STATUS');
});
test('收口: leads.vue 的 setStatus 只接受 0-3(后端约束)', () => {
const src = readAdmin('leads.vue');
// ActionSheet 选项来自 LEAD_STATUS(0-3), 不产生越界值
assert.ok(/itemList:\s*LEAD_STATUS\.map/.test(src), '状态选项必须来自 LEAD_STATUS 字典');
assert.ok(!/status\s*=\s*[4-9]/.test(src), '不得出现 0-3 之外的 status 值');
});
// =============================================================
// 3. 鸿蒙安全导航(禁裸 uni.switchTab 静默失败)
// =============================================================
test('鸿蒙: 商家端 3 页平级互链全部走 $util.redirectTo', () => {
const cross = {
'manage.vue': [['goContacts', '/pages_tool/card/admin/contacts'], ['goLeads', '/pages_tool/card/admin/leads']],
'contacts.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goLeads', '/pages_tool/card/admin/leads']],
'leads.vue': [['goManage', '/pages_tool/card/admin/manage'], ['goContacts', '/pages_tool/card/admin/contacts']]
};
for (const [f, pairs] of Object.entries(cross)) {
const src = readAdmin(f);
for (const [method, target] of pairs) {
const m = src.match(new RegExp(`${method}\\(\\)\\s*\\{([\\s\\S]*?)\\n\\t\\t\\}`));
assert.ok(m, `${f} 应定义 ${method}`);
assert.ok(/\$util\.redirectTo/.test(m[1]), `${f}#${method} 必须走 $util.redirectTo`);
assert.ok(new RegExp(`redirectTo\\('${target.replace(/\//g, '\\/')}'\\)`).test(m[1]),
`${f}#${method} 目标应为 ${target}`);
}
assert.ok(!/uni\.switchTab\s*\(/.test(src), `${f} 不得直接调 uni.switchTab`);
}
});
test('鸿蒙: manage.vue 跳详情/编辑用 navigateTo + encodeURIComponent(card_id)', () => {
const src = readAdmin('manage.vue');
// 名片路由参数一律 card_id(字符串), 双编码无害(纯 ASCII)
assert.ok(/\/pages_tool\/card\/detail\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src),
'goDetail 应 encodeURIComponent(card_id)');
assert.ok(/\/pages_tool\/card\/edit\?card_id='\s*\+\s*encodeURIComponent\(item\.card_id\)/.test(src),
'goEdit 应 encodeURIComponent(card_id)');
});
// =============================================================
// 4. 编辑页复用回跳(from=admin)
// =============================================================
test('复用: contact-edit 支持 from=admin 且缺省行为不变', () => {
const src = readCard('contact-edit.vue');
assert.ok(/this\.fromAdmin\s*=\s*options\.from\s*===\s*'admin'/.test(src),
'onLoad 应解析 from=admin');
const m = src.match(/backToList\(\)\s*\{([\s\S]*?)\n\t\t\}/);
assert.ok(m, '应能提取 backToList 方法体');
assert.ok(/\$util\.redirectTo/.test(m[1]), 'backToList 必须走 $util.redirectTo');
assert.ok(/this\.fromAdmin\s*\?/.test(m[1]), 'backToList 应按来源分支');
assert.ok(m[1].includes("'/pages_tool/card/admin/contacts'"),
'商家端应回 /pages_tool/card/admin/contacts');
assert.ok(m[1].includes("'/pages_tool/card/contacts'"),
'员工端缺省仍回 /pages_tool/card/contacts(不破坏既有行为)');
assert.ok(!/uni\.switchTab\s*\(/.test(src), 'contact-edit.vue 不得直接调 uni.switchTab');
});
test('复用: admin/contacts 的编辑入口带 from=admin', () => {
const src = readAdmin('contacts.vue');
assert.ok(/contact-edit\?from=admin&id='/.test(src), '编辑入口应带 from=admin&id=');
assert.ok(/contact-edit\?from=admin'/.test(src), '添加入口应带 from=admin');
});
// =============================================================
// 5. 条件编译配对平衡(新文件同样纳入)
// =============================================================
test('ifdef 配对平衡: 商家端 3 页', () => {
for (const f of ['manage.vue', 'contacts.vue', 'leads.vue']) {
const src = readAdmin(f);
const open = (src.match(/#ifdef|#ifndef/g) || []).length;
const close = (src.match(/#endif/g) || []).length;
assert.equal(open, close, `${f}: #ifdef/#ifndef=${open} 但 #endif=${close}`);
}
});

View File

@@ -1,326 +0,0 @@
/**
* 名片云 API 收口层测试: common/js/card-api.js ↔ 后端 CARD_API_REFERENCE.md
*
* 背景:
* 后端《名片云(card addon)API 对接文档》checklist 第 1 条要求:
* "所有 /card/api/* URL 收口到 API 层, 与本文档路径逐一比对(现有 7 个页面
* 12 处直连, 路径不可变)"。本测试把该契约固化为断言:
*
* 覆盖:
* 1. card-api.js 的 18 个端点与后端文档逐一比对(一个字符都不能差)
* 2. card-api.js 不得出现文档之外的 /card/api/ 路径
* 3. 每个端点都有对应 helper 方法(收口不是只建常量表)
* 4. 7 个业务页面零字面量直连(全部走 this.$cardApi)
* 5. main.js 注册 $cardApi(Vue.prototype)
* 6. 字典与后端白名单一致: SOCIAL_TYPES / LEAD_STATUS / CONTACT_SOURCES
* 7. 嵌套对象入参走 sendJson 通道(card/update、contact/add、contact/update、
* recognizeBatch), 其余走 sendRequest
* 8. 走 sendJson 的 helper, 页面必须把 payload 包在 data 里
* (sendJson 只合并 params.data, 顶层挂 fields 会被丢弃)
*
* 跑法:
* node --test tests/card-api.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const CARD_DIR = path.join(ROOT, 'pages_tool/card');
const read = f => fs.readFileSync(path.join(CARD_DIR, f), 'utf8');
const API_SRC = fs.readFileSync(path.join(ROOT, 'common/js/card-api.js'), 'utf8');
const MAIN_SRC = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8');
// 后端 CARD_API_REFERENCE.md 第 1/3/4/5 节列出的全部端点(15 个)
const DOC_URLS = [
// 名片夹 /card/api/card/*
'/card/api/card/lists',
'/card/api/card/detail',
'/card/api/card/update',
'/card/api/card/toggleFavorite', // 收藏(双向 toggle, 后端 2.9.12)
'/card/api/card/create', // 后端源码已有(新建数字名片), 旧文档未列, 以源码为准
'/card/api/card/claim',
'/card/api/card/recognize',
'/card/api/card/retry',
'/card/api/card/recognizeBatch',
'/card/api/card/followDone', // 跟进完成态 2.11.3
'/card/api/card/views', // 访客轨迹 2.11.4
// 人脉库 /card/api/contact/*
'/card/api/contact/lists',
'/card/api/contact/add',
'/card/api/contact/info',
'/card/api/contact/update',
'/card/api/contact/delete',
'/card/api/contact/toLead',
// 潜在客户 /card/api/lead/*
'/card/api/lead/lists',
'/card/api/lead/setStatus',
// 交换名片 /card/api/exchange/*
'/card/api/exchange/lists',
'/card/api/exchange/agree',
'/card/api/exchange/ignore',
'/card/api/apply/submit', // 员工建卡申请 2.11.15
'/card/api/apply/status', // 员工建卡申请 2.11.15
'/card/api/design/presets', // 名片预设列表(同源后台 CardTemplatePresets, 后端 v2 无 /card/ 段)
'/card/api/design/save' // 应用预设/保存本卡样式
];
// helper 方法名 ↔ URL 常量名(收口层必须两端齐全)
const HELPER_URL_PAIRS = [
['cardLists', 'CARD_LISTS'],
['cardDetail', 'CARD_DETAIL'],
['cardUpdate', 'CARD_UPDATE'],
['cardToggleFavorite', 'CARD_FAVORITE'],
['cardCreate', 'CARD_CREATE'],
['cardClaim', 'CARD_CLAIM'],
['cardRecognize', 'CARD_RECOGNIZE'],
['cardRetry', 'CARD_RETRY'],
['cardRecognizeBatch', 'CARD_RECOGNIZE_BATCH'],
['contactLists', 'CONTACT_LISTS'],
['contactAdd', 'CONTACT_ADD'],
['contactInfo', 'CONTACT_INFO'],
['contactUpdate', 'CONTACT_UPDATE'],
['contactDelete', 'CONTACT_DELETE'],
['contactToLead', 'CONTACT_TO_LEAD'],
['leadLists', 'LEAD_LISTS'],
['leadSetStatus', 'LEAD_SET_STATUS'],
['exchangeLists', 'EXCHANGE_LISTS'],
['exchangeAgree', 'EXCHANGE_AGREE'],
['exchangeIgnore', 'EXCHANGE_IGNORE'],
['cardFollowDone', 'CARD_FOLLOW_DONE'],
['cardViews', 'CARD_VIEWS'],
['applySubmit', 'APPLY_SUBMIT'],
['applyStatus', 'APPLY_STATUS'],
['cardDesignPresets', 'CARD_DESIGN_PRESETS'],
['cardDesignSave', 'CARD_DESIGN_SAVE']
];
// 文档 3.3/4.2/4.4/3.7: 嵌套对象/数组入参必须走 JSON body(sendJson)
const JSON_BODY_HELPERS = ['cardUpdate', 'cardCreate', 'cardToggleFavorite', 'contactAdd', 'contactUpdate', 'cardRecognizeBatch', 'exchangeAgree', 'exchangeIgnore', 'cardDesignSave'];
// =============================================================
// 1. 端点与后端文档逐一比对
// =============================================================
test('收口: card-api.js 含文档全部 18 个端点', () => {
for (const u of DOC_URLS) {
assert.ok(API_SRC.includes(`'${u}'`), `card-api.js 缺少端点 ${u}(与后端文档不符)`);
}
});
test('收口: card-api.js 不得出现文档之外的 /card/api/ 路径', () => {
const found = API_SRC.match(/'\/card\/api\/[^']+'/g) || [];
const unknown = found.map(s => s.slice(1, -1)).filter(u => !DOC_URLS.includes(u));
assert.deepEqual(unknown, [], `存在文档外路径: ${unknown.join(', ')}`);
assert.equal(found.length, DOC_URLS.length,
`端点数量应为 ${DOC_URLS.length}, 实际 ${found.length}(防止漏收/多收)`);
});
// =============================================================
// 2. 每个端点都有 helper(收口不是只建常量表)
// =============================================================
test('收口: 15 个端点均有对应 helper 方法', () => {
for (const [helper, urlConst] of HELPER_URL_PAIRS) {
assert.ok(new RegExp(`\\b${helper}\\(p\\)\\s*\\{`).test(API_SRC),
`缺少 helper ${helper}(p)`);
assert.ok(new RegExp(`(?:url\\s*:|url\\s*=)\\s*CARD_API_URL\\.${urlConst}\\b`).test(API_SRC),
`helper ${helper} 未绑定 ${urlConst}`);
}
});
test('收口: 嵌套入参 helper 走 sendJson 通道', () => {
for (const helper of JSON_BODY_HELPERS) {
const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`));
assert.ok(m, `应能提取 ${helper} 方法体`);
assert.ok(/Http\.sendJson\(/.test(m[1]),
`${helper} 必须走 Http.sendJson(JSON body), 否则嵌套 fields 跨端序列化有歧义`);
}
});
test('收口: 普通入参 helper 走 sendRequest 通道', () => {
const formHelpers = HELPER_URL_PAIRS
.map(p => p[0])
.filter(h => !JSON_BODY_HELPERS.includes(h));
for (const helper of formHelpers) {
const m = API_SRC.match(new RegExp(`${helper}\\(p\\)\\s*\\{([\\s\\S]*?)\\n\\t\\}`));
assert.ok(m, `应能提取 ${helper} 方法体`);
assert.ok(/Http\.sendRequest\(/.test(m[1]),
`${helper} 应走 Http.sendRequest`);
assert.ok(!/Http\.sendJson\(/.test(m[1]),
`${helper} 不应走 sendJson(无嵌套入参)`);
}
});
// =============================================================
// 3. 页面零字面量直连
// =============================================================
test('收口: 7 个业务页面不得再直连 /card/api/ 字面量', () => {
const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'share.vue', 'contacts.vue', 'contact-edit.vue'];
for (const f of files) {
const src = read(f);
assert.ok(!/['"]\/card\/api\//.test(src),
`${f} 仍直连 /card/api/ 字面量, 必须走 this.$cardApi(后端 checklist 第 1 条)`);
}
});
test('收口: 有接口调用的 6 个页面全部走 this.$cardApi', () => {
// share.vue 是 web-view 中转页, 无业务接口调用, 不在此列
const files = ['list.vue', 'detail.vue', 'edit.vue', 'leads.vue', 'contacts.vue', 'contact-edit.vue'];
for (const f of files) {
const src = read(f);
assert.ok(/this\.\$cardApi\./.test(src),
`${f} 未使用 this.$cardApi`);
}
});
test('收口: main.js 注册 Vue.prototype.$cardApi', () => {
assert.ok(/import\s+CardApi\s+from\s+'\.\/common\/js\/card-api\.js'/.test(MAIN_SRC),
'main.js 应 import card-api.js');
assert.ok(/Vue\.prototype\.\$cardApi\s*=\s*CardApi/.test(MAIN_SRC),
'main.js 应注册 Vue.prototype.$cardApi');
});
// =============================================================
// 4. 字典与后端白名单一致
// =============================================================
test('字典: SOCIAL_TYPES 与后端 CardRichFields::SOCIAL_TYPES 白名单一致', () => {
const keys = [...API_SRC.matchAll(/\{\s*key:\s*'([a-z]+)',\s*label:/g)].map(m => m[1]);
// 2026-10-13: telegram / messenger 由独立标量软下线, 并入 socials 白名单
const expected = ['alipay', 'douyin', 'wxvideo', 'xiaohongshu', 'weibo', 'kuaishou',
'bilibili', 'qq', 'linkedin', 'facebook', 'instagram', 'x', 'telegram', 'messenger', 'other'];
assert.deepEqual(keys, expected, 'SOCIAL_TYPES 增减必须与后端同步');
});
test('字典: CARD_FIELDS 不含 telegram/messenger(2026-10-13 软下线)', () => {
const block = API_SRC.match(/export const CARD_FIELDS = \[([\s\S]*?)\];/);
assert.ok(block, 'CARD_FIELDS 应存在');
const values = [...block[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]);
assert.ok(!values.includes('telegram'), 'telegram 已软下线为独立标量, 不得出现在 CARD_FIELDS');
assert.ok(!values.includes('messenger'), 'messenger 已软下线为独立标量, 不得出现在 CARD_FIELDS');
// 其余标量不受影响
for (const f of ['name', 'company', 'job_title', 'avatar', 'mobile', 'phone', 'email',
'website', 'wechat', 'country', 'province', 'city', 'address', 'postal_code', 'bio']) {
assert.ok(values.includes(f), `CARD_FIELDS 应保留 ${f}`);
}
});
test('软下线: edit.vue 不再提交 telegram/messenger 标量, 字典从收口层导入', () => {
const editSrc = fs.readFileSync(path.join(CARD_DIR, 'edit.vue'), 'utf8');
assert.ok(!/form\.telegram/.test(editSrc), 'edit.vue 不得再绑 form.telegram(软下线)');
assert.ok(!/form\.messenger/.test(editSrc), 'edit.vue 不得再绑 form.messenger(软下线)');
assert.ok(!/fields\.telegram/.test(editSrc) && !/fields\.messenger/.test(editSrc),
'edit.vue 保存时不得再提交 telegram/messenger 标量');
assert.ok(/import\s*\{\s*SOCIAL_TYPES,\s*CARD_FIELDS,\s*RICH_FIELD_LIMITS\s*\}\s*from\s*'@\/common\/js\/card-api\.js'/.test(editSrc),
'edit.vue 应从 card-api.js 导入 SOCIAL_TYPES/CARD_FIELDS/RICH_FIELD_LIMITS');
// 模板里不得再有 Telegram / Messenger 独立输入行
assert.ok(!/<view class="row-label">Telegram<\/view>/.test(editSrc),
'模板不得再有 Telegram 独立输入行');
assert.ok(!/<view class="row-label">Messenger<\/view>/.test(editSrc),
'模板不得再有 Messenger 独立输入行');
});
test('软下线: detail.vue 不再单独渲染 telegram/messenger 行(旧值已并入 socials_list)', () => {
const detailSrc = fs.readFileSync(path.join(CARD_DIR, 'detail.vue'), 'utf8');
assert.ok(!/push\('Telegram'/.test(detailSrc), 'detail.vue 不得再单独渲染 Telegram 行');
assert.ok(!/push\('Messenger'/.test(detailSrc), 'detail.vue 不得再单独渲染 Messenger 行');
// 社交账号统一走 socials_list(后端已合并旧值)
assert.ok(/r\.socials_list/.test(detailSrc), 'detail.vue 应继续消费 socials_list');
});
test('字典: LEAD_STATUS 与后端 Lead::STATUSES 一致(0-3)', () => {
const block = API_SRC.match(/export const LEAD_STATUS = \[([\s\S]*?)\];/);
assert.ok(block, 'LEAD_STATUS 应存在');
const values = [...block[1].matchAll(/value:\s*(\d)/g)].map(m => Number(m[1]));
assert.deepEqual(values, [0, 1, 2, 3]);
// setStatus 只接受 0/1/2/3, 其它值后端报「参数不合法」
assert.ok(/status\?\(0-3\)/.test(API_SRC), 'leadLists 文档注释应标注 status 0-3');
});
test('字典: CONTACT_SOURCES 与后端 Contact::SOURCES 一致', () => {
const block = API_SRC.match(/export const CONTACT_SOURCES = \[([\s\S]*?)\];/);
assert.ok(block, 'CONTACT_SOURCES 应存在');
const values = [...block[1].matchAll(/value:\s*'([a-z_]+)'/g)].map(m => m[1]);
assert.deepEqual(values, ['manual', 'lead_in', 'scan', 'exchange']);
});
test('字典: CONTACT_FIELDS 与后端 Contact::EDITABLE_FIELDS 一致', () => {
const block = API_SRC.match(/export const CONTACT_FIELDS = \[([\s\S]*?)\];/);
assert.ok(block, 'CONTACT_FIELDS 应存在');
const values = [...block[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]);
assert.deepEqual(values,
['name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark', 'status']);
});
// =============================================================
// 5. 文档关键语义在收口层有注释固化(防后人改错)
// =============================================================
test('语义: card/update 的 normalize 规则写在收口层注释里', () => {
// 键存在才写 / 空串=清空 / 键缺席=不擦除(历史事故定稿)
assert.ok(/键存在才写/.test(API_SRC) && /空串=清空/.test(API_SRC) && /键缺席=不擦除/.test(API_SRC),
'cardUpdate 注释必须固化 normalize 三语义');
});
test('语义: recognize 的 deduped 提示写在收口层注释里', () => {
assert.ok(/deduped=true 表示站点内已有同手机号名片/.test(API_SRC),
'cardRecognize 注释必须提示 deduped 语义');
});
test('语义: 名片用 card_id 字符串 / 人脉线索用 id 数字的区分写在注释里', () => {
assert.ok(/card_id(字符串),人脉\/线索用 id(数字)/.test(API_SRC),
'收口层注释必须固化主键类型区分(后端 checklist 第 9 条)');
});
// =============================================================
// 6. 走 sendJson 的 helper: 页面必须把 payload 包在 data 里
// =============================================================
/**
* 提取 `$cardApi.<helper>` 调用的实参文本(括号配对)。
* 兼容两种写法: `this.$cardApi.cardUpdate({...})` 与三元分支
* `(this.isCreate ? this.$cardApi.cardCreate : this.$cardApi.cardUpdate)({...})`
* —— 后者 helper 名后紧跟的是 ')' 而不是 '(', 故先定位名字再找其后第一个 '('。
*/
function callArgs(src, helper) {
const at = src.indexOf('$cardApi.' + helper);
if (at < 0) return null;
const start = src.indexOf('(', at);
if (start < 0) return null;
let depth = 0;
for (let i = start; i < src.length; i++) {
const c = src[i];
if (c === '(') depth++;
else if (c === ')') {
depth--;
if (depth === 0) return src.slice(start, i + 1);
}
}
return null;
}
test('入参: 走 sendJson 的 helper 必须把 payload 包在 data 里', () => {
// 历史事故(2026-10-13): edit.vue 保存时把 fields 挂在顶层传参, 而
// Http.sendJson 只 Object.assign(data, params.data)(见 common/js/http.js),
// 顶层键被整体丢弃 → 请求体里没有 fields → 后端按空 name 校验,
// 前端弹「保存失败」、Network 里看到 {code:-1, data:"请填写姓名"}。
const CALLERS = {
'edit.vue': ['cardCreate', 'cardUpdate'],
'contact-edit.vue': ['contactAdd', 'contactUpdate']
};
for (const [f, helpers] of Object.entries(CALLERS)) {
const src = read(f);
for (const h of helpers) {
const args = callArgs(src, h);
assert.ok(args, `${f} 应能定位 $cardApi.${h} 调用`);
assert.ok(/data\s*:/.test(args),
`${f} 的 $cardApi.${h} 必须传 data: {...}; 顶层挂 fields 会被 sendJson 丢弃`);
}
}
});

View File

@@ -1,215 +0,0 @@
/**
* 名片 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 + ' 不应有探针字段');
}
});

View File

@@ -1,779 +0,0 @@
/**
* 名片 DIY 组件 · 2.17 双形态(渐变头部/宫格快捷操作/资料区标题/复制按钮)契约测试
*
* 锁死三层口径: diy_view.php 默认值 ↔ design.html 面板控件与预览 ↔ C 端 vue 消费
* 兼容铁律(2026-10-24 起分两层):
* ① 基件 flagOn/normalize 缺字段回落历史形态(plain/chips/关) —— 本测试 108-241 行
* 断言保持不变, 保护任何缺字段的存量行渲染零变化;
* ② diy_view.php config 默认值代际升级为参考图形态(theme/grid/开/above) ——
* 配套迁移(test/card_diy_instance_style_migrate.php)把注册表行补缺 + 存量页面
* 实例按整值指纹刷新, 存量行不再依赖①的回落, 故 config 默认值断言随代际更新。
*
* 同时锁死 2.17 修复: self.vue miniConfig 补齐 7 个字号字段透传
* (diy-card-show.vue 自 2026-10-21 起改为自绘, 不再经 miniConfig 复用 diy-card-mini,
* 配置由 computed 直读 this.c, 故仅对仍走 v 对象架构的 self.vue 锁死透传)
*/
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');
/** 2.17 新字段 → 默认值(2026-10-24 代际升级: 参考图形态, 见文件头注释) */
const V2_FIELDS = {
headStyle: 'theme',
headNameColor: '#FFFFFF',
headSubColor: '#FFFFFF',
actsLayout: 'grid',
actGridColorMode: 'preset',
showRowsTitle: 1,
rowsTitle: '联系资料',
showCopyBtn: 1,
};
/** 2.18 新字段 → 默认值(2026-10-24 代际升级: actsPosition above) */
const V18_FIELDS = {
actsPosition: 'above',
actsBlockBg: '',
actsBlockRadius: 0,
actsBlockPad: 0,
qrImage: '',
};
const V18_ACT_TYPES = ['tel', 'wx', 'mail', 'web', 'save'];
const PHP_NAMES = { 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]);
}
function readMiniVue() {
return readFileSync(resolve(FE, 'diy-card-mini.vue'), 'utf8');
}
function readCommonJs() {
return readFileSync(resolve(FE, 'diy-card-common.js'), 'utf8');
}
test('diy_view.php: Show/Self value 均含 8 个 2.17 字段且默认值=参考图形态(2026-10-24 代际升级)', () => {
for (const comp of ['show', 'self']) {
const v = readValueJson(comp);
for (const [field, dft] of Object.entries(V2_FIELDS)) {
assert.ok(v[field] !== undefined, comp + '.' + field + ' 已注册');
assert.equal(v[field], dft, comp + '.' + field + ' 默认值=' + JSON.stringify(dft));
}
}
});
test('diy_view.php: Show/Self value 均含 2.18 字段(actIcons 五类型完整结构, actsPosition=above 代际升级)', () => {
for (const comp of ['show', 'self']) {
const v = readValueJson(comp);
for (const [field, dft] of Object.entries(V18_FIELDS)) {
assert.ok(v[field] !== undefined, comp + '.' + field + ' 已注册');
assert.deepEqual(v[field], dft, comp + '.' + field + ' 默认值=' + JSON.stringify(dft));
}
// actIcons: 必须是完整五类型结构(旧行 refreshComponent 深拷贝补全后, 面板
// v-model="nc.actIcons[type].xxx" 才不会因缺键报错); 2.19 起含 iconKey(素材库)
assert.ok(v.actIcons && typeof v.actIcons === 'object', comp + '.actIcons 已注册');
for (const t of V18_ACT_TYPES) {
assert.deepEqual(v.actIcons[t], { img: '', iconKey: '', svg: '', color: '', bg: '' }, comp + '.actIcons.' + t + ' 结构完整(含 svg 预览快照位)');
}
}
});
test('common.js: normalizeActsPosition + actIconCfgOf(2.18)', () => {
const js = readCommonJs();
const consts = js.match(/export const ACTS_POSITIONS = [^;]+;/)[0].replace('export const', 'const');
const pos = new Function('v', consts + '\n' + js.match(/export function normalizeActsPosition[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return normalizeActsPosition(v);');
assert.equal(pos(undefined), 'below', '缺省回落 below(历史形态)');
assert.equal(pos('nonsense'), 'below', '非法值回落 below');
assert.equal(pos('above'), 'above', 'above 透传');
assert.equal(pos('below'), 'below', 'below 透传');
const cfgOf = new Function('actIcons', 'type', js.match(/export function actIconCfgOf[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return actIconCfgOf(actIcons, type);');
assert.deepEqual(cfgOf(undefined, 'tel'), {}, 'actIcons 缺省 → 空对象');
assert.deepEqual(cfgOf({}, 'tel'), {}, 'actIcons 空表 → 空对象');
assert.deepEqual(cfgOf({ tel: { img: 'a.png' } }, 'tel'), { img: 'a.png' }, '按 type 取配置');
assert.deepEqual(cfgOf({ tel: 'bad' }, 'tel'), {}, '非对象配置 → 空对象');
});
test('common.js: normalize 非法/缺省值回落历史默认(兼容 1.0 铁律)', () => {
const js = readCommonJs();
// 函数体引用模块常量(HEAD_STYLES 等), 重构执行时一并提供作用域
const consts = js.match(/export const (HEAD_STYLES|ACTS_LAYOUTS|QR_POSITIONS|QR_STYLES) = [^;]+;/g)
.map(s => s.replace('export const ', 'const ')).join('\n');
for (const [fn, arg, expect, desc] of [
['normalizeHeadStyle', undefined, 'plain', '缺省回落 plain'],
['normalizeHeadStyle', 'nonsense', 'plain', '非法值回落 plain'],
['normalizeHeadStyle', 'theme', 'theme', 'theme 透传'],
['normalizeActsLayout', undefined, 'chips', '缺省回落 chips'],
['normalizeActsLayout', 'nonsense', 'chips', '非法值回落 chips'],
['normalizeActsLayout', 'grid', 'grid', 'grid 透传'],
['normalizeActGridColorMode', undefined, 'preset', '缺省回落 preset'],
['normalizeActGridColorMode', 'theme', 'theme', 'theme 透传'],
['normalizeActGridColorMode', 'nonsense', 'preset', '非法值回落 preset'],
]) {
const re = new RegExp('export function ' + fn + '\\(([^)]*)\\)\\s*\\{([\\s\\S]*?)\\n}');
const m = js.match(re);
assert.ok(m, 'common.js 存在 ' + fn);
const body = m[0].replace(/^export function /, 'function ');
const fnObj = new Function('v', consts + '\n' + body + '; return ' + fn + '(v);');
assert.equal(fnObj(arg), expect, fn + ': ' + desc);
}
});
test('common.js: hexToRgba 换算与反例 + ACT_GRID_ICONS 字形映射', () => {
const js = readCommonJs();
const hexToRgba = new Function('hex', 'alpha', js.match(/export function hexToRgba[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return hexToRgba(hex, alpha);');
assert.equal(hexToRgba('#2F6BFF', 0.12), 'rgba(47,107,255,0.12)');
assert.equal(hexToRgba('2F6BFF', 0.5), 'rgba(47,107,255,0.5)');
assert.equal(hexToRgba('#F8A', 0.2), 'rgba(255,136,170,0.2)', '3 位短写法展开(复审修复)');
assert.equal(hexToRgba('#XYZ', 0.5), null, '非法 hex 返回 null');
assert.equal(hexToRgba('', 0.5), null, '空串返回 null');
const icons = js.match(/export const WAP_LINE_ICONS = \{[\s\S]*?\n\};/)[0];
// 2.18.1: 宫格图标与 wap detail 快捷操作同源(五枚 SVG 线稿), wx/save 固定色
assert.match(icons, /tel:\s*\{\s*mode:\s*'stroke'/);
assert.match(icons, /wx:\s*\{\s*mode:\s*'fill',\s*color:\s*'#07C160'/, 'wx 微信绿固定色(detail 同源)');
assert.match(icons, /mail:\s*\{\s*mode:\s*'stroke'/);
assert.match(icons, /web:\s*\{\s*mode:\s*'stroke'/);
assert.match(icons, /save:\s*\{\s*mode:\s*'stroke',\s*color:\s*'#FF9500'/, 'save 通讯录橙固定色(detail 同源)');
// svgLineIconBg: data URI 生成(stroke 模式颜色内嵌 / fill 模式固定色)
// 注入依赖: WAP_LINE_ICONS 常量 + 私有 b64ascii(含 B64T 字符表)
const wapConsts = js.match(/export const WAP_LINE_ICONS = \{[\s\S]*?\n\};/)[0].replace('export const', 'const');
const b64fn = js.match(/const B64T = [^;]+;[\s\S]*?function b64ascii\(s\) \{[\s\S]*?\n\}/)[0];
const svgBg = new Function('type', 'color', wapConsts + '\n' + b64fn + '\n' + js.match(/export function svgLineIconBg[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return svgLineIconBg(type, color);');
const telBg = svgBg('tel', '#0F6E56');
assert.match(telBg, /^url\("data:image\/svg\+xml;base64,/, 'data URI base64 形态');
assert.ok(atob(telBg.replace(/^url\("data:image\/svg\+xml;base64,/, '').replace(/"\)$/, '')).indexOf('stroke="#0F6E56"') >= 0, 'stroke 色内嵌');
const wxBg = svgBg('wx', '#123456');
assert.ok(atob(wxBg.replace(/^url\("data:image\/svg\+xml;base64,/, '').replace(/"\)$/, '')).indexOf('fill="#07C160"') >= 0, 'wx 固定绿不随入参色');
assert.equal(svgBg('unknown', '#000'), '', '未知类型空串(首字兜底)');
// save 入多彩预设(紫色档)
assert.match(js, /save:\s*\{\s*bg:\s*'#F0E9FB',\s*fg:\s*'#5B3DA8'/, '宫格 preset 补 save 紫色档');
});
test('common.js: rowCssIconFor 资料行 CSS 图标(2.17.1)', () => {
const js = readCommonJs();
const rowConsts = js.match(/const ROW_CSS_ICONS = \{[\s\S]*?\};/)[0];
const fn = new Function('field', rowConsts + '\n' + js.match(/export function rowCssIconFor[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '; return rowCssIconFor(field);');
assert.equal(fn('email'), 'mail', '邮箱行走 CSS 信封');
assert.equal(fn('website'), 'web', '网站行走 CSS 线框地球');
assert.equal(fn('mobile'), '', '手机行仍用 iconfont 字形(rowIconFor 口径)');
assert.equal(fn(''), '');
assert.equal(fn(undefined), '');
});
test('mini.vue: theme 头部渲染分支 + 白字 fallback + 负 margin 通铺', () => {
const src = readMiniVue();
assert.match(src, /headTheme\(\)\s*\{[\s\S]*?normalizeHeadStyle\(this\.c\.headStyle\)\s*===\s*'theme'/, 'headTheme 消费 normalizeHeadStyle');
assert.match(src, /v-if="headTheme"[\s\S]*?mini-headtheme/, 'theme 档渲染渐变头部块');
assert.match(src, /v-if="!headTheme"[\s\S]*?class="mini-head"/, 'plain 档渲染白卡头部');
// 白字 fallback: headNameColor 缺省 → #FFFFFF; 副行 → 白 85%
assert.match(src, /this\.c\.headNameColor \|\| '#FFFFFF'/, '头部姓名色 fallback 白');
assert.match(src, /this\.c\.headSubColor \|\| 'rgba\(255,255,255,0\.85\)'/, '头部副行色 fallback 白 85%');
// 负 margin 通铺(不用 calc): margin: -{pad}rpx -{pad}rpx 0
assert.match(src, /margin: '-' \+ pad \+ 'rpx -' \+ pad \+ 'rpx 0'/, '头部块负 margin 通铺卡边');
// 空态提示不随 theme 变白(独立 emptyTipStyle)
assert.match(src, /emptyTipStyle\(\)/, '空态提示独立样式');
assert.doesNotMatch(src, /:style="subTextStyle">\{\{ emptyTip/, '空态不再复用 subTextStyle');
});
test('mini.vue: 宫格快捷操作分支 + preset/theme 双配色 + 首字兜底', () => {
const src = readMiniVue();
assert.match(src, /actsGrid\(\)\s*\{[\s\S]*?normalizeActsLayout\(this\.c\.actsLayout\)\s*===\s*'grid'/, 'actsGrid 消费 normalizeActsLayout');
// 2.18.1: 宫格图标 = SVG 线稿(与 wap detail 同源), img 覆盖优先, 未知类型首字兜底
assert.match(src, /mini-acts-grid[\s\S]*?actSvgOf\(act\)/, '宫格渲染用 actSvgOf');
assert.match(src, /v-else-if="actSvgOf\(act\)" class="act-grid-svg" :style="actSvgStyle\(act\)"/, 'SVG 线稿分支');
assert.match(src, /actInitialOf\(act\)/, '未知类型首字兜底');
assert.doesNotMatch(src, /act-grid-glyph/, 'iconfont 字形档已退出宫格(全 SVG)');
assert.match(src, /gridColorMode\(\)\s*\{[\s\S]*?normalizeActGridColorMode\(this\.c\.actGridColorMode\)/, '宫格配色模式消费 normalizeActGridColorMode');
// theme 档浅底 = detail 同款 145deg 12%→20% 主题渐变
assert.match(src, /linear-gradient\(145deg, ' \+ \(hexToRgba\(theme, 0\.12\) \|\| '#F2F3F5'\) \+ ', ' \+ \(hexToRgba\(theme, 0\.2\) \|\| '#F2F3F5'\)/, 'theme 档浅主题渐变底(detail 同源)');
assert.match(src, /ACT_GRID_PRESETS\[\(act && act\.type\) \|\| ''\] \|\| ACT_GRID_PRESET_FALLBACK/, 'preset 色板 + fallback');
// 带参样式函数必须在 methods(computed 不支持参数)
const methods = src.match(/methods:\s*\{[\s\S]*$/)[0];
assert.match(methods, /actGridIconStyle\(act\)/, 'actGridIconStyle 在 methods');
assert.match(methods, /actGridGlyphStyle\(act\)/, 'actGridGlyphStyle 在 methods');
// 2.18.1: 图标色口径 = 每按钮 color > detail 固定色(wx绿/save橙) > theme > preset
assert.match(methods, /actIconColor\(act\)[\s\S]*?cfgColor[\s\S]*?ico && ico\.color[\s\S]*?gridColorMode === 'theme'/, 'actIconColor 四级口径');
assert.match(methods, /actSvgStyle\(act\)\s*\{[\s\S]*?svgLineIconBg\(act && act\.type,\s*this\.actIconColor\(act\)\)/, 'actSvgStyle 消费 svgLineIconBg + actIconColor');
assert.match(src, /import \{[\s\S]*?WAP_LINE_ICONS, svgLineIconBg/, 'mini.vue 引入 SVG 图标源');
// CSS 图标基础样式仍须顶层定义(资料行 email/website 行还在用 --row 版)
assert.match(src, /\n\.css-ico-mail \{/, 'css-ico-mail 顶层定义(资料行消费)');
assert.match(src, /\n\.css-ico-web \{/, 'css-ico-web 顶层定义(资料行消费)');
// 5 动作一行: 列宽按 acts 数量动态(20% / 25%)
assert.match(src, /gridItemWidth\(\)\s*\{[\s\S]*?acts\.length > 4 \? '20%' : '25%'/, '宫格列宽 5 动作 20%');
assert.match(src, /:style="\{ width: gridItemWidth \}"/, '宫格列宽内联绑定');
// WXSS 禁 calc(): CSS 图标规则体内不得出现 calc(限定规则体内, 避免注释「不支持 calc()」字样假阳性)
assert.doesNotMatch(src, /css-ico[a-z-]*\s*\{[^{}]*calc\(/, 'CSS 描边图标无 calc');
});
test('mini.vue + show/self: save(存通讯录)动作全链路(2.17.1)', () => {
// 基件: 资料行 CSS 图标(邮箱/网站)与行图标开关
const src = readMiniVue();
assert.match(src, /rowCssIcon\(row\)\s*\{[\s\S]*?rowCssIconFor\(row && row\.field\)/, '资料行 CSS 图标消费 rowCssIconFor');
assert.match(src, /v-if="rowCssIcon\(row\)"[\s\S]*?css-ico-mail--row/, '资料行渲染缩小版信封');
assert.match(src, /row-css-wrap[\s\S]*?width: 96rpx/, 'CSS 行图标容器与标签列同宽对齐');
// show/self: doAct save 分支 + goSaveContact 字段组装(白名单口径)
for (const file of ['diy-card-show.vue', 'diy-card-self.vue']) {
const vue = readFileSync(resolve(FE, file), 'utf8');
assert.match(vue, /if \(act\.type === 'save'\)\s*\{\s*this\.goSaveContact\(\);\s*return;\s*\}/, file + ' doAct 加 save 分支');
assert.match(vue, /goSaveContact\(\)\s*\{[\s\S]*?save-contact/, file + ' 跳 save-contact 页');
// 字段组装自 rows(白名单)+ 公开头部字段, 键名与 save-contact.vue onLoad keys 一一对应
for (const k of ['name', 'mobile', 'company', 'job_title', 'email', 'website', 'address', 'remark', 'wechat']) {
assert.match(vue, new RegExp(k + ": (c\\." + k + " || '')|(" + k + ": map\\." + k + " || '')"), file + ' 组装字段 ' + k);
}
assert.match(vue, /Array\.isArray\(c\.rows\)[\s\S]*?map\[r\.field\]/, file + ' 字段从 rows(白名单)提取');
assert.doesNotMatch(vue, /doAct\(act\)\s*\{\s*if \(!act \|\| !act\.value\) return;/, file + ' doAct 不再拦 value 为空的 save');
// 2.19.3 扩展类型: sms/map/link 分支 + map 空值回落名片地址
assert.match(vue, /act\.type === 'map' && !act\.value && this\.card && this\.card\.address/, file + ' map 空值回落名片地址');
assert.match(vue, /act\.type === 'sms'\)\s*\{[\s\S]*?window\.location\.href = 'sms:' \+ act\.value;[\s\S]*?号码已复制/, file + ' sms 分支(H5 sms: 链接/非 H5 复制)');
assert.match(vue, /act\.type === 'map'\)\s*\{[\s\S]*?地址已复制,可粘贴到地图导航/, file + ' map 分支(复制地址)');
assert.match(vue, /act\.type === 'link'\)\s*\{[\s\S]*?indexOf\('http:\/\/'\) === 0 [\s\S]*?\$util\.redirectTo\(act\.value, \{\}\)/, file + ' link 分支(外链 H5 跳转/小程序复制, 站内 redirectTo)');
}
});
test('mini.vue: 资料区标题 + 复制按钮(默认关, emit row-act copy)', () => {
const src = readMiniVue();
assert.match(src, /rowsTitleShow\(\)\s*\{[\s\S]*?flagOn\(this\.c\.showRowsTitle,\s*false\)/, '资料标题默认关');
assert.match(src, /showCopyBtn\(\)\s*\{[\s\S]*?flagOn\(this\.c\.showCopyBtn,\s*false\)/, '复制按钮默认关');
assert.match(src, /v-if="rowsTitleShow"[\s\S]*?rowsTitleBarStyle/, '标题渲染含主题色竖条');
assert.match(src, /onCopyRow\(row\)[\s\S]*?\$emit\('row-act',\s*\{\s*type:\s*'copy',\s*value:\s*row\.value\s*\}\)/, '复制复用 row-act copy 分支');
assert.match(src, /@tap\.stop="onCopyRow\(row\)"/, '复制按钮阻断行点击冒泡');
});
test('复审修复(2.17 review): corner 让位计 theme 头高 / 圆角仅底部 / text-shadow 不泄漏 plain', () => {
const src = readMiniVue();
// corner 浮层让位按头部实际高反算(theme 档 164 = 32+104+28)
assert.match(src, /headHeight = this\.headTheme \? 164 : 104/, 'corner 让位区分 theme/plain 头部高度');
// theme 头部块通铺卡边后圆角只给底部(避免与卡片圆角叠加双圆角)
assert.match(src, /\.mini-headtheme \{[\s\S]*?border-radius: 0 0 16rpx 16rpx/, 'headtheme 圆角仅底部');
// text-shadow 只作用于 theme 档头像(scoped 父选择器 .mini-headtheme &), plain 档零变化
assert.match(src, /\.mini-headtheme & \{[\s\S]*?text-shadow/, 'text-shadow 限定 theme 档');
});
test('self.vue: miniConfig 透传 8 个新字段 + 7 个字号(2.17 修复)', () => {
// 注: diy-card-show.vue 自 2026-10-21 起改为自绘, 不再经 miniConfig 复用 diy-card-mini 通用布局,
// 配置由 computed 直读 this.c 消费, 不持有 v 数据对象; 故仅对仍走 v 对象架构的 self.vue 锁死透传。
const sizes = ['nameSize', 'cardTitleSize', 'subSize', 'labelSize', 'valueSize', 'actSize', 'btnSize'];
const v2 = Object.keys(V2_FIELDS);
const file = 'diy-card-self.vue';
const src = readFileSync(resolve(FE, file), 'utf8');
for (const f of v2) {
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 透传 ' + f);
}
// 2.18: 6 个新字段透传
for (const f of Object.keys(V18_FIELDS).concat(['actIcons'])) {
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 透传 ' + f);
}
// 2.17: 7 个字号字段透传(可配置字号)
for (const f of sizes) {
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 补齐字号透传 ' + f);
}
});
test('2.18: 操作区位置/独立样式/每按钮图标/自定义二维码 全链路', () => {
// 基件消费
const src = readMiniVue();
assert.match(src, /actsAbove\(\)\s*\{[\s\S]*?normalizeActsPosition\(this\.c\.actsPosition\)\s*===\s*'above'/, 'actsAbove 消费 normalizeActsPosition');
assert.match(src, /v-if="actsAbove && showActs && acts\.length && actsGrid"/, '上方档宫格分支渲染');
assert.match(src, /v-if="!actsAbove && showActs && acts\.length && actsGrid"/, '下方档宫格分支互斥守卫');
assert.match(src, /rowsClearStyle\(\)\s*\{[\s\S]*?return this\.actsAbove \? \{\} : this\.cornerClearStyle/, '上方档时资料行不再重复让位');
// 2026-10-24: uni-app 运行时静默丢弃 :style="[字符串, 对象]" 数组绑定(2026-10-22 qrBlockStyle 线上问题同根因,
// H5 实测 actsBlockBg/Radius/Pad 全失效、style 属性为 null) → 合并为 actsBlockFinalStyle 单绑定, 数组形态禁止回归
assert.match(src, /:style="actsBlockFinalStyle"/, '操作区块走 actsBlockFinalStyle 单绑定');
assert.doesNotMatch(src, /:style="\[actsBlockStyle/, '操作区块禁数组绑定(uni-app 静默丢弃)');
assert.match(src, /actsBlockFinalStyle\(\)\s*\{[\s\S]*?cornerClearStyle/, 'actsBlockFinalStyle 合并 corner 让位');
assert.match(src, /actsBlockStyle\(\)\s*\{[\s\S]*?actsBlockBg/, '独立块样式消费 actsBlockBg/Radius/Pad');
// 每按钮图标: img 优先于字形/CSS 图标; 前景/底色覆盖
assert.match(src, /actImgOf\(act\)\s*\{[\s\S]*?actIconCfgOf\(this\.c\.actIcons,\s*act && act\.type\)/, 'actImgOf 消费 actIconCfgOf');
assert.match(src, /<image v-if="actImgOf\(act\)" class="act-grid-img"/, '自定义图标图片渲染分支');
assert.match(src, /actGridIconStyle\(act\)\s*\{[\s\S]*?cfgBg[\s\S]*?if \(cfgBg\)/, '底色每按钮覆盖优先');
assert.match(src, /actIconColor\(act\)[\s\S]*?if \(custom\) return custom/, '前景色每按钮覆盖优先');
// 自定义二维码
assert.match(src, /qrSrc\(\)\s*\{[\s\S]*?String\(this\.c\.qrImage \|\| ''\)\.trim\(\)/, 'qrSrc 自定义图优先');
assert.match(src, /flagOn\(this\.c\.showQr,\s*false\) && !!this\.qrSrc/, 'showQrBase 消费 qrSrc');
assert.equal((src.match(/\$util\.img\(qrSrc\)/g) || []).length, 5, '五个二维码挂点全部消费 qrSrc');
// 展示挂点(qr-img 类)禁旧直连; 分享抽屉(ss-qr)保留名片原码 —— 分享出去必须能扫码打开名片(2026-10-24 口径)
assert.doesNotMatch(src, /class="qr-img"[^>]*\$util\.img\(card\.qr_url\)/, '展示挂点无旧直连 card.qr_url');
assert.match(src, /ss-qr[\s\S]{0,120}\$util\.img\(card\.qr_url\)/, '分享抽屉保留名片原码(扫码语义)');
// SCSS: 自定义图标图片铺满图标块
assert.match(src, /\.act-grid-img\s*\{[\s\S]*?width: 100%/, 'act-grid-img 铺满图标块');
// design.html ×2: 面板控件 + 预览消费
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
assert.match(html, /@click="nc\.actsPosition = 'above'"/, comp + ' 操作区位置 chips(above)');
assert.match(html, /@click="nc\.actsPosition = 'below'"/, comp + ' 操作区位置 chips(below)');
assert.match(html, /@click="nc\.actsBlockBg = '#F7F8FA'; nc\.actsBlockPad = 12; nc\.actsBlockRadius = 12"/, comp + ' 独立成块预设联动');
assert.match(html, /field: 'actsBlockBg', label: '操作区底色'/, comp + ' 操作区底色 color');
assert.match(html, /field: 'actsBlockRadius', label: '操作区圆角'/, comp + ' 操作区圆角 slide');
assert.match(html, /field: 'actsBlockPad', label: '操作区内边距'/, comp + ' 操作区内边距 slide');
// 每按钮图标自定义(2.19 起并入「快捷操作管理」图层面板; 媒体库走类名委托)
assert.match(html, /快捷操作管理/, comp + ' 快捷操作管理图层面板');
assert.doesNotMatch(html, /js-act-media/, comp + ' 媒体库选图入口已移除(2.19.1 图标唯一来源=素材图标库)');
assert.match(html, /js-act-color" data-scope="default" :data-key="a\.k" data-field="color"/, comp + ' 每按钮前景色输入(2.19 起走 js-act-color 委托, $set 由委托 JS 兼容存量行)');
assert.match(html, /js-act-color" data-scope="default" :data-key="a\.k" data-field="bg"/, comp + ' 每按钮底色输入(同上)');
// 自定义二维码(媒体库)
assert.match(html, /v-model="nc\.qrImage"/, comp + ' 二维码自定义图输入');
assert.match(html, /js-qr-media" title="从媒体库选择二维码图片"/, comp + ' 二维码选图按钮走 js-qr-media 委托(2.19.3, 内联 openAlbum 不可靠)');
assert.doesNotMatch(html, /@click="openAlbum\(/, comp + ' 零内联 openAlbum 调用');
// 预览消费
assert.match(html, /nc\.actsPosition === 'above'/, comp + ' 预览上方档分支');
assert.match(html, /nc\.actsPosition !== 'above'/, comp + ' 预览下方档互斥守卫');
assert.match(html, /diy-card-mini__act-img/, comp + ' 预览自定义图标分支');
assert.match(html, /diy-card-qr__img--custom/, comp + ' 预览自定义二维码分支');
}
// 平台: refreshComponent 对象默认值深拷贝(防同名组件共享嵌套对象串改)
const ct = readFileSync(resolve(BE, '../../public/static/ext/diyview/js/custom_template.js'), 'utf8');
assert.match(ct, /JSON\.parse\(JSON\.stringify\(defaults\[key\]\)\)/, 'refreshComponent 对象默认值深拷贝');
// 反例: entry/list 不注册 2.18 字段
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
const entryM = php.match(/'name'\s*=>\s*'DiyCardEntry'[\s\S]*?'value'\s*=>\s*'([^\']+)'/);
const entry = JSON.parse(entryM[1]);
assert.equal(entry.actsPosition, undefined, 'entry 不注册 actsPosition');
assert.equal(entry.qrImage, undefined, 'entry 不注册 qrImage');
});
test('design.html ×2: 面板控件与预览消费齐备 + 互证 diy_view 默认值', () => {
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
// 面板: 双形态 chips + 开关 + 文案输入
assert.match(html, /@click="nc\.headStyle = 'plain'"/, comp + ' 头部形态 chips(plain)');
assert.match(html, /@click="nc\.headStyle = 'theme'"/, comp + ' 头部形态 chips(theme)');
assert.match(html, /@click="nc\.actsLayout = 'grid'"/, comp + ' 操作布局 chips(grid)');
assert.match(html, /@click="nc\.actGridColorMode = 'theme'"/, comp + ' 宫格配色 chips(theme)');
assert.match(html, /v-model="nc\.showRowsTitle"/, comp + ' 资料区标题开关');
assert.match(html, /v-model="nc\.rowsTitle"/, comp + ' 标题文案输入');
assert.match(html, /v-model="nc\.showCopyBtn"/, comp + ' 复制按钮开关');
// 预览消费
assert.match(html, /diy-card-mini__head--theme/, comp + ' 预览 theme 头部分支');
assert.match(html, /diy-card-mini__act-grid/, comp + ' 预览宫格分支');
assert.match(html, /diy-card-mini__rows-title/, comp + ' 预览资料标题');
assert.match(html, /diy-card-mini__row-copy/, comp + ' 预览复制按钮');
// 样式页: theme 档头部 color 控件 + 恢复默认按钮覆盖头部色
assert.match(html, /field: 'headNameColor', label: '头部姓名色', defaultColor: '#FFFFFF'/, comp + ' 头部姓名色 color');
assert.match(html, /field: 'headSubColor', label: '头部副行色', defaultColor: '#FFFFFF'/, comp + ' 头部副行色 color');
assert.match(html, /nc\.headNameColor='#FFFFFF'; nc\.headSubColor='#FFFFFF'/, comp + ' 恢复默认覆盖头部色');
}
});
test('反例自测: 新形态标记不得出现在未改造组件中', () => {
// entry/list 未接 2.17 双形态(它们没有 mini 基件), diy_view 默认值也不应出现
const php = readFileSync(resolve(BE, 'config/diy_view.php'), 'utf8');
const entryM = php.match(/'name'\s*=>\s*'DiyCardEntry'[\s\S]*?'value'\s*=>\s*'([^\']+)'/);
const entry = JSON.parse(entryM[1]);
assert.equal(entry.headStyle, undefined, 'entry 不注册 headStyle(基件专属)');
assert.equal(entry.actsLayout, undefined, 'entry 不注册 actsLayout(基件专属)');
// common.js 禁 calc 铁律: 新增代码不得引入 calc()
const common = readCommonJs();
assert.doesNotMatch(common, /calc\(/, 'common.js 无 calc(WXSS 不支持)');
});
/* ------------------------------------------------------------------
* 2.19 快捷操作: 显示控制 + 自定义操作 + 素材图标库 + 图层式管理
* ------------------------------------------------------------------ */
const V19_FIELDS = { actVis: 'object', actDeleted: 'object', actLabels: 'object', customActs: 'array', actOrder: 'array' };
const V19_ACT_LABELS = { tel: '打电话', wx: '微信号', mail: '发邮件', web: '网站', save: '存通讯录' };
/** 2.19.10 detail 版式对齐字段 → 默认值(零变化) */
const V21910_FIELDS = {
showRowLabels: 0,
copyBtnScope: 'all',
copyBtnLook: 'theme',
qrTipEm: '',
qrTipSub: '',
qrUrlShow: 0,
badgeShow: 0,
badgeText: '数字名片',
rowsBlockBg: '',
rowsBlockRadius: 0,
rowsBlockPad: 0,
qrBlockBg: '',
qrBlockRadius: 0,
qrBlockPad: 0,
};
test('diy_view.php: Show/Self value 均含 2.19 字段(默认值=全显/默认文案/空数组/历史顺序=零变化)', () => {
for (const comp of ['show', 'self']) {
const v = readValueJson(comp);
// actVis: 五类型全 1(显示)
assert.ok(v.actVis && typeof v.actVis === 'object', comp + '.actVis 已注册');
assert.deepEqual(v.actVis, { tel: 1, wx: 1, mail: 1, web: 1, save: 1 }, comp + '.actVis 默认全显');
// actLabels: 五类型默认文案
assert.ok(v.actLabels && typeof v.actLabels === 'object', comp + '.actLabels 已注册');
assert.deepEqual(v.actLabels, V19_ACT_LABELS, comp + '.actLabels 默认文案');
// customActs: 空数组
assert.deepEqual(v.customActs, [], comp + '.customActs 默认空数组');
// actOrder(2.19.1): 历史渲染顺序
assert.deepEqual(v.actOrder, ['tel', 'wx', 'mail', 'web', 'save'], comp + '.actOrder 默认=历史顺序');
// actDeleted(2.19.2): 全 0=无删除(历史零变化)
assert.deepEqual(v.actDeleted, { tel: 0, wx: 0, mail: 0, web: 0, save: 0 }, comp + '.actDeleted 默认全 0');
}
});
/* ------------------------------------------------------------------
* 2.19.10 detail 版式对齐: 三列行/复制范围外观/qrTip 富文本/URL 行/hero 徽章/分卡
* ------------------------------------------------------------------ */
test('diy_view.php: Show/Self value 均含 2.19.10 字段(默认值=历史形态零变化)', () => {
for (const comp of ['show', 'self']) {
const v = readValueJson(comp);
for (const [field, dft] of Object.entries(V21910_FIELDS)) {
assert.ok(field in v, comp + '.' + field + ' 已注册');
assert.deepEqual(v[field], dft, comp + '.' + field + ' 默认=' + JSON.stringify(dft));
}
}
});
test('show/self.vue: miniConfig 均透传 9 个 2.19.10 字段', () => {
for (const file of ['diy-card-show.vue', 'diy-card-self.vue']) {
const src = readFileSync(resolve(FE, file), 'utf8');
for (const f of Object.keys(V21910_FIELDS)) {
assert.match(src, new RegExp(f + ':\\s*v\\.' + f + ','), file + ' 透传 ' + f);
}
}
});
test('mini.vue: 消费 2.19.10 字段(computed/模板/样式)', () => {
const src = readFileSync(resolve(FE, 'diy-card-mini.vue'), 'utf8');
// computed: 新字段 getter
for (const f of ['showRowLabels', 'badgeShow', 'badgeText', 'qrTipEm', 'qrTipSub', 'qrUrlShow', 'qrUrlText', 'copyBtnScope', 'copyBtnLook', 'rowsBlockStyle', 'qrBlockCustomStyle']) {
assert.match(src, new RegExp(f + '\\(\\)\\s*\\{'), 'mini.vue computed ' + f);
}
// 模板消费
assert.match(src, /class="mini-hero-badge"/, 'hero 徽章渲染');
assert.match(src, /row-3col.*showRowLabels|showRowLabels.*row-3col/, '三列行 class 绑定');
assert.match(src, /copyBtnVisible\(row\)/, '复制按钮范围方法');
assert.match(src, /qrTipEm/, 'qrTip 加粗段');
assert.match(src, /qrTipSub/, 'qrTip 第二行');
assert.match(src, /qrUrlShow/, 'URL 行开关');
// 独立块样式消费(与操作区同款: Bg/Radius/Pad)
assert.match(src, /rowsBlockBg/, 'rowsBlockStyle 消费 rowsBlockBg');
assert.match(src, /qrBlockBg/, 'qrBlockCustomStyle 消费 qrBlockBg');
assert.match(src, /rowsFinalStyle\(\)\s*\{[\s\S]*?rowsBlockStyle/, 'rowsFinalStyle 合并 rowsBlockStyle');
assert.match(src, /qrBlockFinalStyle\(\)\s*\{[\s\S]*?qrBlockCustomStyle/, 'qrBlockFinalStyle 合并 qrBlockCustomStyle');
// 样式
assert.match(src, /\.mini-hero-badge/, 'hero 徽章 CSS');
assert.match(src, /\.qr-tip-em/, 'qrTip 加粗 CSS');
assert.match(src, /\.qr-tip-sub/, 'qrTip 第二行 CSS');
assert.match(src, /\.qr-url/, 'URL 行 CSS');
assert.match(src, /row-3col/, '三列行 CSS');
// 反例: 禁数组 style 绑定(uni-app 静默丢弃)——先剥注释防误判
const stripped = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
assert.doesNotMatch(stripped, /:style="\[/, '无数组 style 绑定');
// 反例: layoutCards 已移除
assert.doesNotMatch(stripped, /layoutCards/, 'layoutCards 已移除');
// 徽章样式避让企业徽标 badgeStyle
assert.match(src, /heroBadgeStyle\(\)/, 'heroBadgeStyle 避让 badgeStyle');
});
test('素材图标库三源同步: PHP CardIconLibrary ↔ 面板 card-icon-lib.js ↔ C端 common.js', () => {
// PHP 源: 解析 CardIconLibrary::ICONS(key => svg)
const php = readFileSync(resolve(BE, 'service/CardIconLibrary.php'), 'utf8');
const libRe = /'(\w+)'\s*=>\s*\[\s*'label'\s*=>\s*'[^']*',\s*'category'\s*=>\s*'[^']*',\s*'svg'\s*=>\s*'(<svg [^']*)',\s*\]/g;
const phpLib = {};
let m;
while ((m = libRe.exec(php)) !== null) {
phpLib[m[1]] = m[2];
}
assert.ok(Object.keys(phpLib).length >= 25, 'PHP 素材库图标数 >= 25, 实际 ' + Object.keys(phpLib).length);
assert.ok(phpLib.phone && phpLib.phone.indexOf('currentColor') > 0, 'phone 图标带 currentColor');
const extractJson = (src, anchor) => {
const i = src.indexOf(anchor);
assert.ok(i >= 0, '找到锚点 ' + anchor);
const j = src.indexOf('{', i);
// 前向扫描配对花括号(JSON 内无裸花括号字面量, 安全)
let depth = 0, k = j;
for (; k < src.length; k++) {
if (src[k] === '{') depth++;
else if (src[k] === '}') { depth--; if (depth === 0) break; }
}
return JSON.parse(src.slice(j, k + 1));
};
// 面板 resource JS
const panelJs = readFileSync(resolve(BE, 'component/view/public/js/card-icon-lib.js'), 'utf8');
const panelLib = extractJson(panelJs, 'window.CARD_ICON_LIB =');
assert.deepEqual(panelLib, phpLib, '面板图标库与 PHP 同源');
assert.match(panelJs, /function cardLibIconSvg\(key\)/, '面板提供 cardLibIconSvg');
assert.match(panelJs, /function cardLibIconBg\(key, color\)/, '面板提供 cardLibIconBg');
assert.match(panelJs, /split\('currentColor'\)\.join\(color/, '面板上色走 currentColor 替换');
// C端 common.js
const common = readCommonJs();
const feLib = extractJson(common, 'export const CARD_ICON_LIB =');
assert.deepEqual(feLib, phpLib, 'C端图标库与 PHP 同源');
assert.match(common, /export function libIconSvg\(key\)/, 'C端提供 libIconSvg');
assert.match(common, /export function libIconBg\(key, color\)/, 'C端提供 libIconBg');
});
test('common.js: normalizeActVis / normalizeActLabels / normalizeCustomActs / normalizeActOrder(2.19+2.19.1)', () => {
const js = readCommonJs();
const consts = [
js.match(/export const ACT_TYPES = [^;]+;/)[0].replace('export const', 'const'),
js.match(/export const ACT_ACTION_TYPES = [^;]+;/)[0].replace('export const', 'const'),
js.match(/export const ACT_LABEL_DEFAULTS = \{[\s\S]*?\n\};/)[0].replace('export const', 'const'),
js.match(/export const ACT_ORDER_DEFAULT = [^;]+;/)[0].replace('export const', 'const'),
].join('\n');
const grab = (name) => js.match(new RegExp('export function ' + name + '[\\s\\S]*?\\n}'))[0].replace('export function ', 'function ');
const body = consts + '\n' + ['normalizeActVis', 'normalizeActDeleted', 'normalizeActLabels', 'normalizeCustomActs', 'normalizeActOrder'].map(grab).join('\n') + '\n'
+ 'return [normalizeActVis, normalizeActDeleted, normalizeActLabels, normalizeCustomActs, normalizeActOrder];';
const [visFn, delFn, labelFn, customFn, orderFn] = new Function(body)();
// actVis: 缺省全显(历史零变化); '0' → 0; 未知键忽略
assert.deepEqual(visFn(undefined), { tel: 1, wx: 1, mail: 1, web: 1, save: 1 }, 'actVis 缺省全显');
assert.deepEqual(visFn({ tel: '0', wx: 0 }), { tel: 0, wx: 0, mail: 1, web: 1, save: 1 }, 'actVis 0=隐藏/缺键=显');
// actDeleted(2.19.2): 缺省全 0(历史零变化); '1' → 1
assert.deepEqual(delFn(undefined), { tel: 0, wx: 0, mail: 0, web: 0, save: 0 }, 'actDeleted 缺省全 0');
assert.deepEqual(delFn({ tel: '1', save: 1 }), { tel: 1, wx: 0, mail: 0, web: 0, save: 1 }, 'actDeleted 1=已删/缺键=0');
// actLabels: 缺省=内置文案; 空串回落默认
assert.deepEqual(labelFn(undefined), V19_ACT_LABELS, 'actLabels 缺省=内置默认');
assert.deepEqual(labelFn({ tel: '', wx: '加微信' }).wx, '加微信', 'actLabels 改名生效');
assert.equal(labelFn({ tel: '' }).tel, '打电话', 'actLabels 空串回落默认');
// customActs: 白名单过滤 + 字段收敛 + custom 标记
assert.deepEqual(customFn(undefined), [], 'customActs 缺省空');
const list = customFn([
{ label: '加群', type: 'url', value: 'https://x.com', vis: 1 },
{ type: 'tel', value: '13900000000' },
'junk',
]);
assert.equal(list.length, 2, '非对象项剔除');
assert.equal(list[0].type, 'copy', '非法 type 回落 copy');
assert.equal(list[0].custom, 1, '带 custom 标记');
assert.equal(list[0].label, '加群', 'label 保留');
assert.equal(list[1].label, '自定义', 'label 缺省');
assert.equal(list[1].type, 'tel', '合法 type 保留');
// 2.19.3 扩展类型白名单
assert.deepEqual(customFn([{ type: 'sms', value: '139' }, { type: 'map', value: '某某大厦' }, { type: 'link', value: '/pages/index/index' }]).map(x => x.type), ['sms', 'map', 'link'], 'sms/map/link 新类型通过白名单');
// actOrder(2.19.1): 缺省=历史顺序; 去重+白名单+缺失尾补
assert.deepEqual(orderFn(undefined), ['tel', 'wx', 'mail', 'web', 'save'], 'actOrder 缺省=历史顺序');
assert.deepEqual(orderFn(['save', 'tel', 'bogus', 'save', 'mail']), ['save', 'tel', 'mail', 'wx', 'web'], '重排去重+非法剔除+缺失尾补');
assert.deepEqual(orderFn('junk'), ['tel', 'wx', 'mail', 'web', 'save'], '非数组=历史顺序');
});
test('common.js: libIconBg 上色 + data URI 可解码回原图(2.19)', () => {
const js = readCommonJs();
const b64 = js.match(/const B64T = [^;]+;[\s\S]*?function b64ascii\(s\) \{[\s\S]*?\n\}/)[0];
const libJson = js.slice(js.indexOf('{', js.indexOf('export const CARD_ICON_LIB =')));
// 前向扫描取 JSON
let depth = 0, end = 0;
for (let i = libJson.indexOf('{'); i < libJson.length; i++) {
if (libJson[i] === '{') depth++;
else if (libJson[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } }
}
const lib = new Function('return ' + libJson.slice(libJson.indexOf('{'), end))();
const fn = new Function('CARD_ICON_LIB', b64 + '\n' + js.match(/export function libIconSvg\(key\)[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '\n' + js.match(/export function libIconBg\(key, color\)[\s\S]*?\n}/)[0].replace('export function ', 'function ') + '\nreturn libIconBg;')(lib);
const bg = fn('phone', '#FF0000');
assert.match(bg, /^url\("data:image\/svg\+xml;base64,/), 'libIconBg 输出 base64 data URI';
// 解码回 SVG 验证: currentColor 已替换为指定色
const b64str = bg.match(/base64,([^"]+)/)[1];
const svg = Buffer.from(b64str, 'base64').toString('ascii');
assert.ok(svg.indexOf('#FF0000') > 0, 'SVG 内嵌指定色');
assert.equal(svg.indexOf('currentColor'), -1, 'currentColor 已全部替换');
assert.equal(fn('not_exist', '#FFF'), '', '缺 key 返回空串');
});
test('mini.vue: acts 合成(显隐过滤 + 次序 + 文案覆盖 + 自定义动作尾接) + 素材库图标分支(2.19+2.19.1)', () => {
const src = readMiniVue();
// acts computed: 过滤/改名/合并/四 cfg 字段
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActVis\(this\.c\.actVis\)/, 'acts 消费 normalizeActVis');
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActLabels\(this\.c\.actLabels\)/, 'acts 消费 normalizeActLabels');
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeCustomActs\(this\.c\.customActs\)/, 'acts 消费 normalizeCustomActs');
// 2.19.1 次序: 按 normalizeActOrder 输出默认动作 + order 外扩展类型尾接兜底
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActOrder\(this\.c\.actOrder\)/, 'acts 消费 normalizeActOrder');
assert.match(src, /acts\(\)\s*\{[\s\S]*?const byType = \{\};[\s\S]*?order\.forEach\(t => \{/, 'acts 按 order 次序取默认动作');
assert.match(src, /acts\(\)\s*\{[\s\S]*?if \(order\.indexOf\(t\) >= 0 \|\| vis\[t\] === 0 \|\| del\[t\] === 1\) return;/, 'order 外扩展类型尾接且隐藏/已删不渲染');
// 2.19.2 删除: 按 order 取默认动作时 deleted=1 的不渲染
assert.match(src, /acts\(\)\s*\{[\s\S]*?normalizeActDeleted\(this\.c\.actDeleted\)/, 'acts 消费 normalizeActDeleted');
assert.match(src, /acts\(\)\s*\{[\s\S]*?if \(byType\[t\] && vis\[t\] !== 0 && del\[t\] !== 1\) out\.push\(merge\(byType\[t\]\)\);/, 'order 取默认动作时 vis=0/deleted=1 均过滤');
assert.match(src, /labels\[t\] \|\| a\.label/, 'actLabels 覆盖文案(空回落服务端)');
assert.match(src, /cfgImg: String\(cfg\.img \|\| ''\)\.trim\(\)/, '默认动作合并 cfgImg');
assert.match(src, /cfgKey: String\(cfg\.iconKey \|\| ''\)\.trim\(\)/, '默认动作合并 cfgKey(iconKey)');
assert.match(src, /normalizeCustomActs\(this\.c\.customActs\)\.forEach\(ca => \{[\s\S]*?cfgKey: ca\.iconKey/, '自定义动作尾接并合并 cfg 字段');
// 图标渲染优先级: img > 素材库(iconKey) > SVG 线稿 > 首字
assert.match(src, /actLibOf\(act\)\s*\{[\s\S]*?act\.cfgKey/, 'actLibOf 读合并 cfgKey');
assert.match(src, /actLibStyle\(act\)\s*\{[\s\S]*?libIconBg\(this\.actLibOf\(act\), this\.actIconColor\(act\)\)/, 'actLibStyle 消费 libIconBg + 四级色口径');
assert.equal((src.match(/v-else-if="actLibOf\(act\)" class="act-grid-svg" :style="actLibStyle\(act\)"/g) || []).length, 2, '上/下两份宫格副本均插入素材库分支');
assert.match(src, /actSvgOf\(act\)\s*\{[\s\S]*?!this\.actImgOf\(act\) && !this\.actLibOf\(act\) && !!WAP_LINE_ICONS/, '线稿档被 img/素材库优先占位后不再进入');
assert.match(src, /import \{[\s\S]*?normalizeActVis, normalizeActDeleted, normalizeActLabels, normalizeCustomActs, normalizeActOrder, libIconBg/, 'mini.vue 引入 2.19+2.19.1+2.19.2 依赖');
// 透传
// show.vue 自绘, 不持有 v 对象; actVis/actLabels/actOrder/customActs 透传仅校验 self.vue(走 v 架构)
for (const file of ['diy-card-self.vue']) {
const vue = readFileSync(resolve(FE, file), 'utf8');
for (const f of ['actVis', 'actDeleted', 'actLabels', 'actOrder', 'customActs']) {
assert.match(vue, new RegExp(f + ': v\\.' + f + ','), file + ' 透传 ' + f);
}
}
});
test('design.html ×2: 图层式管理面板 + 素材图标库 + 预览显隐/文案/自定义动作消费(2.19)', () => {
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
// 图层管理器: 眼睛开关 / 图标缩略 / 行内改名 / 素材库+媒体库按钮 / 清除
assert.match(html, /diy-card-layer__eye layui-icon" :class="nc\.actVis && nc\.actVis\[a\.k\] === 0 \? 'layui-icon-eye-invisible' : 'layui-icon-eye'/, comp + ' 默认动作眼睛开关');
assert.match(html, /nc\.actVis\[a\.k\] = nc\.actVis\[a\.k\] === 0 \? 1 : 0;/, comp + ' 眼睛切换写回 actVis');
// 存量组件防御(2026-10-24): defaults($diy_view_utils)缺新字段时 refreshComponent 补不了,
// v-model="nc.actLabels[a.k]" 裸读双层直取会在 render 阶段炸 "reading 'tel'" ——
// 管理器容器 v-if 守卫 + 改名输入 :value/@input 防御式写回 + @click 禁以 if 开头(keyword 编译警告)
assert.match(html, /v-if="nc\.showQuick && nc\.actLabels && nc\.actIcons"/, comp + ' 管理器容器 v-if 守卫(未补全不渲染)');
assert.doesNotMatch(html, /v-model="nc\.[a-zA-Z]+\[/, comp + ' 零 v-model 双层裸读(render 阶段防炸)');
assert.match(html, /:value="nc\.actLabels \? \(nc\.actLabels\[a\.k\] \|\| ''\) : ''" @input="\$set\(nc, 'actLabels', nc\.actLabels \|\| \{\}\); \$set\(nc\.actLabels, a\.k, \$event\.target\.value\)"/, comp + ' 行内改名防御式写回($set 兼容存量行)');
assert.doesNotMatch(html, /@click="if \(/, comp + ' 内联事件不以 if 开头(消 JS keyword 编译警告)');
assert.match(html, /v-model="nc\.actLabels\[a\.k\]" maxlength="6"|maxlength="6" title="快捷操作文字"/, comp + ' 改名输入在位');
assert.match(html, /js-act-lib" :data-key="a\.k" data-scope="default"/, comp + ' 素材图标按钮(默认动作)');
assert.doesNotMatch(html, /js-act-media/, comp + ' 媒体库按钮已移除(2.19.1)');
assert.match(html, /\$set\(nc\.actIcons\[a\.k\], 'iconKey', ''\)/, comp + ' 清除图标走 $set(iconKey 兼容存量行)');
// 自定义动作行: 类型 chips / 取值 / 排序 / 删除 / 添加
assert.match(html, /diy-card-layer--custom" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作图层行');
assert.match(html, /:class="\{ active: c\.type === t\.v \}"/, comp + ' 自定义动作类型 chips');
assert.match(html, /\{v:'copy',lb:'复制'\},\{v:'sms',lb:'短信'\},\{v:'map',lb:'导航'\},\{v:'link',lb:'页面'\}\]/, comp + ' 类型 chips 含 2.19.3 扩展(短信/导航/页面, web 文案改网址)');
assert.match(html, /js-act-syslink" data-scope="custom" :data-idx="ci" v-if="c\.type === 'link'"/, comp + ' link 类型出现「系统链接」选择按钮');
assert.match(html, /v-model="c\.value" maxlength="200"/, comp + ' 自定义动作取值输入');
assert.match(html, /js-act-lib" data-scope="custom" :data-idx="ci"/, comp + ' 素材图标按钮(自定义动作)');
assert.match(html, /nc\.customActs\.splice\(ci - 1, 0, nc\.customActs\.splice\(ci, 1\)\[0\]\)/, comp + ' 上移排序');
assert.match(html, /nc\.customActs\.splice\(ci \+ 1, 0, nc\.customActs\.splice\(ci, 1\)\[0\]\)/, comp + ' 下移排序');
assert.match(html, /nc\.customActs\.splice\(ci, 1\)/, comp + ' 删除自定义动作');
// 2.19.1 默认动作次序: 面板行按 actOrder 渲染 + ↑↓ 委托调整
assert.equal((html.match(/<template v-for="k in \(nc\.actOrder && nc\.actOrder\.length \? nc\.actOrder : \['tel','wx','mail','web','save'\]\)"><div class="diy-card-layer"/g) || []).length, 1, comp + ' 面板默认行按 actOrder 次序渲染(嵌套 v-for + v-if 匹配)');
// 2.19.2 删除: 行过滤 + 删除按钮 + 恢复 chips + 预览过滤
assert.match(html, /v-if="a\.k === k && !\(nc\.actDeleted && nc\.actDeleted\[a\.k\] === 1\)"/, comp + ' 面板行过滤已删除动作');
assert.match(html, /diy-card-layer__delact" title="删除此默认操作\(可在下方恢复\)" @click="\$set\(nc, 'actDeleted', nc\.actDeleted \|\| \{ tel: 0, wx: 0, mail: 0, web: 0, save: 0 \}\); \$set\(nc\.actDeleted, a\.k, 1\);"/, comp + ' 删除按钮防御式写回 actDeleted');
assert.match(html, /diy-card-layer__restore" v-if="nc\.actDeleted && \(nc\.actDeleted\.tel === 1/, comp + ' 恢复 chips 行(有删除项才显示)');
assert.match(html, /v-if="nc\.actDeleted\[t\.k\] === 1" :key="t\.k" @click="\$set\(nc\.actDeleted, t\.k, 0\);"/, comp + ' 恢复 chip 点击写回 0');
assert.equal((html.match(/v-if="a\.k === k && !\(nc\.actVis && nc\.actVis\[a\.k\] === 0\) && !\(nc\.actDeleted && nc\.actDeleted\[a\.k\] === 1\)"/g) || []).length, 2, comp + ' 预览显隐+删除双重过滤(上/下两副本)');
assert.equal((html.match(/js-act-move" title="上移" :data-key="a\.k" data-dir="up"/g) || []).length, 1, comp + ' 默认行上移按钮(委托)');
assert.equal((html.match(/js-act-move" title="下移" :data-key="a\.k" data-dir="down"/g) || []).length, 1, comp + ' 默认行下移按钮(委托)');
assert.match(html, /nc\.customActs\.push\(\{ label: '自定义', type: 'copy'[\s\S]*?svg: ''[\s\S]*?vis: 1 \}\)/, comp + ' 添加自定义操作(全键补齐含 svg)');
assert.match(html, /c\.vis = c\.vis === 0 \? 1 : 0;/, comp + ' 自定义动作眼睛开关');
// 预览消费: 次序 + 显隐过滤 + 文案覆盖 + 自定义动作单元格
assert.equal((html.match(/<template v-for="k in \(nc\.actOrder && nc\.actOrder\.length \? nc\.actOrder : \['tel','wx','mail','web','save'\]\)"><template v-for="a in \[\{k:'tel',ch:'打',lb:'打电话'/g) || []).length, 2, comp + ' 预览默认 cell 按 actOrder 次序渲染(上/下两副本)');
assert.match(html, /\{\{ \(nc\.actLabels && nc\.actLabels\[a\.k\]\) \|\| a\.lb \}\}/, comp + ' 预览文案覆盖');
// svg 快照口径: 预览只读纯数据字段(委托 JS 选图/改色时预计算 data URI 写入),
// 模板内严禁调用全局函数(模板作用域函数解析不可靠, 实测 typeof 恒 false)
assert.equal((html.match(/v-for="\(c, ci\) in nc\.customActs"/g) || []).length, 3, comp + ' 自定义动作 v-for = 面板行 + 预览×2 副本');
assert.equal((html.match(/nc\.actIcons\[a\.k\]\.svg" class="diy-card-mini__act-svg"/g) || []).length, 2, comp + ' 预览默认动作素材库图标读 svg 快照(上/下两副本)');
assert.equal((html.match(/v-else-if="c\.svg" class="diy-card-mini__act-svg"/g) || []).length, 2, comp + ' 预览自定义动作素材库图标读 svg 快照(上/下两副本)');
assert.match(html, /nc\.actIcons\[a\.k\]\.svg" class="diy-card-layer__libico"/, comp + ' 面板行图标缩略读 svg 快照');
assert.match(html, /c\.svg" class="diy-card-layer__libico"/, comp + ' 自定义行图标缩略读 svg 快照');
assert.doesNotMatch(html, /cardLibIconBg/, comp + ' 模板内零函数调用(全部改读 svg 字段)');
// 前景/底色走 class 委托(不再内联 @input 多语句)
assert.equal((html.match(/class="diy-card-layer__color js-act-color"/g) || []).length, 4, comp + ' 4 个颜色输入走 js-act-color 委托');
assert.match(html, /data-field="color"/, comp + ' 前景色 data-field');
assert.match(html, /data-field="bg"/, comp + ' 底色 data-field');
assert.match(html, /data-scope="custom" :data-idx="ci" data-field=/, comp + ' 自定义行颜色输入带作用域');
// 2.19.7 面板颜色输入占位必须与预览同链(空值=跟随配色档, 显示诚实):
// 默认行 color: wx#07C160/save#FF9500 固定 → theme#2F6BFF → a.fg; bg: theme#E6EDFF(rgba 叠白近似) → a.bg
const previewColorChain = html.match(/color: \(nc\.actIcons && nc\.actIcons\[a\.k\] && nc\.actIcons\[a\.k\]\.color\) \|\| \(a\.k === 'wx' \? '#07C160'[^"]*\)/);
assert.ok(previewColorChain, comp + ' 预览 color 回落链存在');
// 2.19.9 起 theme 段可被所选名片设计字段覆盖(previewCard.theme_color 优先, 无则回落 #2F6BFF)
assert.match(html, /nc\.actIcons\[a\.k\]\.color \? nc\.actIcons\[a\.k\]\.color : \(a\.k === 'wx' \? '#07C160' : a\.k === 'save' \? '#FF9500' : \(nc\.actGridColorMode === 'theme' \? \(\(nc\.tempData && nc\.tempData\.previewCard && nc\.tempData\.previewCard\.theme_color\) \|\| '#2F6BFF'\) : a\.fg\)\)"/, comp + ' 默认行前景输入占位与预览同链(wx/save 固定色 + theme 段 previewCard 覆盖/preset 档)');
assert.match(html, /nc\.actIcons\[a\.k\]\.bg \? nc\.actIcons\[a\.k\]\.bg : \(nc\.actGridColorMode === 'theme' \? '#E6EDFF' : a\.bg\)"/, comp + ' 默认行底色输入占位与预览同链(theme 叠白近似/preset 浅底)');
assert.doesNotMatch(html, /actIcons\[a\.k\]\.color \? nc\.actIcons\[a\.k\]\.color : '#2F6BFF'/, comp + ' 默认行前景占位不再恒 #2F6BFF(对多彩档是谎报)');
assert.match(html, /:value="c\.color \|\| '#2F6BFF'"/, comp + ' 自定义行前景占位(与自定义预览回落一致)');
assert.match(html, /:value="c\.bg \|\| '#E6EDFF'"/, comp + ' 自定义行底色占位(rgba 叠白近似)');
// 结构铁律: 自定义动作 cell 必须在默认动作 template v-for 之外(否则每默认动作重复渲染一次);
// 2.19.1 起默认 cell 为双层嵌套 template(外层 actOrder 次序), 收尾双 </template>
assert.equal((html.match(/<\/template><\/template><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/g) || []).length, 2, comp + ' 自定义动作 cell 在双层 template v-for 之外(上/下两副本)');
assert.doesNotMatch(html, /(?<!<\/template>)<\/template><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作 cell 不紧贴单层 template 收尾(嵌套层级正确)');
assert.doesNotMatch(html, /<\/div><div class="diy-card-mini__act-cell" v-for="\(c, ci\) in nc\.customActs"/, comp + ' 自定义动作 cell 不再嵌套于 template v-for 内');
// template 标签必须配平(未闭合 template 会把后续编辑器内容吞进惰性节点, vue 全局不初始化)
assert.equal((html.match(/<template/g) || []).length, (html.match(/<\/template>/g) || []).length, comp + ' template 标签配平');
// 资源引用(2026-10-24 起带 ?v={$version} 破生产浏览器/CDN 缓存 —— 同名 CSS/JS
// 旧缓存曾致生产编辑器面板图层行与预览宫格样式全丢、竖排裸文字)
for (const r of ['css/card-select\\.css', 'css/card-panel\\.css', 'css/card-preview\\.css', 'js/card-select\\.js', 'js/card-icon-lib\\.js', 'js/card-act-icon\\.js']) {
assert.match(html, new RegExp('src="\\{\\$resource_path\\}/\\.\\./public/' + r + '\\?v=\\{\\$version\\}"'), comp + ' 资源引用带版本参数: ' + r);
}
assert.doesNotMatch(html, /public\/(css|js)\/card-[a-z-]+\.(css|js)"/, comp + ' 零无版本资源引用');
}
});
test('design.html ×2: 2.19.10 detail 对齐面板控件 + 预览消费齐备', () => {
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
// 面板控件: 9 字段 v-model / chips / toggle
assert.match(html, /nc\.rowsBlockBg/, comp + ' rowsBlockBg chips');
assert.match(html, /nc\.rowsBlockRadius/, comp + ' rowsBlockRadius slide');
assert.match(html, /nc\.rowsBlockPad/, comp + ' rowsBlockPad slide');
assert.match(html, /nc\.qrBlockBg/, comp + ' qrBlockBg chips');
assert.match(html, /nc\.qrBlockRadius/, comp + ' qrBlockRadius slide');
assert.match(html, /nc\.qrBlockPad/, comp + ' qrBlockPad slide');
assert.doesNotMatch(html, /layoutCards/, comp + ' layoutCards 已移除');
assert.match(html, /v-model="nc\.badgeShow"/, comp + ' badgeShow toggle');
assert.match(html, /v-model="nc\.badgeText"/, comp + ' badgeText input');
assert.match(html, /v-model="nc\.showRowLabels"/, comp + ' showRowLabels toggle');
assert.match(html, /nc\.copyBtnScope/, comp + ' copyBtnScope chips');
assert.match(html, /nc\.copyBtnLook/, comp + ' copyBtnLook chips');
assert.match(html, /v-model="nc\.qrTipEm"/, comp + ' qrTipEm input');
assert.match(html, /v-model="nc\.qrTipSub"/, comp + ' qrTipSub input');
assert.match(html, /v-model="nc\.qrUrlShow"/, comp + ' qrUrlShow toggle');
// 预览消费: 徽章 + 三列行 + 复制按钮 + qrTip 多段 + URL 行
assert.match(html, /nc\.badgeShow.*badgeText|badgeText.*nc\.badgeShow/s, comp + ' 预览消费 badge');
assert.match(html, /nc\.showRowLabels/, comp + ' 预览消费 showRowLabels');
assert.match(html, /nc\.copyBtnLook/, comp + ' 预览消费 copyBtnLook');
assert.match(html, /nc\.qrTipEm/, comp + ' 预览消费 qrTipEm');
assert.match(html, /nc\.qrTipSub/, comp + ' 预览消费 qrTipSub');
assert.match(html, /nc\.qrUrlShow/, comp + ' 预览消费 qrUrlShow');
}
});
test('card-act-icon.js: 类名委托修 2.18 选图标 Bug + $set 写回 + 素材库唯一图标来源 + 次序调整(2.19+2.19.1)', () => {
const src = readFileSync(resolve(BE, 'component/view/public/js/card-act-icon.js'), 'utf8');
assert.match(src, /on\('click', '\.js-act-lib'/, '素材图标库走类名委托');
assert.doesNotMatch(src, /js-act-media/, '快捷操作媒体库委托已移除(2.19.1 图标唯一来源=素材图标库; QR 选图的 js-qr-media/openAlbum 为 2.19.3 合法新增)');
// 2.19.1 默认动作次序调整
assert.match(src, /on\('click', '\.js-act-move'/, '次序调整走类名委托');
assert.match(src, /var ACT_ORDER_DEF = \['tel', 'wx', 'mail', 'web', 'save'\];/, 'actOrder 白名单默认序');
assert.match(src, /function moveAct\(comp, key, dir\)/, 'moveAct 归一化+相邻交换');
assert.match(src, /window\.vue\.\$set\(comp, 'actOrder', cur\)/, 'actOrder 写回走 $set(存量行缺键)');
assert.match(src, /closest\('\.draggable-element'\)/, '组件实例定位 draggable-element');
assert.match(src, /data\('scope'\) \|\| 'default'\) === 'custom'/, 'custom 作用域写 customActs[idx]');
assert.match(src, /window\.vue\.\$set\(item, 'iconKey'/, 'iconKey 写回走 $set(存量行缺键)');
assert.match(src, /window\.vue\.\$set\(item, 'img'/, 'img 写回走 $set');
// svg 快照链路: 选图/改色时由委托 JS 预计算 data URI 写入 svg 字段(预览只读数据)
assert.match(src, /function setIcon\(item, patch\)/, 'setIcon 统一写图标');
assert.match(src, /window\.cardLibIconBg\(iconKey, currentColor\(item\)\)/, '选图时预计算 svg 快照');
assert.match(src, /function actColorHandler\(\$el, field\)/, '改色处理器');
assert.match(src, /on\('input', '\.js-act-color'/, '前景/底色走 input 类名委托');
assert.match(src, /window\.cardLibIconBg\(target\.item\.iconKey, currentColor\(target\.item\)\)/, '改色时重算 svg 快照(颜色实时生效)');
assert.match(src, /iconKey: '', svg: '', color: ''/, '缺行防御补齐含 svg 键');
assert.match(src, /window\.CARD_ICON_LIB \|\| \{\}/, '弹层渲染消费同源图标库');
assert.match(src, /cal-item" data-key=/, '弹层图标网格');
// 2.19.3 系统链接对话框委托(link 类型)
assert.match(src, /on\('click', '\.js-act-syslink'/, '系统链接走类名委托');
assert.match(src, /ns\.select_link\(\{\}, function \(data\)/, '调全局 ns.select_link 打开系统链接对话框');
assert.match(src, /data\.wap_url \|\| data\.url/, '回执取 wap_url 写入 value');
assert.match(src, /window\.vue\.\$set\(target\.item, 'value', url\)/, '系统链接结果 $set 写回 value');
// 2.19.3 自定义二维码选图委托(模板内联 openAlbum 不可靠, 2.18 同坑收尾)
assert.match(src, /on\('click', '\.js-qr-media'/, '二维码选图走类名委托');
assert.match(src, /window\.vue\.\$set\(comp, 'qrImage', obj\[0\]\.pic_path\)/, '媒体库回执 $set 写回 qrImage');
// 反例: 面板不再内联调 openAlbum 选快捷操作图标(2.18 Bug 根因规避)
for (const comp of ['show', 'self']) {
const html = readFileSync(resolve(BE, 'component/view/diy-card-' + comp + '/design.html'), 'utf8');
assert.doesNotMatch(html, /openAlbum\(function \(obj\) \{ if \(obj && obj\.length\) \{ if \(!nc\.actIcons\)/, comp + ' 面板不再内联调 openAlbum 写 actIcons');
}
});
test('card-preview.css: 默认线稿图标 mask+currentColor —— 前景色实时跟随(2.19.6)', () => {
const css = readFileSync(resolve(BE, 'component/view/public/css/card-preview.css'), 'utf8');
// 5 个默认线稿类必须走 mask-image(颜色与图分离) + background-color: currentColor
for (const k of ['tel', 'wx', 'mail', 'web', 'save']) {
const m = css.match(new RegExp('\\.diy-card-mini__act-svg--' + k + '\\s*\\{[^}]*\\}'));
assert.ok(m, '线稿类存在: ' + k);
const block = m[0];
assert.match(block, /mask-image:\s*url\("data:image\/svg\+xml/, k + ' 走 mask-image(非固定色 background-image)');
assert.match(block, /-webkit-mask-image:/, k + ' 带 -webkit 前缀');
assert.match(block, /background-color:\s*currentColor/, k + ' 背景消费 currentColor(跟随内联 color)');
assert.doesNotMatch(block, /background-image:/, k + ' 不再用固定色 background-image(颜色编码在图内改色无效)');
}
});