The VIP purchase/renew modal opened with a single "loading..." line, which reads as a hang while /pay/vip/modal round-trips. Every frontend pageview also paid an option write inside wp_head, and the header user-card CSS was enqueued even for guests who never see the card. pay-vip.js now builds a full-layout skeleton (user box, level tabs, the two-column package panel) from vipSkeleton(), reusing the global aksrt-modal-runtime__skeleton line/block classes; the VIP-specific shapes live in the conditionally loaded vip-modal.css, and the 640px two-column threshold matches .pay-vip-form-row. The modal cache TTL goes 15s -> 60s (purchase, card-pass redeem and points exchange already invalidate the vip tag), and hover/focus on a .pay-vip trigger now prefetches the payload through the same cacheKey and flight openRemote uses, so a warm click opens instantly. The bottom note and the whole help_text pipeline behind it are removed; the admin-editable pay_user_vip_desc still renders at the top of the modal, and the recharge/gateway help_text keys are untouched. get_catalog() reads points and wallet balance through one new LedgerService::points_and_balance() call. The StatsTracker counter write moves to shutdown so wp_head only does the visitor check and setcookie, and header-userbox.css is enqueued for logged-in users only. Verification: node --test across the suite (628 tests, including the 7 new contract assertions in tests/widget-system/vip-modal-skeleton.test.cjs) and php -l on every changed PHP file. No visual acceptance performed, per request.
135 lines
7.3 KiB
JavaScript
135 lines
7.3 KiB
JavaScript
/**
|
||
* 会员弹窗骨架屏 / 打开提速 / help_text 清理 / 启动优化契约。
|
||
*
|
||
* 硬约束:
|
||
* 1) 会员弹窗加载占位为完整布局仿形骨架(vipSkeleton()),复用全局
|
||
* aksrt-modal-runtime__skeleton-line/-block 与 shimmer 动画,
|
||
* VIP 专属形状样式落在 assets/css/frontend/vip-modal.css;
|
||
* 2) 弹窗数据缓存 TTL 60s,悬停/聚焦入口按等级意图预热,预热与
|
||
* openRemote 共用 cacheKey 与 flight;
|
||
* 3) 弹窗底部提示句与整条 help_text 管线已移除,顶部说明 desc 保留;
|
||
* 4) 积分/钱包余额合并为一次 LedgerService::points_and_balance();
|
||
* 5) 访问统计写库移到 shutdown,wp_head 只做访客判定与 setcookie;
|
||
* 6) header-userbox 样式仅登录用户入队。
|
||
*/
|
||
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');
|
||
|
||
// 取某个方法体(到下一个同类方法声明为止),避免断言误伤其它功能的同名键。
|
||
function sliceMethod(source, signature, nextSignature) {
|
||
const start = source.indexOf(signature);
|
||
assert.notStrictEqual(start, -1, `missing ${signature}`);
|
||
const end = source.indexOf(nextSignature, start + signature.length);
|
||
return source.slice(start, end === -1 ? undefined : end);
|
||
}
|
||
|
||
const vipJs = read('assets', 'js', 'pay-vip.js');
|
||
const vipCss = read('assets', 'css', 'frontend', 'vip-modal.css');
|
||
const paymentRuntime = read('src', 'Settings', 'PaymentRuntime.php');
|
||
const modal = read('src', 'Content', 'VipServiceModal.php');
|
||
const catalogSource = read('src', 'Content', 'VipServicePurchase.php');
|
||
const ledger = read('src', 'Ledger', 'LedgerService.php');
|
||
const stats = read('src', 'Core', 'StatsTracker.php');
|
||
const scripts = read('src', 'Core', 'ScriptsPart1b.php');
|
||
|
||
test('会员弹窗使用完整布局仿形骨架屏', () => {
|
||
assert.match(vipJs, /function vipSkeleton\(\) \{/);
|
||
assert.match(vipJs, /node\("div", "aksrt-vip-modal-skeleton"\)/);
|
||
assert.match(vipJs, /skeleton\.setAttribute\("aria-busy", "true"\)/);
|
||
// 结构:用户框(头像+两行)、等级卡行、双列面板(权益+套餐区)。
|
||
assert.match(vipJs, /aksrt-vip-modal-skeleton__user/);
|
||
assert.match(vipJs, /aksrt-vip-modal-skeleton__avatar/);
|
||
assert.match(vipJs, /aksrt-vip-modal-skeleton__lines/);
|
||
assert.match(vipJs, /aksrt-vip-modal-skeleton__tabs/);
|
||
assert.match(vipJs, /aksrt-vip-modal-skeleton__tab/);
|
||
assert.match(vipJs, /aksrt-vip-modal-skeleton__pane/);
|
||
assert.match(vipJs, /aksrt-vip-modal-skeleton__info/);
|
||
assert.match(vipJs, /aksrt-vip-modal-skeleton__products/);
|
||
assert.match(vipJs, /aksrt-vip-modal-skeleton__button/);
|
||
// 线条/色块复用全局骨架类。
|
||
assert.match(vipJs, /aksrt-modal-runtime__skeleton-line is-wide/);
|
||
assert.match(vipJs, /aksrt-modal-runtime__skeleton-line is-short/);
|
||
assert.match(vipJs, /aksrt-modal-runtime__skeleton-block/);
|
||
// openRemote 传入自定义骨架,不再使用纯文本占位。
|
||
assert.match(vipJs, /skeleton: vipSkeleton\(\),/);
|
||
assert.doesNotMatch(vipJs, /加载中/);
|
||
});
|
||
|
||
test('骨架样式落在 vip-modal.css 并对齐 640px 双列阈值', () => {
|
||
assert.match(vipCss, /\.aksrt-vip-modal-skeleton \{/);
|
||
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__avatar \{[\s\S]*?border-radius: 50%/);
|
||
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__tab \{[\s\S]*?flex: 1/);
|
||
assert.match(vipCss, /\.aksrt-vip-modal-skeleton__button \{[\s\S]*?height: 38px/);
|
||
assert.match(vipCss, /animation: aksrt-modal-skeleton/);
|
||
assert.match(vipCss, /@media \(min-width: 640px\)[\s\S]*?\.aksrt-vip-modal-skeleton__pane \{[\s\S]*?grid-template-columns: 1fr 1\.2fr/);
|
||
});
|
||
|
||
test('弹窗打开提速:缓存 TTL 提升与意图预热', () => {
|
||
assert.match(vipJs, /cacheTtl: 60000,/);
|
||
// 预热与 openRemote 同一 cacheKey(method + AksrtRequest.url)。
|
||
assert.match(vipJs, /function vipModalCacheKey\(level\) \{/);
|
||
assert.match(vipJs, /"GET:" \+ window\.AksrtRequest\.url\("\/pay\/vip\/modal", \{ vip_level: level \}\)/);
|
||
assert.match(vipJs, /cache\.acquire\(vipModalCacheKey\(level\)/);
|
||
assert.match(vipJs, /ttl: 60000,/);
|
||
assert.match(vipJs, /tags: \["vip"\]/);
|
||
// 悬停/聚焦触发,绑定进 bindOnce。
|
||
assert.match(vipJs, /\["pointerenter", "focusin"\]/);
|
||
assert.match(vipJs, /function bindPrefetch\(\) \{/);
|
||
assert.match(vipJs, /document\.addEventListener\("click", onDocClick, true\);\s*bindPrefetch\(\);/);
|
||
// 每等级每页只预热一次。
|
||
assert.match(vipJs, /prefetchedLevels\[level\] = true;/);
|
||
});
|
||
|
||
test('弹窗底部提示与 help_text 管线已彻底移除', () => {
|
||
assert.doesNotMatch(vipJs, /help_text/);
|
||
// 仅收敛 VIP 设置函数:充值/网关设置的 help_text 与本文无关,保持原样。
|
||
const vipSettings = sliceMethod(
|
||
paymentRuntime,
|
||
'public static function get_payment_vip_settings',
|
||
'public static function get_payment_revenue_settings'
|
||
);
|
||
assert.doesNotMatch(vipSettings, /help_text/);
|
||
assert.doesNotMatch(vipSettings, /开通后立即生效/);
|
||
assert.doesNotMatch(modal, /help_text/);
|
||
const viewModel = sliceMethod(modal, 'public function get_modal_view_model', 'public function get_points_exchange_modal_html');
|
||
assert.doesNotMatch(viewModel, /help_text/);
|
||
assert.doesNotMatch(catalogSource, /help_text/);
|
||
// 顶部说明 desc 仍保留(后台 pay_user_vip_desc 继续生效)。
|
||
assert.match(vipJs, /catalog\.desc/);
|
||
assert.match(paymentRuntime, /'desc'\s+=> \$desc,/);
|
||
});
|
||
|
||
test('积分与钱包余额合并为一次 LedgerService 读取', () => {
|
||
assert.match(ledger, /public function points_and_balance\(int \$user_id\): array/);
|
||
// 仅收敛 get_catalog:购买/履约路径的余额读取与本文无关,保持原样。
|
||
const getCatalog = sliceMethod(catalogSource, 'public function get_catalog', 'private function find_catalog_package');
|
||
assert.match(getCatalog, /points_and_balance\(\$user_id\)/);
|
||
assert.match(getCatalog, /'user_points'\s+=> \$balances\['points'\]/);
|
||
assert.match(getCatalog, /'wallet_balance'\s+=> \$balances\['balance'\]/);
|
||
assert.doesNotMatch(getCatalog, /LedgerService::get_instance\(\)->balance\(/);
|
||
assert.doesNotMatch(getCatalog, /balance_decimal/);
|
||
});
|
||
|
||
test('访问统计写库移出渲染关键路径', () => {
|
||
assert.match(stats, /add_action\('wp_head', \[self::get_instance\(\), 'track_visit'\], 1\)/);
|
||
assert.match(stats, /add_action\('shutdown'/);
|
||
assert.match(stats, /public function record_stats\(string \$option_key, bool \$is_unique\): void/);
|
||
// wp_head 阶段只做判定与 setcookie,不再读写 option。
|
||
const trackVisit = stats.slice(stats.indexOf('public function track_visit'), stats.indexOf('public function record_stats'));
|
||
assert.doesNotMatch(trackVisit, /update_option|get_option/);
|
||
assert.match(trackVisit, /setcookie\(\$visitor_cookie/);
|
||
// 落库与月初清理收敛到 record_stats。
|
||
const recordStats = stats.slice(stats.indexOf('public function record_stats'));
|
||
assert.match(recordStats, /update_option\(\$option_key, \$stats, false\)/);
|
||
assert.match(recordStats, /cleanup_old_stats\(\)/);
|
||
});
|
||
|
||
test('header-userbox 样式仅登录用户入队', () => {
|
||
assert.match(scripts, /if \(is_user_logged_in\(\)\) \{\s*\$enqueue_module\('header-userbox'\);/);
|
||
});
|