Files
wordpress-theme-aksrt/tests/widget-system/vip-settings-structured-contract.test.cjs
T
kate522 af8dccdc45 feat(vip): restructure membership settings into structured groups
Replace the pipe-delimited VIP configuration with structured groups and
accordions, matching the reference theme (Zibll) 1:1.

Settings:
- 会员等级 keeps only the nav buttons and the two level switches, plus the
  "must enable level 1 first" guard. Level identity fields (display name,
  intro, benefits HTML, icon) and 会员商品 move into the 一级会员/二级会员
  tabs of 会员购买选项, so each level is configured in one place.
- Level tab titles bind to the configured display name via a new
  titleKey/titleFallback hook in AksFormRenderer.
- 会员商品 is a repeater with exactly the reference fields: 执行价/原价/
  促销标签/标签颜色/会员有效时间/时长单位. The tag colour uses the full
  b+jb palette and now reaches the product card instead of a hardcoded
  jb-yellow.
- 会员续费 gains the 自定义商品 type backed by per-level renew-product
  groups; the discount becomes a 0.1-9.9 slider with float sanitizing.
- 会员升级 becomes the reference three-block accordion (month-to-month
  day-rate, jump to permanent, permanent to permanent), each with its own
  price, strikethrough price, promo tag and tag colour.
- 积分兑换明细 is a repeater (points/level/duration/unit) where the
  duration accepts the literal Permanent.

Runtime:
- PaymentRuntime normalizes the structured groups into purchasable rows
  (money normalization, palette validation, zero-price and out-of-range
  rows dropped) and no longer emits any *_raw strings.
- The catalog builder carries tag_class through and routes renewals to the
  renew-product group under 自定义商品.
- The points-exchange order note carries time/unit so month durations use
  calendar math instead of a 30-day approximation.

Dead code removed: parse_level_products, parse_packages,
parse_upgrade_product_line and their pipe-splitting, plus nine zero-call
PaymentRuntime proxy methods in HomeCms. No dual-format or migration path
was added; SchemaProvider already falls back to defaults when a stored
group value is not an array, so existing pipe strings degrade safely.

Verified: php -l on every changed PHP file, 655 Node contract tests passing
(14 new ones in vip-settings-structured-contract.test.cjs exercising the
real schema build and runtime normalization via a PHP harness), and the
admin SPA builds.
2026-10-18 17:14:00 +08:00

370 lines
19 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 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);
}
// 只切 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/);
});
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']);
// 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}`);
}
});