Files
wordpress-theme-aksrt/tests/widget-system/vip-modal-copy-fields.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

209 lines
15 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) 弹窗文案字段更名为「会员介绍」(pay_user_vip_desc, input) 与
* 「开通会员更多内容」(pay_user_vip_more, textarea rows 4 +
* sanitize_type html),并从「会员等级」组移到分区末尾的
* 「界面文案」(modal-copy) 独立分组;
* 2) 文案默认值对齐 zibll(黄金会员/钻石会员、尊享专属特权、权益四项、
* 会员介绍与开通会员更多内容默认值),SPA schema / PHP schema /
* PaymentRuntime 回退三处一致——Settings::get_all() 不合并
* schema 默认值,运行时回退即实际默认值;
* 3) vip_renew_desc / vip_upgrade_desc 为参考主题的一句话简介字段,
* 两份 schema 与 PaymentRuntime 均已定义,并由用户中心 /user/vip/
* 档位卡渲染消费(vip-tab.php 按续费/升级动作取对应简介);
* 4) 权益与卡密说明字段保存时保留 HTML(sanitize_type html),
* 不再被 sanitize_textarea_field 剥掉标签。
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
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 rechargeJs = read('assets', 'admin', 'src', 'schema', 'sections', 'payment', 'recharge.js');
const vipTabPhp = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
const modalSource = read('src', 'Content', 'VipServiceModal.php');
const payVipJs = read('assets', 'js', 'pay-vip.js');
const DESC_DEFAULT = '开通VIP会员,享受会员专属折扣以及多项特权';
const MORE_DEFAULT = '<div>购买后不支持退款</div><div>VIP权益仅适用于本站</div><div>欢迎与站长联系</div>';
const EQUITY_DEFAULT = '<li>全站资源折扣购买</li><li>部分内容免费阅读</li><li>一对一技术指导</li><li>VIP用户专属QQ群</li>';
// 取两个标记之间的源码段(分组切片,避免断言误伤其它功能的同名键)。
function slice(source, from, to) {
const start = source.indexOf(from);
assert.notStrictEqual(start, -1, `missing ${from}`);
const end = source.indexOf(to, start + from.length);
return source.slice(start, end === -1 ? undefined : end);
}
test('弹窗文案字段更名为会员介绍/开通会员更多内容并改为正确类型', () => {
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp]]) {
assert.ok(!src.includes('弹窗顶部会员说明') && !src.includes('弹窗底部补充说明 HTML'), `${name} 旧名称应已移除`);
assert.ok(src.includes('会员介绍') && src.includes('开通会员更多内容'), `${name} 应含新名称`);
}
// 会员介绍为单行 input(zibll text「一句话简介」);开通会员更多内容为 rows 4 的 HTML textarea。
assert.match(
vipSchemaJs,
/key: 'pay_user_vip_desc',\s*\n\s*label: '会员介绍',\s*\n\s*type: 'input',\s*\n\s*default: '开通VIP会员,享受会员专属折扣以及多项特权'/
);
assert.match(
vipSchemaJs,
/key: 'pay_user_vip_more',\s*\n\s*label: '开通会员更多内容',\s*\n\s*type: 'textarea',\s*\n\s*sanitize_type: 'html'[\s\S]*?rows: 4/
);
assert.match(
schemaPhp,
/'key' => 'pay_user_vip_desc', 'label' => '会员介绍', 'type' => 'input', 'default' => '开通VIP会员,享受会员专属折扣以及多项特权'/
);
assert.match(
schemaPhp,
/'key' => 'pay_user_vip_more', 'label' => '开通会员更多内容', 'type' => 'textarea', 'sanitize_type' => 'html'[\s\S]*?'rows' => 4/
);
// 帮助文本对齐 zibll 的 desc。
assert.ok(vipSchemaJs.includes('显示在开通界面顶部一句话简介,可以为会员权益简介或者活动介绍'));
assert.ok(vipSchemaJs.includes('显示在开通界面底部位置,可以为提醒事项、用户协议等,支持HTML代码'));
});
test('两个文案字段位于分区末尾的界面文案分组且不再属于会员等级组', () => {
const modalJs = vipSchemaJs.indexOf("key: 'modal-copy'");
const modalPhp = schemaPhp.indexOf("'key' => 'modal-copy'");
assert.notStrictEqual(modalJs, -1, 'vip.js 应含 modal-copy 分组');
assert.notStrictEqual(modalPhp, -1, 'SchemaPayment.php 应含 modal-copy 分组');
assert.ok(modalJs > vipSchemaJs.indexOf("key: 'points-exchange'"), 'vip.js modal-copy 应在分区末尾');
assert.ok(modalPhp > schemaPhp.indexOf("'key' => 'points-exchange'"), 'SchemaPayment.php modal-copy 应在分区末尾');
// modal-copy 之后不再出现其它分组/字段 key。
// modal-copy 之后(至分区末尾)不再出现其它分组/字段 key;dependsOn 引用的等级开关除外。
assert.doesNotMatch(vipSchemaJs.slice(modalJs), /key: '(?!modal-copy|pay_user_vip_desc|pay_user_vip_more|pay_user_vip_1_s|pay_user_vip_2_s)/);
const phpTail = slice(schemaPhp, "'key' => 'modal-copy'", "'revenue'");
assert.doesNotMatch(phpTail, /'key' => '(?!modal-copy|pay_user_vip_desc|pay_user_vip_more|pay_user_vip_1_s|pay_user_vip_2_s)/);
// 「会员等级」组不再包含这两个字段。
assert.doesNotMatch(slice(vipSchemaJs, "key: 'basic'", "key: 'benefit'"), /pay_user_vip_desc|pay_user_vip_more/);
assert.doesNotMatch(slice(schemaPhp, "'key' => 'basic'", "'key' => 'benefit'"), /pay_user_vip_desc|pay_user_vip_more/);
// 「会员等级」组只留导航按钮与两个等级开关;等级身份字段全部搬进
// 「会员购买选项」的等级 Tab(名称/简介/权益/图标)。
const basicJs = slice(vipSchemaJs, "key: 'basic'", "key: 'benefit'");
const basicPhp = slice(schemaPhp, "'key' => 'basic'", "'key' => 'benefit'");
for (const key of ['pay_user_vip_1_name', 'pay_user_vip_2_name', 'pay_user_vip_1_desc', 'pay_user_vip_2_desc', 'vip_1_img_icon', 'vip_2_img_icon']) {
assert.ok(!basicJs.includes(key), `vip.js 会员等级组不应再含 ${key}`);
assert.ok(!basicPhp.includes(key), `SchemaPayment.php 会员等级组不应再含 ${key}`);
}
assert.ok(basicJs.includes('pay_user_vip_1_s') && basicJs.includes('pay_user_vip_2_s'), 'vip.js 会员等级组应保留两个等级开关');
assert.ok(basicPhp.includes("'key' => 'pay_user_vip_1_s'") && basicPhp.includes("'key' => 'pay_user_vip_2_s'"), 'SchemaPayment.php 会员等级组应保留两个等级开关');
// 二级会员先于一级会员开启时的危险提示(参考主题同款)。
assert.ok(basicJs.includes('vip_level_order_notice') && basicPhp.includes('vip_level_order_notice'));
// 依赖:任一等级启用时显示(zibll pay_user_vip_1_s 依赖的双等级适配)。
assert.ok(
vipSchemaJs.includes("dependsOn: { any: [{ key: 'pay_user_vip_1_s', value: true }, { key: 'pay_user_vip_2_s', value: true }] }"),
'vip.js 应为 any 双等级依赖'
);
assert.ok(
schemaPhp.includes("'dependsOn' => ['any' => [['key' => 'pay_user_vip_1_s', 'value' => true], ['key' => 'pay_user_vip_2_s', 'value' => true]]]"),
'SchemaPayment.php 应为 any 双等级依赖'
);
});
test('续费/升级介绍字段存在且被档位卡消费', () => {
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp]]) {
assert.ok(src.includes('vip_renew_desc'), `${name} 应含 vip_renew_desc`);
assert.ok(src.includes('vip_upgrade_desc'), `${name} 应含 vip_upgrade_desc`);
}
// 运行时回退默认值(get_all 不合并 schema 默认值,回退即实际默认值)。
assert.ok(paymentRuntime.includes("$settings['vip_renew_desc'] ?? '立即续费会员,畅享VIP权益'"));
assert.ok(paymentRuntime.includes("$settings['vip_upgrade_desc'] ?? '升级VIP会员,享更多会员权益'"));
// 消费方:/user/vip/ 档位卡按动作取续费/升级简介,不得回退成死配置。
assert.ok(vipTabPhp.includes("$vip_settings['renew']['desc']"));
assert.ok(vipTabPhp.includes("$vip_settings['upgrade']['desc']"));
assert.match(vipTabPhp, /\$card_action = 'renew'/);
assert.match(vipTabPhp, /\$card_action = 'upgrade'/);
});
test('文案默认值三处一致并对齐 zibll', () => {
// SPA defaults 块与字段级 default。
assert.ok(vipSchemaJs.includes("pay_user_vip_1_name: '黄金会员'"));
assert.ok(vipSchemaJs.includes("pay_user_vip_2_name: '钻石会员'"));
assert.ok(vipSchemaJs.includes("pay_user_vip_1_desc: '尊享专属特权'"));
assert.ok(vipSchemaJs.includes("pay_user_vip_2_desc: '尊享专属特权'"));
assert.ok(vipSchemaJs.includes(`pay_user_vip_desc: '${DESC_DEFAULT}'`));
assert.ok(vipSchemaJs.includes(`pay_user_vip_more: '${MORE_DEFAULT}'`));
// 权益默认值收敛为常量,两份 schema 与运行时共用同一份字面量。
assert.ok(vipSchemaJs.includes(`const VIP_EQUITY_DEFAULT = '${EQUITY_DEFAULT}'`));
assert.ok(vipSchemaJs.includes('default: VIP_EQUITY_DEFAULT'));
// PHP schema 字段 default。
assert.ok(schemaPhp.includes("'default' => $level === 1 ? '黄金会员' : '钻石会员'"));
assert.ok(schemaPhp.includes(EQUITY_DEFAULT));
assert.ok(schemaPhp.includes(`'default' => '${DESC_DEFAULT}'`));
assert.ok(schemaPhp.includes(`'default' => '${MORE_DEFAULT}'`));
// PaymentRuntime 运行时回退(get_all 不合并 schema 默认值,回退即实际默认值)。
assert.ok(paymentRuntime.includes(`$desc = sanitize_text_field((string) ($settings['pay_user_vip_desc'] ?? __('${DESC_DEFAULT}', 'aksrt')))`));
assert.ok(paymentRuntime.includes(`$more = (string) ($settings['pay_user_vip_more'] ?? '${MORE_DEFAULT}')`));
assert.ok(paymentRuntime.includes("$name1 = __('黄金会员', 'aksrt');"));
assert.ok(paymentRuntime.includes("$name2 = __('钻石会员', 'aksrt');"));
assert.ok(paymentRuntime.includes("$settings['pay_user_vip_1_desc'] ?? __('尊享专属特权', 'aksrt')"));
assert.ok(paymentRuntime.includes("$settings['pay_user_vip_2_desc'] ?? __('尊享专属特权', 'aksrt')"));
assert.ok(paymentRuntime.includes(EQUITY_DEFAULT));
// 旧默认值不再出现在任何一处。
for (const [name, src] of [['vip.js', vipSchemaJs], ['SchemaPayment.php', schemaPhp], ['PaymentRuntime.php', paymentRuntime]]) {
assert.ok(!/尊享一级会员特权|尊享二级会员特权|免费下载部分资源|更高免费下载额度/.test(src), `${name} 旧默认值应已移除`);
}
});
test('权益与卡密说明字段保存时保留 HTML', () => {
// 权益字段已并入「会员购买选项」的等级 Tab,标签统一为「会员权益 HTML」。
assert.ok(vipSchemaJs.includes("key: `pay_user_vip_${level}_equity`, label: '会员权益 HTML', type: 'textarea', sanitize_type: 'html'"));
assert.ok(schemaPhp.includes("['key' => 'pay_user_vip_' . $level . '_equity', 'label' => '会员权益 HTML', 'type' => 'textarea', 'sanitize_type' => 'html'"));
assert.ok(rechargeJs.includes("key: 'pay_vip_pass_charge_desc', label: '卡密兑换会员说明', type: 'textarea', sanitize_type: 'html'"));
assert.ok(schemaPhp.includes("'key' => 'pay_vip_pass_charge_desc', 'label' => '卡密兑换会员说明', 'type' => 'textarea', 'sanitize_type' => 'html'"));
});
test('会员介绍运行时按单行文本净化', () => {
// 只收敛 VIP 设置函数:充值等其它设置的 $desc 与本文无关。
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
assert.ok(vipSettings.includes("$desc = sanitize_text_field((string) ($settings['pay_user_vip_desc']"));
assert.ok(!vipSettings.includes('$desc = sanitize_textarea_field'));
});
test('开通会员更多内容只在弹窗内渲染,不裸露在用户中心页面', () => {
// 参考主题 zibpay_user_page_tab_content_vip 只渲染 pay_user_vip_desc;
// pay_user_vip_more 仅出现在两个弹窗函数(购买/续费弹窗)中。
assert.ok(!vipTabPhp.includes("vip_settings['more']"), '用户中心 VIP 页不应渲染 more');
assert.ok(!vipTabPhp.includes('muted-box padding-10 mini-radius mt15'), '用户中心 VIP 页不应有 more 提示盒');
// 弹窗侧保持:视图模型透传 more + pay-vip.js 右列渲染。
assert.match(modalSource, /'more'\s+=>/);
assert.match(payVipJs, /catalog\.more/);
});
test('more 与卡密说明输出按 post 级 HTML 放行', () => {
// 保存端已 wp_kses_post,输出端不能再窄白名单/textarea 净化二次剥标签。
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
assert.match(vipSettings, /'more'\s+=> wp_kses_post\(\$more\)/);
assert.doesNotMatch(vipSettings, /\$allowed_equity \+ \['div'/);
const rechargeSettings = slice(paymentRuntime, 'public static function get_payment_recharge_settings', 'public static function get_payment_vip_settings');
assert.match(rechargeSettings, /'desc'\s+=> wp_kses_post\(\(string\) \(\$settings\['pay_vip_pass_charge_desc'\]/);
});
test('弹窗内 more 与卡密说明按 HTML 渲染而非纯文本', () => {
// node() 走 textContent 会把标签显示为字面文本;富文本字段必须走 innerHTML。
assert.match(payVipJs, /function htmlNode\(tag, className, value\) \{[\s\S]*?el\.innerHTML = text\(value\);/);
assert.ok(payVipJs.includes('htmlNode("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more)'));
assert.ok(payVipJs.includes('htmlNode("div", "muted-box muted-2-color padding-10 mb10 em09", cardPass.desc)'));
});
test('积分兑换说明按 post 级 HTML 放行并原样渲染', () => {
// 参考主题该字段 sanitize=false 且弹窗内原样拼接;AKSRT 对齐为保存/输出
// 双端 wp_kses_post + 弹窗 wp_kses_post 渲染。
assert.match(vipSchemaJs, /key: 'pay_vip_points_exchange_desc', label: '兑换说明', type: 'textarea', sanitize_type: 'html'/);
assert.match(schemaPhp, /'key' => 'pay_vip_points_exchange_desc', 'label' => '兑换说明', 'type' => 'textarea', 'sanitize_type' => 'html'/);
const vipSettings = slice(paymentRuntime, 'public static function get_payment_vip_settings', 'public static function get_payment_revenue_settings');
assert.match(vipSettings, /'desc'\s+=> wp_kses_post\(\(string\) \(\$settings\['pay_vip_points_exchange_desc'\]/);
// 弹窗渲染端不再 esc_html 剥标签。
assert.match(modalSource, /<div class="muted-box muted-2-color padding-10 em09 mt20">' \. wp_kses_post\(\$desc_text\)/);
assert.doesNotMatch(modalSource, /esc_html\(\$desc_text\)/);
});