Files
wordpress-theme-aksrt/tests/widget-system/vip-points-exchange-modal.test.cjs
T
kate522andClaude Opus 5 5b16bc6e15 fix(frontend): route the VIP points-exchange entry through the custom currency name
/user/vip/ 档位购买卡下方的入口横条仍硬编码「积分兑换会员」,而后台
「积分配置 → 自定义货币名称」改名后不跟随。上一轮改名只覆盖了兑换弹窗
标题与会员弹窗内入口,页面自身的入口横条是另一处服务端渲染,漏改了。

入口横条(inc/helpers/aksrt-vip.php)
- aksrt_get_vip_points_exchange_entry_html 改取已加载的
  catalog.currency_name,回退 Settings::get_currency_name() 再回退「积分」

同一页面上其余漏改的兑换文案一并收口,命名与入口/弹窗保持一致:
- VipServiceModal:积分不足提示 → {货币名}不足,暂时无法兑换
- VipServicePurchase.exchange_with_points:兑换未启用 / 兑换无效 /
  不足三处 WP_Error、流水说明、订单备注(%1$s兑换会员 %2$d %1$s。)
  currency_name 在此单独解析,get_catalog 内的局部变量不在该作用域
- LedgerPresentation:vip_points_exchange 流水原因标签

契约测试新增源断言,并用两个 PHP harness 做运行时断言:入口横条
(改名后 / 已是会员不输出 / 缺 key 回退)与弹窗(余额充足 / 不足两分支)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 16:19:49 +08:00

264 lines
16 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.
/**
* 积分兑换会员弹窗与双入口契约(参考主题 1:1 对齐)。
*
* 硬约束:
* 1) 服务端静态输出参考主题 refresh_modal 同构壳(`modal flex jc fade` >
* `modal-dialog modal-mini full-sm` > `modal-content` > `modal-body`)。
* 入口有两处,分别对应参考主题的两个变体:
* - 会员购买弹窗内(zibpay_get_pay_uservip_modal 的
* `btn-block but mb10 padding-lg c-green mini-radius` 按钮);
* - /user/vip/ 档位购买卡下方(zibpay_user_page_tab_content_vip 的
* `c-yellow zib-widget flex ac jsb` 全宽横条,仅非会员展示)。
* 两处入口均带 data-aksrt-vip-points-open,由 pay-vip.js 文档级委托打开
* 同一静态弹窗。
* 2) 静止态必须被 vip-modal.css 显式隐藏(.flex 覆盖回归护栏),打开态为
* flex 而非 block,且层级高于 runtime 宿主(100050),以复现参考主题在
* 会员弹窗之上再叠一层兑换弹窗的行为。
* 3) 旧的客户端自实现弹窗(renderPointsModal / aksrt-vip-points-option /
* catalog.vip_url)与旧版 /user/vip/ 自定义入口(uc-vip-points-link)
* 必须已彻底移除。
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const root = path.join(__dirname, '..', '..');
const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8');
const modal = read('src', 'Content', 'VipServiceModal.php');
const catalogSource = read('src', 'Content', 'VipServicePurchase.php');
const footer = read('inc', 'helpers', 'aksrt-vip.php');
const vipJs = read('assets', 'js', 'pay-vip.js');
const creditsJs = read('assets', 'js', 'credits.js');
const vipCss = read('assets', 'css', 'frontend', 'vip-modal.css');
const vipTab = read('inc', 'helpers', 'user-center-renderers', 'vip-tab.php');
const creditsCss = read('assets', 'css', 'routes', 'user-center', 'credits.css');
const ledger = read('src', 'Ledger', 'LedgerPresentation.php');
test('弹窗为参考主题 refresh_modal 同构静态壳并带 data 钩子', () => {
assert.match(modal, /public function get_points_exchange_modal_html/);
assert.match(modal, /class="modal flex jc fade"[^']*data-aksrt-vip-points-modal/);
assert.match(modal, /class="modal-dialog modal-mini full-sm"/);
assert.match(modal, /class="modal-content"><div class="modal-body">/);
assert.match(modal, /data-dismiss="modal"/);
assert.match(modal, /#icon-close/);
assert.match(modal, /data-aksrt-vip-points-form/);
});
test('弹窗内容保持参考主题结构:标题/我的{货币名}/卡片选择/立即兑换', () => {
// 头部:.mb10.touch + modal-title.flex.ac + vip_1 图标。
assert.match(modal, /'<div class="mb10 touch">'/);
assert.match(modal, /class="modal-title flex ac"/);
assert.match(modal, /#icon-vip_1/);
// 我的{货币名}盒(点回用户中心积分页)。货币名走自定义名称,不硬编码。
assert.match(modal, /muted-box padding-h10 mb20/);
assert.match(modal, /sprintf\(__\('我的%s', 'aksrt'\), \$currency\)/);
assert.match(modal, /#icon-points/);
assert.match(modal, /aksrt_get_user_center_tab_url\('credits'\)/);
// 标题同样跟随货币名。
assert.match(modal, /sprintf\(__\('%s兑换会员', 'aksrt'\), \$currency\)/);
// 卡片列表 + 参考主题 data-for/data-value 选中模型。
assert.match(modal, /请选择需兑换的会员/);
assert.match(modal, /flex hh exchange-card-box/);
assert.match(modal, /vip-card vip-theme/);
assert.match(modal, /data-for="product_id" data-value=/);
assert.match(modal, /flex jsb ac hh em16 mt10/);
// 表单:hidden 契约 + 立即兑换按钮 + 积分不足提示。
assert.match(modal, /name="order_type" value="4"/);
assert.match(modal, /name="action" value="points_initiate_pay"/);
assert.match(modal, /mt6 but jb-yellow btn-block radius padding-lg/);
assert.match(modal, /data-aksrt-vip-points-submit/);
// CTA 与积分不足状态都从卡片选择区留出 20px 上间距,避免状态切换时布局贴紧。
assert.match(modal, /\$form = '<div class="mt20">'/);
assert.match(modal, /'<div class="mt20"><div class="badg c-red btn-block">'/);
});
test('弹窗壳随 wp_footer 全局输出且不进入 catalog 载荷', () => {
assert.match(footer, /function aksrt_render_vip_points_exchange_modal/);
assert.match(footer, /add_action\('wp_footer', 'aksrt_render_vip_points_exchange_modal'\)/);
assert.match(footer, /VipService::get_instance\(\)->get_points_exchange_modal_html/);
// 兑换入口不再跳用户中心:catalog 里的 vip_url 已清除。
assert.doesNotMatch(catalogSource, /'vip_url'/);
assert.doesNotMatch(modal, /'vip_url'/);
});
test('我的{货币名}盒恢复参考主题 muted-box 的背景、圆角与内边距', () => {
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s+\.muted-box\.padding-h10\s*\{\s*background-color:\s*var\(--this-bg,\s*var\(--muted-border-color\)\);\s*border-radius:\s*var\(--main-radius\);\s*padding:\s*10px 15px;\s*\}/);
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s+\.muted-box\.padding-10,\s*\.modal\[data-aksrt-vip-points-modal\]\s+\.aksrt-vip-points-desc\s*\{\s*background-color:\s*var\(--this-bg,\s*var\(--muted-border-color\)\);\s*border-radius:\s*var\(--main-radius\);\s*padding:\s*10px;\s*\}/);
});
test('后台兑换说明直接从 VIP 设置读取并作为富文本置于套餐与提交动作之间', () => {
assert.match(modal, /\$desc_text = trim\(\(string\) \(\$vip\['points_exchange'\]\['desc'\] \?\? ''\)\);/);
assert.match(modal, /aksrt-vip-points-desc/);
assert.match(modal, /wp_kses_post\(\$desc_text\)/);
assert.match(modal, /\. \$header\s*\. '<form class="" data-aksrt-vip-points-form>' \. \$user_box \. \$lists \. \$desc \. \$form/);
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s+\.aksrt-vip-points-desc\s*\{[^}]*display:\s*block;[^}]*margin-top:\s*20px;[^}]*color:\s*var\(--muted-2-color,\s*#999\);/s);
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s+\.muted-box\.padding-10,\s*\.modal\[data-aksrt-vip-points-modal\]\s+\.aksrt-vip-points-desc\s*\{[^}]*padding:\s*10px;/s);
});
test('兑换说明从后台设置实际输出,保留 HTML 并显示在卡片与 CTA 之间', () => {
const rendered = execFileSync(process.env.PHP_BINARY || 'php', [
path.join(__dirname, 'vip-points-exchange-description-harness.php'),
], { encoding: 'utf8' });
assert.match(rendered, /class="muted-box muted-2-color padding-10 em09 mt20 aksrt-vip-points-desc"><p>后台配置的兑换说明<\/p><\/div>/);
assert.ok(rendered.indexOf('aksrt-vip-points-desc') < rendered.indexOf('data-aksrt-vip-points-submit'));
});
test('运行时:自定义货币名改写弹窗标题/我的X/卡片单位,不足提示同源', () => {
// harness 的 catalog.currency_name = 书币(模拟后台改名后的设置值)。
const rendered = execFileSync(process.env.PHP_BINARY || 'php', [
path.join(__dirname, 'vip-points-exchange-description-harness.php'),
], { encoding: 'utf8' });
// 标题与「我的书币」盒。
assert.match(rendered, /书币兑换会员/);
assert.match(rendered, /我的书币/);
// 卡片时长行右侧的单位。
assert.match(rendered, /500<span class="px12">书币<\/span>/);
assert.doesNotMatch(rendered, /积分兑换会员/);
assert.doesNotMatch(rendered, /我的积分/);
// 余额充足 → 输出兑换 CTA 而非不足提示。
assert.match(rendered, /data-aksrt-vip-points-submit/);
});
test('运行时:余额不足时提示文案同样跟随自定义货币名', () => {
// user_points(100) < 套餐价(500) → 走 badg c-red 分支,替换货币名后应显示「书币不足」。
const rendered = execFileSync(process.env.PHP_BINARY || 'php', [
path.join(__dirname, 'vip-points-exchange-description-harness.php'),
'100',
], { encoding: 'utf8' });
assert.match(rendered, /书币不足,暂时无法兑换/);
assert.doesNotMatch(rendered, /积分不足/);
assert.doesNotMatch(rendered, /data-aksrt-vip-points-submit/);
});
test('运行时:/user/vip/ 入口横条实际渲染出自定义货币名', () => {
const out = execFileSync(process.env.PHP_BINARY || 'php', [
path.join(__dirname, 'vip-points-entry-harness.php'),
], { encoding: 'utf8' }).split('\n');
// 场景 1:后台改名后,横条标题 = 「{货币名}兑换会员」,不再硬编码「积分」。
assert.match(out[0], /<a class="uc-vip-points-entry flex ac jsb"[^>]*data-aksrt-vip-points-open>/);
assert.match(out[0], /书币兑换会员/);
assert.doesNotMatch(out[0], /积分兑换会员/);
// 场景 2:已是会员 → 入口不输出(参考主题门控)。
assert.strictEqual(out[1], 'VIP_HIDDEN');
// 场景 3:catalog 未带 currency_name → 回退到设置里的名称,不显示空名。
assert.match(out[2], /回退币兑换会员/);
});
test('弹窗壳静止态必须被 vip-modal.css 显式隐藏(.flex 覆盖回归护栏)', () => {
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s*\{\s*display:\s*none;/);
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\.in\s*\{\s*display:\s*flex;/);
assert.doesNotMatch(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\.in\s*\{\s*display:\s*block;/);
// 层级必须高于 runtime 宿主(.aksrt-modal-runtime 的 z-index: 100050)。
assert.match(vipCss, /\.modal\[data-aksrt-vip-points-modal\]\s*\{[^}]*z-index:\s*100060;/s);
assert.match(vipCss, /\.modal-backdrop\[data-aksrt-vip-points-backdrop\]\s*\{\s*z-index:\s*100055;/);
});
test('开关自包含:不依赖 jQuery/Bootstrap modal 运行时', () => {
assert.match(vipJs, /function openPointsModal\(trigger\)/);
assert.match(vipJs, /function closePointsModal\(\)/);
assert.doesNotMatch(vipJs, /\.modal\(\s*['"]show['"]/);
assert.doesNotMatch(vipJs, /hidden\.bs\.modal/);
assert.match(vipJs, /pointsBackdrop\.className = "modal-backdrop fade in"/);
assert.match(vipJs, /modal\.style\.display = "flex"/);
assert.doesNotMatch(vipJs, /modal\.style\.display = "block"/);
assert.match(vipJs, /void modal\.offsetWidth/);
});
test('会员弹窗内的入口为参考主题同款按钮并开本地弹窗', () => {
// 参考主题 zibpay_get_pay_uservip_modal 的 class 与图标顺序。
assert.match(vipJs, /node\("a", "btn-block but mb10 padding-lg c-green mini-radius"\)/);
assert.match(vipJs, /#icon-points-color/);
// 图标顺序与 class 串保持参考主题一致;FA5 样式类 `fas` 由图标契约(
// legacy-navigation-contract 的 FA5-only 约束)强制要求,故在此一并断言。
assert.match(vipJs, /fa fas fa-angle-right em12 ml10/);
assert.match(vipJs, /data-aksrt-vip-points-open/);
assert.match(vipJs, /openPointsModal\(pointsLink\)/);
});
test('/user/vip/ 档位购买卡下方的入口为参考主题用户中心同款横条', () => {
// 参考主题 zibpay_user_page_tab_content_vip:my_box → vip_card → points link,
// 横条为 `c-yellow zib-widget flex ac jsb`(zib-widget → 本主题aksrt-panel表面)。
assert.match(footer, /function aksrt_get_vip_points_exchange_entry_html/);
assert.match(vipTab, /aksrt_get_vip_points_exchange_entry_html\(\$user_id, \$level\)/);
// 结构 1:1:左 points-color 图标 + 标题(flex1 em12 c-yellow),
// 右「立即兑换」badg + 右箭头。
assert.match(footer, /uc-vip-points-entry flex ac jsb/);
assert.match(footer, /flex1 em12 c-yellow/);
assert.match(footer, /#icon-points-color/);
assert.match(footer, /badg radius c-yellow/);
assert.match(footer, /fa fas fa-chevron-circle-right ml6/);
assert.match(footer, /data-aksrt-vip-points-open/);
});
test('/user/vip/ 入口门控与弹窗壳同源:非会员 + 兑换开启 + 有可兑换套餐', () => {
// 参考主题 zibpay_get_vip_points_exchange_link 门控:已是会员不展示。
assert.match(footer, /if \(\$vip_level > 0\) \{\s*return '';/);
// 可用性直接取服务端 catalog(内含 credits 门控与等级启用过滤),
// 与 wp_footer 弹窗壳的渲染条件一致,避免出现无弹窗可开的死入口。
assert.match(footer, /get_catalog\(\$user_id\)/);
assert.match(footer, /empty\(\$points_exchange\['enabled'\]\) \|\| \$packages === \[\]/);
// 模板传入当前会员等级(>0 时不输出)。
assert.match(footer, /int \$vip_level = -1/);
});
test('/user/vip/ 入口文案跟随自定义货币名称,不硬编码「积分」', () => {
// 横条标题取 catalog.currency_name(后台「积分配置 → 自定义货币名称」),
// 与它打开的兑换弹窗标题(VipServiceModal)和会员弹窗内入口(pay-vip.js)同源。
assert.match(footer, /\$currency = \(string\) \(\$catalog\['currency_name'\] \?\? ''\);/);
assert.match(footer, /esc_html\(sprintf\(__\('%s兑换会员', 'aksrt'\), \$currency\)\)/);
// 缺失时回退设置里的名称,再回退「积分」。
assert.match(footer, /Settings::get_currency_name\(\)/);
assert.doesNotMatch(footer, /esc_html__\('积分兑换会员', 'aksrt'\)/);
// 弹窗内同一命名路径:标题 / 我的{货币名} / 不足提示都走 $currency。
assert.match(modal, /\$currency = \(string\) \(\$catalog\['currency_name'\] \?\? __\('积分', 'aksrt'\)\);/);
assert.doesNotMatch(modal, /esc_html__\('积分兑换会员', 'aksrt'\)/);
});
test('兑换链路文案(弹窗/错误/订单备注/流水)统一跟随货币名', () => {
// 不足提示与后端 WP_Error 同文案,均以货币名为前缀。
assert.match(modal, /esc_html\(sprintf\(__\('%s不足,暂时无法兑换', 'aksrt'\), \$currency\)\)/);
assert.match(catalogSource, /aksrt_points_insufficient', sprintf\(__\('%s不足,暂时无法兑换', 'aksrt'\), \$currency_name\)/);
assert.match(catalogSource, /aksrt_points_exchange_disabled', sprintf\(__\('%s兑换会员未启用', 'aksrt'\), \$currency_name\)/);
assert.match(catalogSource, /aksrt_invalid_price', sprintf\(__\('兑换%s无效', 'aksrt'\), \$currency_name\)/);
// exchange_with_points 自行解析 currency_name(get_catalog 内的局部变量不在该作用域)。
assert.match(catalogSource, /\$currency_name = \(string\) \(\$catalog\['currency_name'\] \?\? ''\);/);
// 订单备注与流水说明。
assert.match(catalogSource, /sprintf\(__\('%1\$s兑换会员 %2\$d %1\$s。', 'aksrt'\), \$currency_name, \$cost\)/);
assert.match(catalogSource, /sprintf\(__\('%s兑换会员 · %s', 'aksrt'\), \$currency_name, \(string\) \(\$match\['title'\] \?\? ''\)\)/);
// 流水原因标签(用户中心明细里 desc 为空时的回退)。
assert.match(ledger, /'vip_points_exchange' => sprintf\(__\('%s兑换会员', 'aksrt'\), self::points_name\(\)\)/);
assert.doesNotMatch(ledger, /'vip_points_exchange' => __\('积分兑换会员', 'aksrt'\)/);
});
test('页面入口样式落在 credits.css 且与档位卡同一表面体系', () => {
assert.match(creditsCss, /\.uc-vip-points-entry \{/);
assert.match(creditsCss, /\.uc-vip-points-entry\s*\{[^}]*var\(--main-bg-color, var\(--surface-1, #fff\)\)/s);
assert.match(creditsCss, /\.uc-vip-points-entry\s*\{[^}]*box-shadow:\s*var\(--shadow-sm\)/s);
});
test('入口开合由文档级委托驱动,弹窗内不再单独挂监听', () => {
// /user/vip/ 静态横条与弹窗内按钮共用一处 document 委托(冒泡)。
assert.match(vipJs, /function bindPointsEntry\(\)/);
assert.match(vipJs, /bindPointsEntry\(\);/);
assert.match(vipJs, /document\.addEventListener\("click", function \(event\) \{[\s\S]*?data-aksrt-vip-points-open/);
// bindModalUi 内的重复分支已移除:openPointsModal(pointsLink) 只保留一处调用。
const openCalls = vipJs.match(/openPointsModal\(pointsLink\)/g) || [];
assert.strictEqual(openCalls.length, 1);
});
test('旧的客户端自实现弹窗与旧版自定义入口已彻底移除', () => {
assert.doesNotMatch(vipJs, /renderPointsModal|exchangeFromModal|vipPointsPay/);
assert.doesNotMatch(vipJs, /aksrt-vip-points-option|aksrt-vip-points-modal__/);
assert.doesNotMatch(vipCss, /\.aksrt-vip-points-option|\.aksrt-vip-points-submit/);
// 旧版 /user/vip/ 自定义入口(uc-vip-points-link)不得回归;现行入口为
// 参考主题 1:1 的 uc-vip-points-entry 横条。
assert.doesNotMatch(vipTab, /uc-vip-points-link/);
assert.doesNotMatch(creditsCss, /\.uc-vip-points-link/);
// 用户中心侧的旧兑换控制器([data-aksrt-vip-points],全站无对应 DOM)。
assert.doesNotMatch(creditsJs, /initVipPointsExchange|data-aksrt-vip-points/);
});