Files
wordpress-theme-aksrt/tests/widget-system/vip-settings-structured-contract.test.cjs
T
kate522 064f4e0955 fix(admin): keep enum values in their declared type when sanitizing
The 积分兑换明细.level radio declares its options as the integers 1 and 2, but sanitize_field_value() ran every radio value through sanitize_text_field(), so the sanitized payload held the string 1. validate() then compared with in_array(..., true) and rejected both default rows, which made the VIP payment section impossible to save.

Resolve enum values to the value declared by their option during sanitization, covering select, radio, palette and image_select. A value that matches no option still falls through to the previous string coercion, so invalid input keeps producing a precise validation error. This also keeps the stored type aligned with the schema, which the naive-ui radio group compares with === when it re-renders a saved setting.

Verified with an end-to-end sanitize, validate, write and read round-trip, and locked in with a contract test in the VIP structured-settings suite. The widget-system, admin, front-submit and navigation suites all pass (658 tests).
2026-10-18 19:45:57 +08:00

507 lines
26 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('积分兑换明细.level 枚举净化后仍能通过校验,非法值依旧报错', () => {
const { sanitize } = harness;
// level 的选项值是整数 1 / 2。默认两行提交数字即可保存,且净化后必须保持
// 整数类型:前端 n-radio 用 === 比较保存值,字符串化会让单选框失活。
assert.strictEqual(sanitize.default_rows.valid, true);
assert.deepStrictEqual(sanitize.default_rows.errors, []);
assert.deepStrictEqual(sanitize.default_rows.points_rows.map((r) => r.level), [1, 1]);
// SPA 提交字符串等级时,同样收敛回选项声明的整数。
assert.strictEqual(sanitize.string_level.valid, true);
assert.deepStrictEqual(sanitize.string_level.points_rows.map((r) => r.level), [2]);
// 收敛不得掩盖真正的非法值:level 与 unit 仍各自报出精确错误。
assert.strictEqual(sanitize.invalid_level.valid, false);
assert.deepStrictEqual(sanitize.invalid_level.errors, ['积分兑换明细[0].level has an invalid value']);
assert.strictEqual(sanitize.invalid_unit.valid, false);
assert.deepStrictEqual(sanitize.invalid_unit.errors, ['积分兑换明细[0].unit has an invalid value']);
// 字符串型枚举(palette)不受影响。
assert.strictEqual(sanitize.invalid_tag_class.valid, false);
assert.deepStrictEqual(sanitize.invalid_tag_class.errors, ['会员商品[0].tag_class has an invalid value']);
});
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;/);
});