/** * 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;/); });