Merge branch 'feat/camcard' into dev/1.0
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -47,9 +47,11 @@ const DOC_URLS = [
|
||||
'/card/api/card/recognize',
|
||||
'/card/api/card/retry',
|
||||
'/card/api/card/delete', // 后端新增端点, 与源码同步
|
||||
'/card/api/card/uploadQrImage', // 自定义二维码上传(2.20)
|
||||
'/card/api/card/recognizeBatch',
|
||||
'/card/api/card/followDone', // 跟进完成态 2.11.3
|
||||
'/card/api/card/views', // 访客轨迹 2.11.4
|
||||
'/card/api/diy/card', // 小程序码主键寻址换业务 card_id(2.21)
|
||||
// 人脉库 /card/api/contact/*
|
||||
'/card/api/contact/lists',
|
||||
'/card/api/contact/add',
|
||||
@@ -101,6 +103,7 @@ const HELPER_URL_PAIRS = [
|
||||
['exchangeIgnore', 'EXCHANGE_IGNORE'],
|
||||
['cardFollowDone', 'CARD_FOLLOW_DONE'],
|
||||
['cardViews', 'CARD_VIEWS'],
|
||||
['cardByRelId', 'CARD_BY_REL_ID'],
|
||||
['applySubmit', 'APPLY_SUBMIT'],
|
||||
['applyStatus', 'APPLY_STATUS'],
|
||||
['cardDesignPresets', 'CARD_DESIGN_PRESETS'],
|
||||
|
||||
299
tests/diy-card-qr-image-source.test.js
Normal file
299
tests/diy-card-qr-image-source.test.js
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 名片 DIY 组件 · 名片级自定义二维码(card.qr_image)契约测试
|
||||
*
|
||||
* 背景(2.20):
|
||||
* 此前二维码只有两个来源 —— 组件级自定义图(c.qrImage)与系统生成码(card.qr_url)。
|
||||
* 2.20 起每张名片(员工名片/企业名片)可各自持有一张自定义二维码图片,
|
||||
* DIY 组件按三层优先级取用:
|
||||
* 1) 名片级 card.qr_image ← 每张名片各自一张(「我的名片」登录后即见本人那张)
|
||||
* 2) 组件级 c.qrImage ← 装修期统一指定(名片自己没设图时生效)
|
||||
* 3) 系统码 card.qr_url ← 兜底(原有行为)
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. mini 基件 qrSrc 按上述优先级取值(源码契约 + 真实求值双证)
|
||||
* 2. 组件级 qrImage 仍完整保留(未被名片级顶掉)
|
||||
* 3. 反例: 虚构字段不得出现(防恒真)
|
||||
*
|
||||
* 跑法: node --test tests/diy-card-qr-image-source.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 read = f => fs.readFileSync(path.join(DIY_DIR, f), 'utf8');
|
||||
// 先剥注释再断言 —— 注释里写「绝不能写 X」会被自判违规
|
||||
const strip = s => s
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
|
||||
const miniSrc = strip(read('diy-card-mini.vue'));
|
||||
|
||||
/**
|
||||
* 从 mini.vue 抽出 qrSrc 的真实函数体并求值 —— 不靠正则猜逻辑,
|
||||
* 避免「正则匹配到注释/字符串就假绿」。
|
||||
*
|
||||
* 函数体内部用的是 this.c / this.card, 故以 ctx 为 this 求值。
|
||||
*/
|
||||
function extractQrSrc(src) {
|
||||
const m = src.match(/qrSrc\(\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||||
assert.ok(m, 'mini.vue 找不到 qrSrc 计算属性');
|
||||
return m[1];
|
||||
}
|
||||
|
||||
/** 用构造器求值 qrSrc 函数体(只依赖 card/c 两个入参) */
|
||||
function evalQrSrc(card, config) {
|
||||
const body = extractQrSrc(miniSrc);
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('card', 'c', body);
|
||||
// ctx 需提供 qrPriority 计算属性(Vue 里 qrSrc 依赖 this.qrPriority)
|
||||
const ctx = {
|
||||
card: card,
|
||||
c: config,
|
||||
get qrPriority() { return evalQrPriority(config); },
|
||||
};
|
||||
return fn.call(ctx, card, config);
|
||||
}
|
||||
|
||||
/** 抽 qrPriority 计算属性体并求值(模式白名单化) */
|
||||
function evalQrPriority(config) {
|
||||
const m = miniSrc.match(/qrPriority\(\)\s*\{([\s\S]*?)\n\t\t\}/);
|
||||
assert.ok(m, 'mini.vue 找不到 qrPriority 计算属性');
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('c', m[1]);
|
||||
const ctx = { c: config };
|
||||
return fn.call(ctx, config);
|
||||
}
|
||||
|
||||
test('mini: qrSrc 源码按「名片级 > 组件级 > 系统码」取用', () => {
|
||||
const body = extractQrSrc(miniSrc);
|
||||
// 名片级: card.qr_image
|
||||
assert.ok(/card\.qr_image/.test(body), 'qrSrc 未消费名片级 card.qr_image');
|
||||
// 组件级: c.qrImage
|
||||
assert.ok(/c\.qrImage/.test(body), 'qrSrc 未保留组件级 c.qrImage');
|
||||
// 系统码兜底: card.qr_url
|
||||
assert.ok(/card\.qr_url/.test(body), 'qrSrc 未保留系统码兜底 card.qr_url');
|
||||
});
|
||||
|
||||
test('mini: qrSrc 真实求值 —— 名片级优先于组件级', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png' }
|
||||
);
|
||||
assert.equal(got, '/upload/own.png');
|
||||
});
|
||||
|
||||
test('mini: qrSrc 真实求值 —— 名片级为空时回落组件级', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png' }
|
||||
);
|
||||
assert.equal(got, '/upload/component.png');
|
||||
});
|
||||
|
||||
test('mini: qrSrc 真实求值 —— 两级皆空回落系统生成码(原行为)', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '', qr_url: 'http://x/q' },
|
||||
{ qrImage: '' }
|
||||
);
|
||||
assert.equal(got, 'http://x/q');
|
||||
});
|
||||
|
||||
test('mini: qrSrc 真实求值 —— 名片级优先(组件级也配了图时)', () => {
|
||||
// 「我的名片」组件: 员工登录后应看到属于该员工自己名片的那张图,
|
||||
// 即使装修时组件级也配了图 —— 名片级更贴近实体, 优先。
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/employee-own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/shop-level.png' }
|
||||
);
|
||||
assert.equal(got, '/upload/employee-own.png');
|
||||
});
|
||||
|
||||
test('mini: qrSrc 真实求值 —— card 为 null 不抛异常(未登录空态)', () => {
|
||||
const got = evalQrSrc(null, { qrImage: '/upload/component.png' });
|
||||
assert.equal(got, '/upload/component.png');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2.20.1 优先级模式(用户在 DIY 组件面板显式选择)
|
||||
// =============================================================
|
||||
|
||||
test('mini: qrPriority 白名单化 —— 缺失/非法值回落 card', () => {
|
||||
assert.equal(evalQrPriority({}), 'card');
|
||||
assert.equal(evalQrPriority({ qrPriority: '' }), 'card');
|
||||
assert.equal(evalQrPriority({ qrPriority: 'evil' }), 'card');
|
||||
assert.equal(evalQrPriority({ qrPriority: 'component' }), 'component');
|
||||
assert.equal(evalQrPriority({ qrPriority: 'system' }), 'system');
|
||||
});
|
||||
|
||||
test('mini: mode=card 名片级优先(默认; 员工登录看到自己那张)', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png', qrPriority: 'card' }
|
||||
);
|
||||
assert.equal(got, '/upload/own.png');
|
||||
});
|
||||
|
||||
test('mini: mode=card 名片级为空则用组件级', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png', qrPriority: 'card' }
|
||||
);
|
||||
assert.equal(got, '/upload/component.png');
|
||||
});
|
||||
|
||||
test('mini: mode=component 组件级优先于名片级', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png', qrPriority: 'component' }
|
||||
);
|
||||
assert.equal(got, '/upload/component.png');
|
||||
});
|
||||
|
||||
test('mini: mode=component 组件级为空则回落名片级', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '', qrPriority: 'component' }
|
||||
);
|
||||
assert.equal(got, '/upload/own.png');
|
||||
});
|
||||
|
||||
test('mini: mode=system 只用系统生成码(忽略全部自定义图)', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png', qrPriority: 'system' }
|
||||
);
|
||||
assert.equal(got, 'http://x/q');
|
||||
});
|
||||
|
||||
test('mini: mode=system 名片级有图也忽略', () => {
|
||||
const got = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '', qrPriority: 'system' }
|
||||
);
|
||||
assert.equal(got, 'http://x/q');
|
||||
});
|
||||
|
||||
test('mini: 未设置 qrPriority(存量实例)等价于 card', () => {
|
||||
const withMode = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png', qrPriority: 'card' }
|
||||
);
|
||||
const withoutMode = evalQrSrc(
|
||||
{ qr_image: '/upload/own.png', qr_url: 'http://x/q' },
|
||||
{ qrImage: '/upload/component.png' }
|
||||
);
|
||||
assert.equal(withoutMode, withMode);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 面板契约(两个组件的 design.html 都要有来源优先级控件)
|
||||
// =============================================================
|
||||
|
||||
const PANEL_DIR = path.resolve(__dirname, '../../backend/src/addon/card/component/view');
|
||||
const hasPanels = fs.existsSync(path.join(PANEL_DIR, 'diy-card-self/design.html'));
|
||||
|
||||
if (hasPanels) {
|
||||
const selfPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8'));
|
||||
const showPanel = strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8'));
|
||||
|
||||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||||
test('面板(' + name + '): 二维码来源优先级三档控件均存在', () => {
|
||||
for (const v of ['card', 'component', 'system']) {
|
||||
assert.ok(
|
||||
panel.includes("nc.qrPriority = '" + v + "'"),
|
||||
name + ' 面板缺优先级选项 ' + v
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('面板(' + name + '): 来源控件在「显示二维码」打开时才显示', () => {
|
||||
// 控件所在容器必须带 v-show="nc.showQr"
|
||||
const idx = panel.indexOf("nc.qrPriority = 'card'");
|
||||
assert.ok(idx > 0, name + ' 面板找不到来源控件');
|
||||
const head = panel.slice(Math.max(0, idx - 1200), idx);
|
||||
assert.ok(head.includes('v-show="nc.showQr"'), name + ' 面板来源控件未随显示二维码开关联动');
|
||||
});
|
||||
}
|
||||
|
||||
test('面板: 优先级默认值与 config 注册表一致(card)', () => {
|
||||
// config/diy_view.php 的 util 段默认值
|
||||
const cfgPath = path.resolve(__dirname, '../../backend/src/addon/card/config/diy_view.php');
|
||||
const cfg = fs.readFileSync(cfgPath, 'utf8');
|
||||
assert.ok(cfg.includes('"qrPriority":"card"'), 'config/diy_view.php 缺 qrPriority 默认值');
|
||||
});
|
||||
|
||||
test('面板: 预览真实消费 qrPriority(4 处二维码挂点走分层 v-if)', () => {
|
||||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||||
// 4 个挂点(head/corner/rows/bottom) 各一组分支:
|
||||
// system 占位 + component 组件图 + component 名片图 + card 名片图 + card 组件图 + 兜底占位
|
||||
const sys = (panel.match(/v-if="nc\.qrPriority === 'system'"/g) || []).length;
|
||||
const comp = (panel.match(/v-else-if="nc\.qrPriority === 'component'/g) || []).length;
|
||||
assert.equal(sys, 4, name + ' 面板 system 档分支数不对: ' + sys);
|
||||
// component 档每挂点 2 支(组件图 + 名片图回落) = 8
|
||||
assert.equal(comp, 8, name + ' 面板 component 档分支数不对: ' + comp);
|
||||
// 名片级图分支(每挂点 2 支: component 回落 + card 优先;
|
||||
// 每支出现 2 次: v-else-if 条件 + style 内取值) = 16
|
||||
const cardImg = (panel.match(/nc\.tempData\.previewCard && nc\.tempData\.previewCard\.qr_image/g) || []).length;
|
||||
assert.equal(cardImg, 16, name + ' 面板名片级图分支数不对: ' + cardImg);
|
||||
}
|
||||
});
|
||||
|
||||
test('面板: 预览不得调用 window 全局函数(Vue2 模板作用域不可见)', () => {
|
||||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||||
assert.ok(
|
||||
!/cardQrPreviewSrc\s*\(/.test(panel),
|
||||
name + ' 面板仍调用 window 全局函数 —— Vue2 模板会报「not defined on the instance」'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 2026-10-27: 「快捷操作管理」图层行的 color input 空值报错
|
||||
// 图层行 v-for 数组曾只有 {k,ch}(无 bg/fg), 而同行 :value 引用 a.fg/a.bg
|
||||
// → undefined → Vue 写 elm.value='' → Chrome CSS color 格式告警
|
||||
test('面板: 图层行 v-for 数组含 bg/fg(避免 color input 空值)', () => {
|
||||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||||
const m = panel.match(/v-for="a in (\[\{k:'tel'[\s\S]*?\}\])"/);
|
||||
assert.ok(m, name + ' 面板找不到图层行 v-for 数组');
|
||||
const arr = m[1];
|
||||
const items = arr.match(/\{k:'[a-z]+'/g) || [];
|
||||
assert.equal(items.length, 5, name + ' 面板图层行数组项数不对: ' + items.length);
|
||||
const bgCount = (arr.match(/bg:'#[0-9A-Fa-f]{6}'/g) || []).length;
|
||||
const fgCount = (arr.match(/fg:'#[0-9A-Fa-f]{6}'/g) || []).length;
|
||||
assert.equal(bgCount, 5, name + ' 面板图层行数组缺 bg: ' + bgCount);
|
||||
assert.equal(fgCount, 5, name + ' 面板图层行数组缺 fg: ' + fgCount);
|
||||
}
|
||||
});
|
||||
|
||||
test('面板: 图层行 color input 的 :value 不含未定义引用', () => {
|
||||
for (const [name, panel] of [['self', selfPanel], ['show', showPanel]]) {
|
||||
const colorBind = panel.match(/data-field="color"[^>]*:value="([^"]*)"/);
|
||||
const bgBind = panel.match(/data-field="bg"[^>]*:value="([^"]*)"/);
|
||||
assert.ok(colorBind, name + ' 面板找不到图层行 color 绑定');
|
||||
assert.ok(bgBind, name + ' 面板找不到图层行 bg 绑定');
|
||||
for (const [label, expr] of [['color', colorBind[1]], ['bg', bgBind[1]]]) {
|
||||
const refs = (expr.match(/\ba\.([a-z]+)/g) || []).map(x => x.slice(2));
|
||||
for (const r of Array.from(new Set(refs))) {
|
||||
assert.ok(
|
||||
['k', 'ch', 'lb', 'bg', 'fg'].includes(r),
|
||||
name + ' 面板图层行 ' + label + ' 绑定引用了数组里不存在的 a.' + r
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 反例(防恒真)
|
||||
// =============================================================
|
||||
|
||||
test('反例: 虚构字段绝不出现在基件', () => {
|
||||
assert.ok(!miniSrc.includes('c.qrMagic'));
|
||||
assert.ok(!miniSrc.includes('card.qrMagic'));
|
||||
});
|
||||
@@ -40,6 +40,13 @@ const miniSrc = strip(read('diy-card-mini.vue'));
|
||||
const hasPanels = fs.existsSync(path.join(PANEL_DIR, 'diy-card-self/design.html'));
|
||||
const showPanel = hasPanels ? strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-show/design.html'), 'utf8')) : '';
|
||||
const selfPanel = hasPanels ? strip(fs.readFileSync(path.join(PANEL_DIR, 'diy-card-self/design.html'), 'utf8')) : '';
|
||||
const BOX_STYLE_FIELDS = [
|
||||
'bandShow', 'bandBgMode', 'bandBg',
|
||||
'wrapMarginTop', 'wrapMarginRight', 'wrapMarginBottom', 'wrapMarginLeft',
|
||||
'headBgMode', 'headBg', 'headRadiusMode', 'headRadius', 'headSpacingCustom',
|
||||
'headMarginTop', 'headMarginRight', 'headMarginBottom', 'headMarginLeft',
|
||||
'headPaddingTop', 'headPaddingRight', 'headPaddingBottom', 'headPaddingLeft',
|
||||
];
|
||||
|
||||
/**
|
||||
* 面板控件的字段绑定有三种形态(2026-10-21 面板改版: 下拉框 → 可视化选项组,
|
||||
@@ -172,20 +179,76 @@ test('common: qrTransform 位移(任一轴非零才输出; WXSS 支持 translate
|
||||
// 2. mini.vue 基件契约
|
||||
// =============================================================
|
||||
|
||||
test('mini: 蓝色顶带版式(2026-10-23 与精选名片 show 同款: 自适应白卡高度 + 弧线距底 88rpx)', () => {
|
||||
// 模板: 卡片包进 wrap, 顶带在最前
|
||||
test('mini: 顶部色带默认隐藏,背景支持同步名片设计或独立颜色', () => {
|
||||
assert.match(miniSrc, /class="mini-card-wrap"/);
|
||||
assert.match(miniSrc, /class="mini-band" :style="bandStyle"/);
|
||||
assert.match(miniSrc, /<view v-if="bandShow" class="mini-band" :style="bandStyle"><\/view>/);
|
||||
assert.match(miniSrc, /bandShow\(\)\s*\{\s*return flagOn\(this\.c\.bandShow, false\)/);
|
||||
assert.match(miniSrc, /bandStyle\(\)\s*\{[\s\S]*?this\.c\.bandBgMode === 'custom'[\s\S]*?themeGradient\(this\.card\)/);
|
||||
assert.match(miniSrc, /class="mini-card fade-up" :style="wrapStyle"/);
|
||||
// 计算属性: 顶带底色可自定义(bandBg)或回落主题渐变; 高度可配(bandHeight)
|
||||
assert.match(miniSrc, /bandStyle\(\)\s*\{[\s\S]*?bandBg[\s\S]*?themeGradient\(this\.card\)/);
|
||||
// CSS: wrap 撑露沿 + band 拉伸(top:-40rpx / bottom:88rpx) + 圆角(顶直底弧)
|
||||
// 保留原色带造型,显隐和背景只改内联消费,不改几何结构。
|
||||
assert.match(miniSrc, /\.mini-card-wrap \{\s*position: relative;\s*margin-top: 40rpx;/);
|
||||
assert.match(miniSrc, /\.mini-band \{\s*position: absolute;[\s\S]*?top: -40rpx;[\s\S]*?bottom: 88rpx;[\s\S]*?border-radius: 0 0 28rpx 28rpx;/);
|
||||
// mini-block 方案不得回归
|
||||
assert.ok(!miniSrc.includes('mini-block'));
|
||||
});
|
||||
|
||||
test('mini: 卡片四向外边距与头部盒模型配置完整消费', () => {
|
||||
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
||||
assert.match(miniSrc, new RegExp('margin' + side + ': px2rpx\\(num\\(c\\.wrapMargin' + side + ', 0\\)\\)'));
|
||||
assert.match(miniSrc, new RegExp('c\\.headMargin' + side));
|
||||
assert.match(miniSrc, new RegExp('c\\.headPadding' + side));
|
||||
}
|
||||
assert.match(miniSrc, /headBoxStyle\(\)\s*\{[\s\S]*?c\.headBgMode === 'custom'[\s\S]*?this\.headTheme \? themeGradient\(this\.card\) : 'transparent'/);
|
||||
assert.match(miniSrc, /headThemeStyle\(\)\s*\{\s*return this\.headBoxStyle/);
|
||||
assert.match(miniSrc, /plainHeadFinalStyle\(\)\s*\{[\s\S]*?this\.cornerHeadStyle[\s\S]*?return \[this\.headBoxStyle, corner\]/);
|
||||
});
|
||||
|
||||
test('mini: C端盒模型计算值——历史默认兼容且自定义值逐项生效', async () => {
|
||||
const m = await import('../components-diy/diy-card-common.js');
|
||||
const script = miniSrc.match(/<script>([\s\S]*?)<\/script>/)[1]
|
||||
.replace(/import\s*\{[\s\S]*?\}\s*from\s*'\.\/diy-card-common\.js';/, '')
|
||||
.replace('export default {', 'module.exports = {');
|
||||
const names = Object.keys(m);
|
||||
const moduleBox = { exports: {} };
|
||||
new Function('module', ...names, script)(moduleBox, ...names.map(k => m[k]));
|
||||
const computed = moduleBox.exports.computed;
|
||||
const util = { objToStyle: o => o };
|
||||
const card = { theme_color: '#119DFF', gradient_end: '#0E81D1', gradient_angle: 135 };
|
||||
|
||||
assert.equal(computed.bandShow.call({ c: {} }), false, '历史配置色带必须默认隐藏');
|
||||
assert.equal(computed.bandStyle.call({ c: {}, card, $util: util }).background.includes('linear-gradient'), true);
|
||||
assert.equal(computed.bandStyle.call({ c: { bandBgMode: 'custom', bandBg: '#123456' }, card, $util: util }).background, '#123456');
|
||||
|
||||
const wrap = computed.wrapStyle.call({
|
||||
c: { wrapMarginTop: 11, wrapMarginRight: 12, wrapMarginBottom: 13, wrapMarginLeft: 14, padding: 22 },
|
||||
card, $util: util,
|
||||
});
|
||||
assert.deepEqual([wrap.marginTop, wrap.marginRight, wrap.marginBottom, wrap.marginLeft], ['22rpx', '24rpx', '26rpx', '28rpx']);
|
||||
assert.equal(wrap.padding, '44rpx');
|
||||
|
||||
const autoHead = computed.headBoxStyle.call({ c: { padding: 16 }, headTheme: true, card, $util: util });
|
||||
assert.deepEqual(
|
||||
[autoHead.marginTop, autoHead.marginRight, autoHead.marginBottom, autoHead.marginLeft],
|
||||
['-32rpx', '-32rpx', '0rpx', '-32rpx']
|
||||
);
|
||||
assert.deepEqual(
|
||||
[autoHead.paddingTop, autoHead.paddingRight, autoHead.paddingBottom, autoHead.paddingLeft],
|
||||
['32rpx', '32rpx', '28rpx', '32rpx']
|
||||
);
|
||||
assert.equal(autoHead.borderRadius, '0 0 16rpx 16rpx');
|
||||
const customCfg = {
|
||||
headBgMode: 'custom', headBg: '#ABCDEF', headRadiusMode: 'custom', headRadius: 18, headSpacingCustom: 1,
|
||||
headMarginTop: 1, headMarginRight: 2, headMarginBottom: 3, headMarginLeft: 4,
|
||||
headPaddingTop: 5, headPaddingRight: 6, headPaddingBottom: 7, headPaddingLeft: 8,
|
||||
};
|
||||
for (const headTheme of [true, false]) {
|
||||
const style = computed.headBoxStyle.call({ c: customCfg, headTheme, card, $util: util });
|
||||
assert.equal(style.background, '#ABCDEF');
|
||||
assert.equal(style.borderRadius, '36rpx');
|
||||
assert.deepEqual([style.marginTop, style.marginRight, style.marginBottom, style.marginLeft], ['2rpx', '4rpx', '6rpx', '8rpx']);
|
||||
assert.deepEqual([style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft], ['10rpx', '12rpx', '14rpx', '16rpx']);
|
||||
}
|
||||
});
|
||||
|
||||
test('mini: 企业徽标固定红底(参考图口径, 与头像底色解耦)', () => {
|
||||
assert.match(miniSrc, /badgeStyle\(\)\s*\{\s*return this\.\$util\.objToStyle\(\{\s*background: '#FA5151'\s*\}\);/);
|
||||
});
|
||||
@@ -248,8 +311,9 @@ test('mini: cornerClearStyle 按实际尺寸反算让位(防遮挡, 纯数值不
|
||||
test('mini: cornerHeadStyle 头部水平让位按浮层实际宽度动态反算', () => {
|
||||
// 审计修复: 静态 180rpx 在自定义 qrSize>120 时不足 → 头部让位动态化
|
||||
assert.match(miniSrc, /cornerHeadStyle\(\)\s*\{[\s\S]*?if \(this\.qrPos !== 'corner' \|\| !this\.showQrBase\) return \{\};/);
|
||||
// 浮层宽[尺寸+衬底] + 右偏移 20 + 呼吸 20 (+ 2.20 逐向微调 customRight)
|
||||
assert.match(miniSrc, /paddingRight: \(size \+ pad \+ 40(?:\s*\+\s*customRight)?\) \+ 'rpx'/);
|
||||
// 浮层宽[尺寸+衬底] + 右偏移 20 + 呼吸 20 + 2.20 逐向微调右位移(0=关)
|
||||
assert.match(miniSrc, /const customRight = flagOn\(this\.c\.qrSpacingCustom, false\) \? px2rpx\(num\(this\.c\.qrMarginRight, 0\)\) : 0;/);
|
||||
assert.match(miniSrc, /paddingRight: \(size \+ pad \+ 40 \+ customRight\) \+ 'rpx'/);
|
||||
// 模板头部绑定了动态让位(浮层不存在时输出空对象不占位)
|
||||
assert.match(miniSrc, /<view class="mini-head" :style="cornerHeadStyle">/);
|
||||
});
|
||||
@@ -274,6 +338,12 @@ test('self: miniConfig 透传头像+二维码新六字段', () => {
|
||||
assert.match(selfSrc, /qrTipShow:\s*v\.qrTipShow/);
|
||||
});
|
||||
|
||||
test('self: miniConfig 透传色带、卡片外边距与头部盒模型字段', () => {
|
||||
for (const f of BOX_STYLE_FIELDS) {
|
||||
assert.match(selfSrc, new RegExp(f + ':\\s*v\\.' + f + ','), 'miniConfig 缺透传 ' + f);
|
||||
}
|
||||
});
|
||||
|
||||
test('self: 「我的名片」角标与未登录提示条均已移除(2026-10-23 用户定稿)', () => {
|
||||
// 角标按 2026-10-23 参考图删除, 反例防回归
|
||||
assert.ok(!selfSrc.includes('self-badge') && !selfSrc.includes('slot="badge"'));
|
||||
@@ -298,6 +368,7 @@ test('show: miniConfig 全字段透传(方案A定稿: C端=diy-card-mini 基件=
|
||||
'headStyle', 'headNameColor', 'headSubColor', 'actsLayout', 'actGridColorMode',
|
||||
'showRowsTitle', 'rowsTitle', 'showCopyBtn', 'actsPosition', 'actsBlockBg', 'actsBlockRadius', 'actsBlockPad',
|
||||
'actIcons', 'actVis', 'actDeleted', 'actLabels', 'actOrder', 'customActs',
|
||||
...BOX_STYLE_FIELDS,
|
||||
];
|
||||
for (const f of fields) {
|
||||
assert.match(showSrc, new RegExp(f + ':\\s*v\\.' + f + ','), 'miniConfig 缺透传 ' + f);
|
||||
@@ -329,6 +400,18 @@ for (const [name, panel] of hasPanels ? [['show', showPanel], ['self', selfPanel
|
||||
}
|
||||
});
|
||||
|
||||
test(name + ' 面板: 色带/卡片/头部盒模型字段均有控件且预览真实消费', () => {
|
||||
const preview = panel.split('<template slot="edit-content">')[0];
|
||||
for (const f of BOX_STYLE_FIELDS) {
|
||||
assert.ok(bound(panel, f), '面板缺控件绑定 ' + f);
|
||||
assert.ok(preview.includes('nc.' + f), '预览缺消费 ' + f);
|
||||
}
|
||||
assert.match(panel, /nc\.bandBgMode = 'theme'/);
|
||||
assert.match(panel, /nc\.bandBgMode = 'custom'/);
|
||||
assert.match(panel, /nc\.headBgMode = 'auto'/);
|
||||
assert.match(panel, /nc\.headBgMode = 'custom'/);
|
||||
});
|
||||
|
||||
test(name + ' 面板: 预览 mock 消费新属性(属性变更预览同步)', () => {
|
||||
// 头像开关
|
||||
assert.ok(panel.includes('nc.showAvatar !== false'), '预览不消费 showAvatar');
|
||||
|
||||
Reference in New Issue
Block a user