dc6598d7e4
The four account panes now share a fixed frame, chart profiles open as list then detail, and membership lives in the homepage dialog instead of a separate page. Co-authored-by: Cursor <cursoragent@cursor.com>
293 lines
17 KiB
TypeScript
293 lines
17 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
const projectFile = (path: string) => new URL(`../${path}`, import.meta.url);
|
|
const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8");
|
|
const hookSource = readProjectFile("src/hooks/use-billing-panel.ts");
|
|
const panelSource = readProjectFile("src/components/billing-panel.tsx");
|
|
const billingSource = `${hookSource}\n${panelSource}`;
|
|
const membershipLib = readProjectFile("src/lib/membership.ts");
|
|
const nextConfig = readProjectFile("next.config.ts");
|
|
const pageSource = readProjectFile("src/app/page.tsx");
|
|
const tabsSource = readProjectFile("src/components/ui/tabs.tsx");
|
|
|
|
function countMatches(source: string, pattern: RegExp) {
|
|
return source.match(pattern)?.length ?? 0;
|
|
}
|
|
|
|
test("membership routes are gone and billing loads without a spinner", () => {
|
|
// 原值: src/app/membership/page.tsx 存在且含 Suspense / membership-loading
|
|
// 新值: 页面删除;billing-panel 是 client 组件,无 spinner / 骨架 / 「正在加载」
|
|
// 原因: 账户与点数进入设置弹窗,等待态遵守 AGENTS §6
|
|
assert.equal(existsSync(projectFile("src/app/membership/page.tsx")), false);
|
|
assert.equal(existsSync(projectFile("src/app/membership/orders/page.tsx")), false);
|
|
assert.match(panelSource, /"use client"/);
|
|
assert.doesNotMatch(billingSource, /membership-loading|正在加载|Suspense/);
|
|
assert.doesNotMatch(panelSource, /正在加载订单/);
|
|
assert.match(nextConfig, /source: "\/membership"/);
|
|
assert.match(nextConfig, /destination: "\/\?settings=billing&source=legacy"/);
|
|
assert.match(nextConfig, /source: "\/membership\/orders"/);
|
|
assert.match(nextConfig, /destination: "\/\?settings=billing&tab=orders"/);
|
|
});
|
|
|
|
test("membership page loads account, packages and redeem endpoints without fetching orders", () => {
|
|
// 原值: 会员页不请求 /api/payment/orders
|
|
// 新值: 挂载只并发账户与套餐;订单仅在 orders 页签拉取
|
|
// 原因: 订单记录改成分区内页签,不能一打开就打列表接口
|
|
assert.match(hookSource, /fetch\("\/api\/account"/);
|
|
assert.match(hookSource, /fetch\("\/api\/payment\/packages"/);
|
|
assert.match(hookSource, /fetch\("\/api\/payment\/epay\/create"/);
|
|
assert.match(hookSource, /fetch\(`\/api\/payment\/epay\/status\?orderNo=/);
|
|
assert.match(hookSource, /fetch\("\/api\/redeem"/);
|
|
assert.match(hookSource, /await Promise\.allSettled\(\[fetchAccountData\(\), fetchPackages\(\)\]\)/);
|
|
const mountStart = hookSource.indexOf("await Promise.allSettled");
|
|
const mountEnd = hookSource.indexOf("}, [fetchAccountData, fetchPackages]);");
|
|
const mountEffect = hookSource.slice(mountStart, mountEnd);
|
|
assert.doesNotMatch(mountEffect, /fetchOrders|\/api\/payment\/orders/);
|
|
assert.match(panelSource, /if \(value === "orders"\) void fetchOrders\(\)/);
|
|
});
|
|
|
|
test("creates orders with productId and opens the signed cashier URL safely", () => {
|
|
assert.match(hookSource, /JSON\.stringify\(\{ productId \}\)/);
|
|
assert.match(hookSource, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/);
|
|
assert.doesNotMatch(billingSource, /document\.createElement\("form"\)|payload\.submitUrl|payload\.fields/);
|
|
assert.match(hookSource, /if \(!paymentOrder \|\| paymentOrder\.status !== "pending"\) return;/);
|
|
});
|
|
|
|
test("terminal payment failures stop polling and offer re-pay with the same product", () => {
|
|
assert.match(hookSource, /\["failed", "closed", "cancelled"\]\.includes\(payload\.status\)/);
|
|
assert.match(hookSource, /payload\.grantStatus === "failed"/);
|
|
assert.match(hookSource, /status !== "pending"\) return;/);
|
|
assert.match(hookSource, /status: paid \? "paid" : failed \? "failed" : "pending"/);
|
|
assert.match(panelSource, /paymentOrder\.status === "failed"/);
|
|
assert.match(panelSource, /支付失败/);
|
|
assert.match(panelSource, /createPayment\(paymentOrder\.productId\)/);
|
|
assert.match(panelSource, /重新支付/);
|
|
});
|
|
|
|
test("failed status beats paid and non-terminal statuses keep polling", () => {
|
|
assert.match(hookSource, /const paid = payload\.status === "paid" && !failed;/);
|
|
assert.match(hookSource, /status: paid \? "paid" : failed \? "failed" : "pending"/);
|
|
assert.match(hookSource, /if \(paid\) \{/);
|
|
assert.doesNotMatch(hookSource, /status: paid \? "paid" : failed \? "failed" : payload\.status/);
|
|
assert.doesNotMatch(hookSource, /payload\.status === "paid" \? "paid"/);
|
|
});
|
|
|
|
test("polls order status and refreshes only the balance on paid", () => {
|
|
const poll = hookSource.slice(
|
|
hookSource.indexOf("const checkPaymentStatus"),
|
|
hookSource.indexOf("let timer = 0"),
|
|
);
|
|
assert.match(poll, /const paid = payload\.status === "paid" && !failed;/);
|
|
assert.match(poll, /if \(paid\) \{/);
|
|
assert.match(poll, /status: paid \? "paid" : failed \? "failed" : "pending"/);
|
|
assert.match(poll, /fetchAccountData\(\)/);
|
|
assert.match(poll, /notifyBalanceChanged\(refreshed\?\.credits/);
|
|
assert.doesNotMatch(poll, /fetchOrders/);
|
|
});
|
|
|
|
test("keeps the selected product and allows retry on payment failure", () => {
|
|
assert.match(hookSource, /setSelectedProductId\(productId\)/);
|
|
assert.match(hookSource, /setPaymentError\(caught instanceof Error \? caught\.message : "创建支付失败"\)/);
|
|
assert.match(hookSource, /finally \{\s*setPayingProductId\(null\);/);
|
|
assert.match(hookSource, /if \(payingProductId\) return;/);
|
|
assert.match(panelSource, /createPayment\(selectedProductId\)/);
|
|
assert.match(panelSource, /membership-payment-error/);
|
|
assert.doesNotMatch(hookSource, /setPayingProductId\(null\)[\s\S]{0,40}setPaymentOrder\(null\)/);
|
|
});
|
|
|
|
test("membership never opens the redeem dialog from the URL", () => {
|
|
// 原值: redeemOpen 状态,URL 不得带 redeem=
|
|
// 新值: 兑换码是页签;settings-url 的 tab=redeem 打开页签,不再套子弹窗
|
|
// 原因: 决策 5,兑换码不再是子弹窗
|
|
assert.doesNotMatch(billingSource, /redeemOpen|setRedeemOpen/);
|
|
assert.doesNotMatch(billingSource, /searchParams\.get\("redeem"\)|searchParams\.delete\("redeem"\)|redeem:\s*true/);
|
|
assert.match(panelSource, /value="redeem"/);
|
|
assert.doesNotMatch(panelSource, /className="account-modal membership-redeem"/);
|
|
});
|
|
|
|
test("returning prefers history.back and keeps browser back semantics", () => {
|
|
// 原值: goBack → history.back 或 assign("/")
|
|
// 新值: 账单在首页弹窗内,无 goBack;深链接用 stripSettingsQuery 抹参数
|
|
// 原因: 删除整页后返回就是关掉弹窗,浏览器后退仍是原生行为
|
|
assert.doesNotMatch(billingSource, /goBack|history\.back\(/);
|
|
assert.doesNotMatch(panelSource, /window\.location\.assign\("\/"\)/);
|
|
assert.match(pageSource, /stripSettingsQuery\(/);
|
|
});
|
|
|
|
test("trims the redeem code without changing its case", () => {
|
|
assert.match(hookSource, /const code = redeemCode\.trim\(\);/);
|
|
assert.doesNotMatch(hookSource, /redeemCode\.(?:toUpperCase|toLowerCase)\(\)/);
|
|
assert.doesNotMatch(hookSource, /normalizeRedeemCode/);
|
|
assert.match(hookSource, /JSON\.stringify\(\{ code \}\)/);
|
|
});
|
|
|
|
test("redeem success reports awarded credits, latest balance and completes", () => {
|
|
assert.match(hookSource, /awardedCredits/);
|
|
assert.match(hookSource, /本次到账/);
|
|
assert.match(hookSource, /最新余额/);
|
|
assert.match(hookSource, /setRedeemDone\(true\)/);
|
|
assert.match(hookSource, /setRedeemCode\(""\)/);
|
|
assert.match(hookSource, /const refreshed = await fetchAccountData\(\);/);
|
|
assert.match(hookSource, /notifyBalanceChanged\(refreshed\?\.credits \?\? result\.credits\)/);
|
|
assert.match(panelSource, /完成/);
|
|
assert.match(panelSource, /onClick=\{closeRedeem\}/);
|
|
});
|
|
|
|
test("supports plan hints without rendering source notices", () => {
|
|
assert.match(panelSource, /const highlighted = alias === highlightedPlan;/);
|
|
assert.doesNotMatch(billingSource, /searchParams\.get\("source"\)|membershipSourceNotice|sourceNotice/);
|
|
assert.doesNotMatch(billingSource, /membership-notice|已为你定位到/);
|
|
assert.doesNotMatch(billingSource, /体验 \/ 月卡 \/ 年卡见下方套餐|用于咨询与生时校正等服务/);
|
|
});
|
|
|
|
test("membership section shows fixed trial/monthly/yearly plans with monthly recommended", () => {
|
|
assert.match(panelSource, /selectMembershipPlans\(paymentPackages\)/);
|
|
assert.match(panelSource, /planAlias\(product\)/);
|
|
assert.match(panelSource, /alias === "monthly"/);
|
|
assert.match(panelSource, /membership-plan-card--recommended/);
|
|
assert.match(membershipLib, /trial: "体验"/);
|
|
assert.match(membershipLib, /monthly: "月卡"/);
|
|
assert.match(membershipLib, /yearly: "年卡"/);
|
|
assert.match(panelSource, /会员套餐/);
|
|
// 原值: 点数充值
|
|
// 新值: 点数包
|
|
// 原因: VOICE 页签名「会员套餐 / 点数包 / 兑换码 / 订单记录」
|
|
assert.match(panelSource, /点数包/);
|
|
assert.match(panelSource, /id="membership-plans-tab"/);
|
|
assert.match(panelSource, /id="membership-credits-tab"/);
|
|
assert.match(panelSource, /<Tabs[\s\S]*defaultValue=\{billing\.initialTab\}/);
|
|
assert.match(panelSource, /<TabsTrigger[^>]*value="membership"/);
|
|
assert.match(panelSource, /<TabsTrigger[^>]*value="credits"/);
|
|
assert.match(panelSource, /<TabsContent[^>]*value="membership"/);
|
|
assert.match(panelSource, /<TabsContent[^>]*value="credits"/);
|
|
});
|
|
|
|
test("plan cards carry price, duration, audience, entitlements, purchase and rules", () => {
|
|
assert.match(panelSource, /formatPrice\(product\.priceCents, product\.currency\)/);
|
|
assert.match(panelSource, /formatProductDuration\(product\)/);
|
|
assert.match(panelSource, /productAudience\(product\)/);
|
|
assert.match(panelSource, /entitlementLabel\(entitlement\)/);
|
|
assert.match(panelSource, /<details className="membership-rules">/);
|
|
assert.match(panelSource, /<summary>详细规则<\/summary>/);
|
|
assert.match(panelSource, /import \{ Button \} from "@\/components\/ui\/button"/);
|
|
assert.match(panelSource, /className="membership-buy"/);
|
|
assert.match(panelSource, /variant=\{recommended \? "default" : "outline"\}/);
|
|
});
|
|
|
|
test("renewal semantics map the active subscription to the matching plan", () => {
|
|
assert.match(hookSource, /const currentProductCode = activeSubscription\?\.status === "active"/);
|
|
assert.match(panelSource, /currentProductCode === product\.code/);
|
|
assert.match(panelSource, /isCurrent \? "续费" : product\.productType === "trial" \? "立即开通" : "立即购买"/);
|
|
assert.match(panelSource, /当前套餐/);
|
|
});
|
|
|
|
test("membership and credits use the local Base UI tabs wrapper without manual segment state", () => {
|
|
assert.match(panelSource, /import \{ Tabs, TabsContent, TabsList, TabsTrigger \} from "@\/components\/ui\/tabs"/);
|
|
assert.doesNotMatch(billingSource, /activeSegment|setActiveSegment/);
|
|
assert.match(tabsSource, /import \{ Tabs as TabsPrimitive \} from "@base-ui\/react\/tabs"/);
|
|
assert.match(tabsSource, /TabsPrimitive\.Root/);
|
|
assert.match(tabsSource, /TabsPrimitive\.List/);
|
|
assert.match(tabsSource, /TabsPrimitive\.Tab/);
|
|
assert.match(tabsSource, /TabsPrimitive\.Panel/);
|
|
assert.match(panelSource, /const plans = selectMembershipPlans\(paymentPackages\);/);
|
|
assert.match(panelSource, /const creditPacks = paymentPackages\.filter\(\(product\) => product\.productType === "credit_pack"\);/);
|
|
assert.match(panelSource, /className="membership-plan-grid"/);
|
|
assert.match(panelSource, /className="membership-credit-grid"/);
|
|
assert.doesNotMatch(panelSource, /creditPacks\.map[\.\s\S]{0,120}membership-plan-card/);
|
|
});
|
|
|
|
test("plan param forces the membership segment and highlights the matching card", () => {
|
|
assert.match(panelSource, /const highlighted = alias === highlightedPlan;/);
|
|
assert.match(panelSource, /membership-plan-card--highlighted/);
|
|
assert.match(panelSource, /aria-current=\{highlighted \? "true" : undefined\}/);
|
|
assert.match(panelSource, /highlightedCardRef/);
|
|
assert.match(hookSource, /scrollIntoView\(\{ behavior: "smooth", block: "center" \}\)/);
|
|
assert.match(hookSource, /card\.focus\(\{ preventScroll: true \}\)/);
|
|
assert.match(hookSource, /initialTab: input\.highlightedPlan \? "membership" : input\.initialTab/);
|
|
assert.doesNotMatch(billingSource, /setActiveSegment\("credits"\)[\s\S]{0,80}highlightedPlan/);
|
|
});
|
|
|
|
test("page main title is 套餐与会员", () => {
|
|
// 原值: <h1 className="membership-title">套餐与会员</h1>
|
|
// 新值: 分区标题由弹窗壳提供「账户与点数」,摘要行写余额与会员状态
|
|
// 原因: 不再是独立页面
|
|
assert.doesNotMatch(panelSource, /<h1 className="membership-title">/);
|
|
assert.match(panelSource, /className="billing-summary"/);
|
|
assert.match(panelSource, /aria-label="账户与点数"/);
|
|
});
|
|
|
|
test("payment errors sit in a common position above the visible segment", () => {
|
|
const errorAt = panelSource.indexOf("membership-payment-error");
|
|
const plansPanelAt = panelSource.indexOf('<TabsContent className="membership-panel" value="membership">');
|
|
assert.notEqual(errorAt, -1, "missing common payment error surface");
|
|
assert.notEqual(plansPanelAt, -1, "missing plans panel");
|
|
assert.ok(errorAt < plansPanelAt, "payment error must render before the segment panels");
|
|
assert.doesNotMatch(panelSource, /<p className="form-error" role="alert">\{paymentError\}<\/p>/);
|
|
});
|
|
|
|
test("summary stays on the membership page and links to the independent orders page", () => {
|
|
// 原值: membership-summary-card + Link 到 /membership/orders
|
|
// 新值: billing-summary + 订单记录页签,行内列出订单
|
|
// 原因: 删除独立订单页
|
|
assert.match(panelSource, /className="billing-summary"/);
|
|
assert.match(panelSource, /value="orders"/);
|
|
assert.match(panelSource, /membership-order-row/);
|
|
assert.match(panelSource, /orderStatusLabel/);
|
|
assert.doesNotMatch(panelSource, /href="\/membership\/orders"/);
|
|
});
|
|
|
|
test("independent orders page preserves loading, empty, error, refresh and list states", () => {
|
|
// 原值: 独立订单页含「正在加载订单…」
|
|
// 新值: 页签内 idle/ready/unavailable;空与失败有文案,没有加载句
|
|
// 原因: AGENTS §6 禁止「正在加载」
|
|
assert.match(hookSource, /fetch\("\/api\/payment\/orders"/);
|
|
assert.match(hookSource, /response\.status === 401[\s\S]*window\.location\.assign\("\/login"\)/);
|
|
assert.doesNotMatch(billingSource, /正在加载订单/);
|
|
assert.match(panelSource, /订单记录暂时不可用,请稍后刷新。/);
|
|
assert.match(panelSource, /暂无订单。/);
|
|
assert.match(panelSource, /onClick=\{\(\) => void fetchOrders\(\)\}>刷新/);
|
|
assert.match(panelSource, /membership-order-row/);
|
|
assert.match(panelSource, /orderStatusLabel\(order\.status\)/);
|
|
assert.match(panelSource, /formatPrice\(order\.moneyCents, order\.currency\)/);
|
|
assert.match(membershipLib, /paid: "已支付"/);
|
|
});
|
|
|
|
test("orders return replaces its history entry so membership back cannot reopen orders", () => {
|
|
// 原值: Link replace 回 /membership
|
|
// 新值: 页签切换不写历史;无 membership-back
|
|
// 原因: 不再有两页互相推历史
|
|
assert.doesNotMatch(billingSource, /membership-back|href="\/membership"/);
|
|
});
|
|
|
|
test("payment and plan copy points users to the separate orders page", () => {
|
|
// 原值: 本页会自动检查支付状态并刷新余额 / 「订单记录」页面
|
|
// 新值: 收银台打开后自动检查;订单在页签查看
|
|
// 原因: 订单不再是另一页
|
|
assert.match(panelSource, /请在已打开的收银台页面完成支付;打开后会自动检查支付状态并刷新余额。/);
|
|
assert.match(panelSource, /「订单记录」页签/);
|
|
assert.doesNotMatch(panelSource, /本页会自动刷新余额与订单记录|本页「订单记录」|用于咨询、报告与生时校正等服务/);
|
|
});
|
|
|
|
test("redeem modal surface contains title, balance, input, redeem, rules and close", () => {
|
|
// 原值: 兑换子弹窗含 aria-label=关闭
|
|
// 新值: 兑换码页签含标题、余额、输入、兑换、规则;关闭属于设置弹窗外框
|
|
// 原因: 决策 5
|
|
assert.match(panelSource, /id="membership-redeem-title"/);
|
|
assert.match(panelSource, /<h2 id="membership-redeem-title">兑换点数<\/h2>/);
|
|
assert.match(panelSource, /className="redeem-balance"/);
|
|
assert.match(panelSource, /id="membership-redeem-code"/);
|
|
assert.match(panelSource, /立即兑换/);
|
|
assert.match(panelSource, /<summary>兑换规则<\/summary>/);
|
|
assert.match(panelSource, /className="redeem-form"/);
|
|
assert.match(panelSource, /<Button type="submit"/);
|
|
assert.doesNotMatch(panelSource, /className="account-modal-overlay"/);
|
|
});
|
|
|
|
test("401 redirects stay hard navigations so no client state survives the sign-out", () => {
|
|
assert.match(hookSource, /if \(response\.status === 401\) \{\s*window\.location\.assign\("\/login"\);/);
|
|
assert.equal(countMatches(hookSource, /window\.location\.assign\("\/login"\)/g), 4);
|
|
assert.doesNotMatch(billingSource, /router\.(push|replace)\("\/login"\)/);
|
|
});
|