Files
wordpress-theme-aksrt/tests/widget-system/vip-settings-structured-contract.test.cjs
T
kate522 4b5098a753 fix(vip): restore the upgrade-price accordion and space out structured groups
升级价格 declared three reference-theme blocks through the accordion sections option, but the section builder handed the computed the whole field object instead of its sections array, so the guard always saw an empty length and the renderer silently fell back to a flat list. The three blocks ran into each other with no separation at all.

- Move the section expansion into a pure buildFieldsetSections() helper so the mistake is now covered by a real contract test that runs the actual schema through it, instead of a regex over the component source.
- Apply dependsOn to fields inside each block, so 天单价/促销标签 hide when 月费会员升级月费会员 is switched off, matching the flat path.
- Give structured groups (会员商品/续费商品/积分兑换明细/升级价格) their own bottom margin and widen the gap between accordion blocks, so two adjacent groups no longer sit flush against each other.

Verified: 657 Node contract tests passing, the admin SPA builds.
2026-10-18 19:24:00 +08:00

483 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* VIP 后台设置结构化契约(对照参考主题 zibll 1:1)。
*
* 硬约束:
* 1) 会员商品 / 续费商品 / 升级价格 / 积分兑换明细全部为结构化 group 或
* accordion,`|` 分隔的 textarea 配置已彻底移除(不写双路/迁移);
* 2) 「会员等级」组只留导航按钮与两个等级开关,等级身份字段(显示名称/
* 简介/权益HTML/图标URL)与「会员商品」同处「会员购买选项」的等级 Tab;
* 3) 会员商品字段集恰好 6 项:执行价/原价/促销标签/标签颜色/会员有效时间/
* 时长单位;标签颜色为参考主题 b+jb 全量 16 色;
* 4) 续费价格类型含「自定义商品」,升级价格为参考主题同款三块 accordion;
* 5) tag_class 一路透传到前端套餐卡;等级 Tab 标题由显示名称动态驱动。
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { execFileSync } = require('node:child_process');
const root = path.join(__dirname, '..', '..');
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
const vipSchemaJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'vip.js');
const schemaPhp = read('src', 'Admin', 'Schema', 'SchemaPayment.php');
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
const packagesTrait = read('src', 'Content', 'VipServicePackages.php');
const purchaseSource = read('src', 'Content', 'VipServicePurchase.php');
const modalSource = read('src', 'Content', 'VipServiceModal.php');
const rendererVue = read('assets', 'admin', 'src', 'components', 'form', 'AksFormRenderer.vue');
const fieldVue = read('assets', 'admin', 'src', 'components', 'form', 'AksField.vue');
const payVipJs = read('assets', 'js', 'pay-vip.js');
const vipTabPhp = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
const homeCms = read('src', 'Settings', 'HomeCms.php');
const TAG_PALETTE = [
'b-red', 'b-yellow', 'b-cyan', 'b-blue', 'b-green', 'b-purple', 'b-black',
'jb-red', 'jb-pink', 'jb-yellow', 'jb-cyan', 'jb-blue', 'jb-green', 'jb-purple',
'jb-vip1', 'jb-vip2',
];
const PRODUCT_FIELDS = ['price', 'show_price', 'tag', 'tag_class', 'time', 'unit'];
const UPGRADE_FIELDS = [
'month_to_month_s', 'unit_price', 'unit_tag', 'unit_tag_class',
'jump_s', 'jump_price', 'jump_show_price', 'jump_tag', 'jump_tag_class',
'permanent_price', 'permanent_show_price', 'permanent_tag', 'permanent_tag_class',
];
const LEGACY_KEYS = [
'payment_vip_1_products',
'payment_vip_2_products',
'payment_vip_points_packages',
];
function runHarness() {
const out = execFileSync(process.env.PHP_BINARY || 'php', [
path.join(__dirname, 'vip-settings-structured-harness.php'),
], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
return JSON.parse(out);
}
/** 在 SPA schema 里按 key 递归查找字段定义。 */
function findGroup(schema, key) {
const walk = (nodes) => {
for (const node of nodes) {
if (node.key === key) return node
if (Array.isArray(node.tabs)) {
for (const tab of node.tabs) {
const hit = walk(tab.fields || [])
if (hit) return hit
}
}
if (Array.isArray(node.fields)) {
const hit = walk(node.fields)
if (hit) return hit
}
}
return null
}
return walk(schema.groups)
}
// 只切 VIP 设置函数:充值/积分设置里的 products_raw 与本文无关。
function vipSettingsSlice() {
const start = paymentRuntime.indexOf('public static function get_payment_vip_settings');
assert.notStrictEqual(start, -1, 'missing get_payment_vip_settings');
const end = paymentRuntime.indexOf('public static function get_payment_revenue_settings', start);
return paymentRuntime.slice(start, end === -1 ? undefined : end);
}
let harness;
test('PHP harness 可执行并产出结构化设置', () => {
harness = runHarness();
assert.ok(harness && harness.schema && harness.runtime, 'harness 输出不完整');
});
test('旧 `|` 分隔配置键已从两份 schema 与运行时彻底移除', () => {
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp], ['PaymentRuntime.php', paymentRuntime]]) {
for (const key of LEGACY_KEYS) {
assert.ok(!src.includes(key), `${name} 不应再含 ${key}`);
}
}
// 运行时不再暴露任何 *_raw 原始字符串。
const vipRuntime = vipSettingsSlice();
for (const key of ['products_raw', 'packages_raw']) {
assert.ok(!vipRuntime.includes(`'${key}'`), `PaymentRuntime VIP 设置不应再产出 ${key}`);
}
assert.ok(!vipRuntime.includes("'product' => sanitize_textarea_field"), 'upgrade.product 管道配置应已移除');
for (const key of LEGACY_KEYS) {
assert.ok(!vipRuntime.includes(key), `PaymentRuntime VIP 设置不应再读 ${key}`);
}
});
test('会员等级组只留导航按钮与等级开关,身份字段搬进等级 Tab', () => {
const basic = harness.schema.basic_field_keys;
assert.deepStrictEqual(basic, [
'nav_pay_vip', 'nav_user_pay_vip', 'pay_user_vip_1_s', 'vip_level_order_notice', 'pay_user_vip_2_s',
]);
const tabs = harness.schema.opt_tabs;
assert.deepStrictEqual(Object.keys(tabs), ['vip1', 'vip2', 'renew', 'upgrade']);
for (const level of [1, 2]) {
const fields = Object.keys(tabs[`vip${level}`].fields);
assert.deepStrictEqual(fields, [
`pay_user_vip_${level}_name`,
`pay_user_vip_${level}_desc`,
`pay_user_vip_${level}_equity`,
`vip_${level}_product_s`,
`vip_${level}_product`,
`vip_${level}_img_icon`,
]);
assert.strictEqual(tabs[`vip${level}`].fields[`vip_${level}_img_icon`].type, 'upload');
}
});
test('等级 Tab 标题由显示名称动态驱动', () => {
for (const level of [1, 2]) {
const tab = harness.schema.opt_tabs[`vip${level}`];
assert.strictEqual(tab.titleKey, `pay_user_vip_${level}_name`);
assert.strictEqual(tab.titleFallback, level === 1 ? '一级会员' : '二级会员');
}
// 会员权益的两个等级 Tab 同样动态。
assert.strictEqual(harness.schema.benefit_tabs.vip1.titleKey, 'pay_user_vip_1_name');
assert.strictEqual(harness.schema.benefit_tabs.vip2.titleKey, 'pay_user_vip_2_name');
// 渲染器支持 titleKey / titleFallback。
assert.match(rendererVue, /:name="tabName\(tab\)"/);
assert.match(rendererVue, /:tab="tabName\(tab\)"/);
assert.match(rendererVue, /function tabName\(tab\) \{[\s\S]*?tab\.titleKey[\s\S]*?tab\.titleFallback/);
// 手风琴:默认收起,点击标题行展开。
assert.match(fieldVue, /const repeaterCollapsible = computed\(\(\) => !!props\.schema\.collapsible\)/);
assert.match(fieldVue, /const repeaterDefaultOpen = computed\(\(\) => props\.schema\.defaultCollapsed === false\)/);
assert.match(fieldVue, /function toggleRepeaterItem\(index\)/);
assert.match(fieldVue, /v-show="!repeaterCollapsible \|\| isRepeaterItemOpen\(index\)"/);
assert.match(fieldVue, /aksrt-field__repeater-summary/);
// accordion/fieldset 的 sections 分块手风琴。
assert.match(fieldVue, /const fieldsetSections = computed\(/);
assert.match(fieldVue, /function toggleFieldsetSection\(index\)/);
assert.match(fieldVue, /aksrt-field__accordion-section-head/);
});
test('结构化分组默认收起,标题行带摘要;升级价格按三块手风琴渲染', () => {
const accordion = harness.accordion;
// 会员商品 / 续费商品 / 积分兑换明细均默认收起。
const expectedGroups = [
'vip1/vip_1_product',
'vip2/vip_2_product',
'renew/vip_1_renew_product',
'renew/vip_2_renew_product',
'points-exchange/pay_vip_points_exchange_product',
];
assert.deepStrictEqual(Object.keys(accordion.groups).sort(), [...expectedGroups].sort());
for (const key of expectedGroups) {
const cfg = accordion.groups[key];
assert.strictEqual(cfg.collapsible, true, `${key} 应开启手风琴`);
assert.strictEqual(cfg.defaultCollapsed, true, `${key} 应默认收起`);
assert.ok(cfg.listPreview && cfg.listPreview.titleKey, `${key} 应配置标题行摘要`);
assert.ok(cfg.listPreview.descKey, `${key} 应配置标题行时长摘要`);
assert.strictEqual(cfg.listPreview.descUnitKey, 'unit', `${key} 时长摘要应带单位`);
}
// 会员商品与续费商品摘要前缀为「价格:」,积分兑换为「积分:」。
assert.strictEqual(accordion.groups['vip1/vip_1_product'].listPreview.prefix, '价格:');
assert.strictEqual(accordion.groups['renew/vip_1_renew_product'].listPreview.prefix, '价格:');
assert.strictEqual(accordion.groups['points-exchange/pay_vip_points_exchange_product'].listPreview.prefix, '积分:');
assert.strictEqual(accordion.groups['points-exchange/pay_vip_points_exchange_product'].listPreview.titleKey, 'points');
// 升级价格拆成三块,且每块覆盖的字段并集恰好等于全部字段。
const sections = accordion.sections['upgrade/vip_upgrade_product'];
assert.strictEqual(sections.length, 3);
assert.deepStrictEqual(sections.map((s) => s.title), [
'月费会员升级月费会员',
'月费会员升级永久会员',
'永久会员升级永久会员',
]);
const covered = sections.flatMap((s) => s.keys).sort();
const declared = harness.schema.opt_tabs.upgrade.fields.vip_upgrade_product.fields
.map((f) => f.key)
.sort();
assert.deepStrictEqual(covered, declared, 'sections 必须覆盖升级价格的全部字段');
});
test('会员商品字段集恰好 6 项且标签颜色为参考主题 16 色', () => {
const group = harness.schema.opt_tabs.vip1.fields.vip_1_product;
assert.strictEqual(group.type, 'group');
assert.strictEqual(group.max, 8);
assert.strictEqual(group.button_title, '添加会员商品');
assert.deepStrictEqual(
group.fields.filter((f) => !f.ui_only).map((f) => f.key),
PRODUCT_FIELDS
);
assert.strictEqual(group.fields.find((f) => f.key === 'price').sanitize_type, 'money_decimal');
assert.strictEqual(group.fields.find((f) => f.key === 'show_price').sanitize_type, 'money_decimal');
assert.strictEqual(group.fields.find((f) => f.key === 'time').type, 'spinner');
assert.strictEqual(group.fields.find((f) => f.key === 'time').max, 3600);
assert.deepStrictEqual(
group.fields.find((f) => f.key === 'unit').options.map((o) => o.value),
['day', 'month']
);
const palette = group.fields.find((f) => f.key === 'tag_class');
assert.strictEqual(palette.type, 'palette');
assert.strictEqual(palette.default, 'jb-yellow');
assert.deepStrictEqual(palette.options.map((o) => o.value), TAG_PALETTE);
// 标签颜色仅在填写了促销标签时出现。
assert.deepStrictEqual(palette.dependsOn, { key: 'tag', operator: 'not_empty' });
// 时长单位在 time 为 0(永久)时隐藏。
assert.deepStrictEqual(group.fields.find((f) => f.key === 'unit').dependsOn, { key: 'time', operator: 'truthy' });
// 二级会员 Tab 复用同一字段集。
assert.deepStrictEqual(
harness.schema.opt_tabs.vip2.fields.vip_2_product.fields.filter((f) => !f.ui_only).map((f) => f.key),
PRODUCT_FIELDS
);
});
test('续费价格类型含自定义商品,折扣为折值滑块', () => {
const renew = harness.schema.opt_tabs.renew.fields;
assert.deepStrictEqual(renew.vip_renew_price_type.options.map((o) => o.value), [
'same', 'discount', 'reduce', 'customize',
]);
const discount = renew.vip_renew_discount;
assert.strictEqual(discount.type, 'slider');
assert.strictEqual(discount.sanitize_type, 'float');
assert.strictEqual(discount.min, 0.1);
assert.strictEqual(discount.max, 9.9);
assert.strictEqual(discount.step, 0.1);
assert.strictEqual(renew.vip_renew_reduce.type, 'spinner');
assert.strictEqual(renew.vip_renew_reduce.max, 500);
assert.strictEqual(renew.vip_renew_reduce.step, 5);
// 自定义续费商品组仅在 customize 时出现。
for (const level of [1, 2]) {
const group = renew[`vip_${level}_renew_product`];
assert.strictEqual(group.type, 'group');
assert.strictEqual(group.max, 8);
assert.strictEqual(group.button_title, '添加续费商品');
assert.deepStrictEqual(group.dependsOn, { key: 'vip_renew_price_type', value: 'customize' });
assert.deepStrictEqual(
group.fields.filter((f) => !f.ui_only).map((f) => f.key),
PRODUCT_FIELDS
);
}
});
test('升级价格为参考主题同款三块 accordion', () => {
const upgrade = harness.schema.opt_tabs.upgrade.fields.vip_upgrade_product;
assert.strictEqual(upgrade.type, 'accordion');
const keys = upgrade.fields.filter((f) => !f.ui_only).map((f) => f.key);
assert.deepStrictEqual(keys, UPGRADE_FIELDS);
assert.strictEqual(upgrade.fields.find((f) => f.key === 'month_to_month_s').default, true);
assert.strictEqual(upgrade.fields.find((f) => f.key === 'jump_s').default, false);
for (const key of ['unit_price', 'jump_price', 'jump_show_price', 'permanent_price', 'permanent_show_price']) {
assert.strictEqual(upgrade.fields.find((f) => f.key === key).sanitize_type, 'money_decimal', `${key} 应为 money_decimal`);
}
for (const key of ['unit_tag_class', 'jump_tag_class', 'permanent_tag_class']) {
const field = upgrade.fields.find((f) => f.key === key);
assert.strictEqual(field.type, 'palette', `${key} 应为 palette`);
assert.deepStrictEqual(field.options.map((o) => o.value), TAG_PALETTE);
}
});
test('积分兑换明细为结构化 group,时长为 time + Permanent 语义', () => {
assert.deepStrictEqual(harness.schema.points_fields, [
'pay_vip_points_exchange_s', 'pay_vip_points_exchange_product', 'pay_vip_points_exchange_desc',
]);
const group = harness.schema.points_group;
assert.strictEqual(group.type, 'group');
assert.strictEqual(group.max, 16);
assert.strictEqual(group.button_title, '添加积分兑换商品');
assert.deepStrictEqual(group.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']);
assert.strictEqual(group.fields.find((f) => f.key === 'points').type, 'number');
assert.strictEqual(group.fields.find((f) => f.key === 'level').type, 'radio');
assert.strictEqual(group.fields.find((f) => f.key === 'time').type, 'input');
// 永久时隐藏时长单位。
assert.deepStrictEqual(group.fields.find((f) => f.key === 'unit').dependsOn, { key: 'time', operator: 'neq', value: 'Permanent' });
});
test('运行时归一化:默认值、价格净化、零价与非法行剔除', () => {
const defaults = harness.runtime.defaults;
// 参考主题默认两行。
assert.strictEqual(defaults.levels['1'].products.length, 2);
assert.strictEqual(defaults.levels['1'].products[0].price, '99.00');
assert.strictEqual(defaults.levels['1'].products[0].time, 3);
assert.strictEqual(defaults.levels['1'].products[0].unit, 'month');
assert.strictEqual(defaults.levels['1'].products[0].days, 90);
assert.strictEqual(defaults.levels['1'].renew_products[0].price, '69.00');
assert.strictEqual(defaults.levels['2'].renew_products[0].price, '269.00');
// 积分兑换默认两行(含 Permanent)。
assert.strictEqual(defaults.points_exchange.packages.length, 2);
assert.strictEqual(defaults.points_exchange.packages[1].is_permanent, true);
assert.strictEqual(defaults.points_exchange.packages[1].days, 0);
// 续费/升级默认值。
assert.strictEqual(defaults.renew.price_type, 'discount');
assert.strictEqual(defaults.renew.discount, 8);
assert.strictEqual(defaults.upgrade.month_to_month_s, true);
assert.strictEqual(defaults.upgrade.jump_s, false);
assert.strictEqual(defaults.upgrade.unit_price, '10.00');
assert.strictEqual(defaults.upgrade.unit_tag_class, 'jb-yellow');
assert.strictEqual(defaults.upgrade.jump_tag_class, 'jb-red');
const custom = harness.runtime.custom;
// 零价行被剔除;价格规范化为两位小数。
assert.strictEqual(custom.levels['1'].products.length, 2);
assert.strictEqual(custom.levels['1'].products[0].price, '88.80');
assert.strictEqual(custom.levels['1'].products[0].tag_class, 'jb-green');
// 非法标签颜色回落为空串。
assert.strictEqual(custom.levels['2'].products[0].tag_class, '');
// 自定义续费价格类型与折扣。
assert.strictEqual(custom.renew.price_type, 'customize');
assert.strictEqual(custom.renew.discount, 7.5);
assert.strictEqual(custom.renew.desc, '续费有礼');
assert.strictEqual(custom.levels['1'].renew_products[0].tag_class, 'b-red');
// 升级三块参数透传。
assert.strictEqual(custom.upgrade.unit_price, '12.50');
assert.strictEqual(custom.upgrade.unit_tag_class, 'jb-blue');
assert.strictEqual(custom.upgrade.jump_s, true);
assert.strictEqual(custom.upgrade.permanent_tag_class, 'jb-vip2');
// 积分兑换:非法行(积分为 0 / 等级越界 / 时长为空)被剔除。
assert.strictEqual(custom.points_exchange.packages.length, 3);
assert.strictEqual(custom.points_exchange.packages[0].days, 30);
assert.strictEqual(custom.points_exchange.packages[1].is_permanent, true);
assert.strictEqual(custom.points_exchange.packages[2].days, 90);
});
test('旧 `|` 管道存量值按新默认值回退,不会渲染成假条目', () => {
const legacy = harness.runtime.legacy;
assert.strictEqual(legacy.levels['1'].products.length, 2);
assert.strictEqual(legacy.levels['1'].products[0].price, '99.00');
assert.strictEqual(legacy.points_exchange.packages.length, 2);
assert.strictEqual(legacy.upgrade.unit_price, '10.00');
});
test('续费计价:折值/立减/自定义商品三种语义', () => {
const p = harness.pricing;
// 相同价格:不改价、不展示原价。
assert.strictEqual(p.same.price, '100.00');
assert.strictEqual(p.same.show_price, '0.00');
// 8 折:执行价 80,原价划线为 100。
assert.strictEqual(p.discount_8.price, '80.00');
assert.strictEqual(p.discount_8.show_price, '100.00');
assert.strictEqual(p.discount_8.tag, '8折优惠');
// 支持小数折。
assert.strictEqual(p.discount_7_5.price, '75.00');
assert.strictEqual(p.discount_7_5.tag, '7.5折优惠');
// 超出滑块上限时按 9.9 折钳制(参考主题 slider max)。
assert.strictEqual(p.discount_10_clamped.price, '99.00');
assert.strictEqual(p.discount_10_clamped.tag, '9.9折优惠');
// 立减 20:执行价 80。
assert.strictEqual(p.reduce_20.price, '80.00');
assert.strictEqual(p.reduce_20.show_price, '100.00');
assert.strictEqual(p.reduce_20.tag, '续费立减¥20.00');
// 自定义商品:沿用商品自身的原价、促销标签与标签颜色。
assert.strictEqual(p.customize.price, '100.00');
assert.strictEqual(p.customize.show_price, '200.00');
assert.strictEqual(p.customize.tag, '限时');
assert.strictEqual(p.customize.tag_class, 'jb-red');
// 开通动作不套用续费规则。
assert.strictEqual(p.pay.price, '100.00');
assert.strictEqual(p.pay.show_price, '0.00');
assert.strictEqual(p.pay.tag, '');
});
test('tag_class 透传到前端套餐卡与积分兑换弹窗', () => {
assert.match(payVipJs, /item\.tag_class \|\| "jb-yellow"/);
// 弹窗时长行按 time/unit 渲染,Permanent 走永久。
assert.match(modalSource, /\$is_permanent = !empty\(\$package\['is_permanent'\]\)/);
assert.match(modalSource, /\$pkg_unit === 'month' \? __\('个月', 'aksrt'\) : __\('天', 'aksrt'\)/);
// 用户中心档位卡消费续费/升级简介。
assert.match(vipTabPhp, /\$renew_desc = \(string\) \(\$vip_settings\['renew'\]\['desc'\]/);
assert.match(vipTabPhp, /\$upgrade_desc = \(string\) \(\$vip_settings\['upgrade'\]\['desc'\]/);
});
test('管道解析死代码已清理', () => {
for (const [name, src] of [['VipServicePackages.php', packagesTrait], ['VipServicePurchase.php', purchaseSource]]) {
assert.ok(!/function parse_level_products/.test(src), `${name} 不应再定义 parse_level_products`);
assert.ok(!/function parse_packages\(/.test(src), `${name} 不应再定义 parse_packages`);
assert.ok(!/function parse_upgrade_product_line/.test(src), `${name} 不应再定义 parse_upgrade_product_line`);
assert.ok(!/preg_split\('\/\\r\\n\|\\r\|\\n\/'/.test(src), `${name} 不应再按行拆分管道配置`);
}
// HomeCms 的零调用 PaymentRuntime 代理方法已删除。
for (const method of ['get_payment_vip_settings', 'get_payment_recharge_settings', 'get_withdrawal_settings']) {
assert.ok(!homeCms.includes(`function ${method}`), `HomeCms 不应再含死代理 ${method}`);
}
});
test('SPA 镜像 schema 与 PHP schema 同构', async () => {
const mod = await import(pathToFileURL(path.join(root, 'assets/admin/src/schema/sections/payment/vip.js')).href);
const schema = mod.vipSchema;
assert.deepStrictEqual(schema.groups.map((g) => g.key), [
'notice', 'basic', 'benefit', 'opt', 'points-exchange', 'modal-copy',
]);
const opt = schema.groups.find((g) => g.key === 'opt').fields[0];
assert.strictEqual(opt.type, 'tabbed');
assert.deepStrictEqual(opt.tabs.map((t) => t.key), ['vip1', 'vip2', 'renew', 'upgrade']);
for (const level of [1, 2]) {
const tab = opt.tabs[level - 1];
assert.strictEqual(tab.titleKey, `pay_user_vip_${level}_name`);
assert.deepStrictEqual(tab.fields.map((f) => f.key), [
`pay_user_vip_${level}_name`,
`pay_user_vip_${level}_desc`,
`pay_user_vip_${level}_equity`,
`vip_${level}_product_s`,
`vip_${level}_product`,
`vip_${level}_img_icon`,
]);
}
assert.deepStrictEqual(
opt.tabs[2].fields.find((f) => f.key === 'vip_renew_price_type').options.map((o) => o.value),
['same', 'discount', 'reduce', 'customize']
);
assert.deepStrictEqual(
opt.tabs[3].fields.find((f) => f.key === 'vip_upgrade_product').fields.filter((f) => !f.ui_only).map((f) => f.key),
UPGRADE_FIELDS
);
const points = schema.groups.find((g) => g.key === 'points-exchange').fields[1];
assert.strictEqual(points.type, 'group');
assert.deepStrictEqual(points.fields.map((f) => f.key), ['points', 'level', 'time', 'unit']);
// 手风琴配置与 PHP 侧一致。
for (const key of ['vip_1_product', 'vip_2_product', 'vip_1_renew_product', 'vip_2_renew_product']) {
const group = findGroup(schema, key);
assert.strictEqual(group.collapsible, true, `${key} 应开启手风琴`);
assert.strictEqual(group.defaultCollapsed, true, `${key} 应默认收起`);
}
assert.strictEqual(findGroup(schema, 'pay_vip_points_exchange_product').collapsible, true);
const upgrade = findGroup(schema, 'vip_upgrade_product');
assert.strictEqual(upgrade.sections.length, 3);
// SPA 默认值与 PHP 侧一致。
assert.strictEqual(schema.defaults.vip_1_product[0].price, 99);
assert.strictEqual(schema.defaults.vip_1_renew_product[0].price, 69);
assert.strictEqual(schema.defaults.vip_2_renew_product[0].price, 269);
assert.strictEqual(schema.defaults.vip_renew_discount, 8);
assert.strictEqual(schema.defaults.vip_upgrade_product.jump_s, false);
// 旧键不出现在 SPA schema。
for (const key of LEGACY_KEYS) {
assert.ok(!vipSchemaJs.includes(key), `vip.js 不应再含 ${key}`);
}
});
test('升级价格 sections 能展开成三块手风琴,且块内字段遵循 dependsOn', async () => {
const { buildFieldsetSections } = await import(
pathToFileURL(path.join(root, 'assets/admin/src/components/form/fieldset-sections.js')).href
);
const mod = await import(pathToFileURL(path.join(root, 'assets/admin/src/schema/sections/payment/vip.js')).href);
const upgrade = findGroup(mod.vipSchema, 'vip_upgrade_product');
const sections = buildFieldsetSections(upgrade);
assert.strictEqual(sections.length, 3, '升级价格应展开成三块');
assert.deepStrictEqual(sections.map((s) => s.title), [
'月费会员升级月费会员',
'月费会员升级永久会员',
'永久会员升级永久会员',
]);
// 每块的字段并集恰好等于声明的全部字段,且顺序与 schema.fields 一致。
const expanded = sections.flatMap((s) => s.fields.map((f) => f.key));
assert.deepStrictEqual(expanded, upgrade.fields.map((f) => f.key));
// 第一块带「已设置为永久会员」之外的提示与开关,字段定义必须是真实对象而非占位。
assert.ok(sections[0].fields.every((f) => f && typeof f === 'object' && f.key));
// 没有 sections 声明的 fieldset 保持平铺,不产生假块。
assert.deepStrictEqual(buildFieldsetSections({ type: 'fieldset', fields: [{ key: 'a' }] }), []);
assert.deepStrictEqual(buildFieldsetSections(undefined), []);
// 渲染层把 sections 交给纯函数,并对块内字段套用 dependsOn。
assert.match(fieldVue, /const fieldsetSections = computed\(\(\) => \{[\s\S]*?buildFieldsetSections\(props\.schema\)/);
assert.match(fieldVue, /function sectionFields\(section\) \{[\s\S]*?matchDependency\(field\.dependsOn, props\.modelValue\)/);
// 结构化分组之间留出间隔,两个分组不会上下贴在一起。
assert.match(fieldVue, /\.aksrt-field--group,\s*\.aksrt-field--repeater,\s*\.aksrt-field--accordion,\s*\.aksrt-field--fieldset \{[\s\S]*?margin-bottom: 26px;/);
assert.match(fieldVue, /\.aksrt-field__accordion-section \+ \.aksrt-field__accordion-section \{[\s\S]*?margin-top: 14px;/);
});