Files
Jyotisha/frontend/tests/membership-page.test.ts
T
Jesse_Chen 54269fcfcc
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
refactor(chat): extract home helpers, chart library, and starter surfaces
page.tsx still owns the chat main chain, but the first product surfaces now
live in their own modules so later splits can land without editing the 4k-line
Home. Source-lock tests follow the moved tokens; the orphan user-data contract
is aligned and added to the quick gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 22:58:02 +08:00

364 lines
21 KiB
TypeScript

import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import { cssDeclarations } from "./css-contract-test-support.ts";
import { homeSurface as homePageSource } from "./home-surface.ts";
const projectFile = (path: string) => new URL(`../${path}`, import.meta.url);
const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8");
const globalStyles = readProjectFile("src/app/globals.css");
const onboardingPaywallSource = readProjectFile("src/components/onboarding-redeem-paywall.tsx");
const pageSource = readProjectFile("src/app/membership/page.tsx");
const ordersPageSource = readProjectFile("src/app/membership/orders/page.tsx");
const tabsSource = readProjectFile("src/components/ui/tabs.tsx");
const membershipLib = readProjectFile("src/lib/membership.ts");
const cssBlock = (selector: string) => cssDeclarations(selector, globalStyles);
test("membership page exists as a client component with a loading boundary", () => {
assert.equal(existsSync(projectFile("src/app/membership/page.tsx")), true);
assert.match(pageSource, /"use client"/);
assert.match(pageSource, /<Suspense/);
assert.match(pageSource, /membership-loading/);
});
test("membership page loads account, packages and redeem endpoints without fetching orders", () => {
assert.match(pageSource, /fetch\("\/api\/account"/);
assert.match(pageSource, /fetch\("\/api\/payment\/packages"/);
assert.doesNotMatch(pageSource, /\/api\/payment\/orders|fetchOrders/);
assert.match(pageSource, /fetch\("\/api\/payment\/epay\/create"/);
assert.match(pageSource, /fetch\(`\/api\/payment\/epay\/status\?orderNo=/);
assert.match(pageSource, /fetch\("\/api\/redeem"/);
});
test("creates orders with productId and opens the signed cashier URL safely", () => {
assert.match(pageSource, /JSON\.stringify\(\{ productId \}\)/);
assert.match(pageSource, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/);
assert.doesNotMatch(pageSource, /document\.createElement\("form"\)|payload\.submitUrl|payload\.fields/);
assert.match(pageSource, /if \(!paymentOrder \|\| paymentOrder\.status !== "pending"\) return;/);
});
test("terminal payment failures stop polling and offer re-pay with the same product", () => {
assert.match(pageSource, /\["failed", "closed", "cancelled"\]\.includes\(payload\.status\)/);
assert.match(pageSource, /payload\.grantStatus === "failed"/);
assert.match(pageSource, /status !== "pending"\) return;/);
assert.match(pageSource, /status: paid \? "paid" : failed \? "failed" : "pending"/);
assert.match(pageSource, /paymentOrder\.status === "failed"/);
assert.match(pageSource, /支付失败/);
assert.match(pageSource, /createPayment\(paymentOrder\.productId\)/);
assert.match(pageSource, /重新支付/);
});
test("failed status beats paid and non-terminal statuses keep polling", () => {
assert.match(pageSource, /const paid = payload\.status === "paid" && !failed;/);
assert.match(pageSource, /status: paid \? "paid" : failed \? "failed" : "pending"/);
assert.match(pageSource, /if \(paid\) \{/);
assert.doesNotMatch(pageSource, /status: paid \? "paid" : failed \? "failed" : payload\.status/);
assert.doesNotMatch(pageSource, /payload\.status === "paid" \? "paid"/);
});
test("polls order status and refreshes only the balance on paid", () => {
assert.match(pageSource, /const paid = payload\.status === "paid" && !failed;/);
assert.match(pageSource, /if \(paid\) \{/);
assert.match(pageSource, /status: paid \? "paid" : failed \? "failed" : "pending"/);
assert.match(pageSource, /fetchAccountData\(\)/);
assert.match(pageSource, /notifyBalanceChanged\(refreshed\?\.credits/);
assert.doesNotMatch(pageSource, /fetchOrders/);
});
test("keeps the selected product and allows retry on payment failure", () => {
assert.match(pageSource, /setSelectedProductId\(productId\)/);
assert.match(pageSource, /setPaymentError\(caught instanceof Error \? caught\.message : "创建支付失败"\)/);
assert.match(pageSource, /finally \{\s*setPayingProductId\(null\);/);
assert.match(pageSource, /if \(payingProductId\) return;/);
assert.match(pageSource, /createPayment\(selectedProductId\)/);
assert.match(pageSource, /membership-payment-error/);
assert.doesNotMatch(pageSource, /setPayingProductId\(null\)[\s\S]{0,40}setPaymentOrder\(null\)/);
});
test("membership never opens the redeem dialog from the URL", () => {
assert.match(pageSource, /const \[redeemOpen, setRedeemOpen\] = useState\(false\)/);
assert.doesNotMatch(pageSource, /searchParams\.get\("redeem"\)|searchParams\.delete\("redeem"\)|redeem:\s*true/);
assert.match(pageSource, /onClick=\{\(\) => setRedeemOpen\(true\)\}/);
});
test("returning prefers history.back and keeps browser back semantics", () => {
assert.match(pageSource, /window\.history\.back\(\)/);
assert.match(pageSource, /window\.location\.assign\("\/"\)/);
assert.match(pageSource, /onClick=\{goBack\}/);
});
test("home refreshes the account whenever it is shown again", () => {
const pageShow = homePageSource.slice(
homePageSource.indexOf("const onPageShow"),
homePageSource.indexOf("const onOnline"),
);
assert.match(pageShow, /void refreshAccount\(\)/);
assert.doesNotMatch(pageShow, /event\.persisted/);
});
test("login redirects keep the loading screen instead of flashing the account error page", () => {
assert.match(homePageSource, /class LoginRedirectError extends Error/);
assert.match(homePageSource, /window\.location\.replace\("\/login"\)/);
assert.match(homePageSource, /if \(caught instanceof LoginRedirectError\) \{\n\s*redirectedToLogin = true;\n\s*return;/);
assert.match(homePageSource, /if \(redirectedToLogin\) return;/);
});
test("homepage composer does not render notice copy", () => {
assert.doesNotMatch(homePageSource, /composer-notice/);
});
test("trims the redeem code without changing its case", () => {
assert.match(pageSource, /const code = redeemCode\.trim\(\);/);
assert.doesNotMatch(pageSource, /redeemCode\.(?:toUpperCase|toLowerCase)\(\)/);
assert.doesNotMatch(pageSource, /normalizeRedeemCode/);
assert.match(pageSource, /JSON\.stringify\(\{ code \}\)/);
});
test("redeem success reports awarded credits, latest balance and completes", () => {
assert.match(pageSource, /awardedCredits/);
assert.match(pageSource, /本次到账/);
assert.match(pageSource, /最新余额/);
assert.match(pageSource, /setRedeemDone\(true\)/);
assert.match(pageSource, /setRedeemCode\(""\)/);
assert.match(pageSource, /const refreshed = await fetchAccountData\(\);/);
assert.match(pageSource, /notifyBalanceChanged\(refreshed\?\.credits \?\? result\.credits\)/);
assert.match(pageSource, /完成/);
assert.match(pageSource, /onClick=\{closeRedeem\}/);
});
test("supports plan hints without rendering source notices", () => {
assert.match(pageSource, /searchParams\.get\("plan"\)/);
assert.doesNotMatch(pageSource, /searchParams\.get\("source"\)|membershipSourceNotice|sourceNotice/);
assert.doesNotMatch(pageSource, /membership-notice|已为你定位到/);
assert.doesNotMatch(globalStyles, /\.membership-notice/);
assert.doesNotMatch(pageSource, /体验 \/ 月卡 \/ 年卡见下方套餐|用于咨询与生时校正等服务/);
});
test("membership section shows fixed trial/monthly/yearly plans with monthly recommended", () => {
assert.match(pageSource, /selectMembershipPlans\(paymentPackages\)/);
assert.match(pageSource, /planAlias\(product\)/);
assert.match(pageSource, /alias === "monthly"/);
assert.match(pageSource, /membership-plan-card--recommended/);
assert.match(membershipLib, /trial: "体验"/);
assert.match(membershipLib, /monthly: "月卡"/);
assert.match(membershipLib, /yearly: "年卡"/);
assert.match(pageSource, /会员套餐/);
assert.match(pageSource, /点数充值/);
assert.match(pageSource, /id="membership-plans-tab"/);
assert.match(pageSource, /id="membership-credits-tab"/);
assert.match(pageSource, /<Tabs defaultValue="membership">/);
assert.match(pageSource, /<TabsTrigger[^>]*value="membership"/);
assert.match(pageSource, /<TabsTrigger[^>]*value="credits"/);
assert.match(pageSource, /<TabsContent[^>]*value="membership"/);
assert.match(pageSource, /<TabsContent[^>]*value="credits"/);
});
test("plan cards carry price, duration, audience, entitlements, purchase and rules", () => {
assert.match(pageSource, /formatPrice\(product\.priceCents, product\.currency\)/);
assert.match(pageSource, /formatProductDuration\(product\)/);
assert.match(pageSource, /productAudience\(product\)/);
assert.match(pageSource, /entitlementLabel\(entitlement\)/);
assert.match(pageSource, /<details className="membership-rules">/);
assert.match(pageSource, /<summary>详细规则<\/summary>/);
assert.match(pageSource, /className=\{recommended \? "button-primary membership-buy" : "button-secondary membership-buy"\}/);
});
test("renewal semantics map the active subscription to the matching plan", () => {
assert.match(pageSource, /const currentProductCode = activeSubscription\?\.status === "active"/);
assert.match(pageSource, /currentProductCode === product\.code/);
assert.match(pageSource, /isCurrent \? "续费" : product\.productType === "trial" \? "立即开通" : "立即购买"/);
assert.match(pageSource, /当前套餐/);
});
test("membership and credits use the local Base UI tabs wrapper without manual segment state", () => {
assert.match(pageSource, /import \{ Tabs, TabsContent, TabsList, TabsTrigger \} from "@\/components\/ui\/tabs"/);
assert.doesNotMatch(pageSource, /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(pageSource, /const plans = selectMembershipPlans\(paymentPackages\);/);
assert.match(pageSource, /const creditPacks = paymentPackages\.filter\(\(product\) => product\.productType === "credit_pack"\);/);
assert.match(pageSource, /className="membership-plan-grid"/);
assert.match(pageSource, /className="membership-credit-grid"/);
assert.doesNotMatch(pageSource, /creditPacks\.map[\.\s\S]{0,120}membership-plan-card/);
});
test("plan param forces the membership segment and highlights the matching card", () => {
assert.match(pageSource, /const highlighted = alias === highlightedPlan;/);
assert.match(pageSource, /membership-plan-card--highlighted/);
assert.match(pageSource, /aria-current=\{highlighted \? "true" : undefined\}/);
assert.match(pageSource, /highlightedCardRef/);
assert.match(pageSource, /scrollIntoView\(\{ behavior: "smooth", block: "center" \}\)/);
assert.match(pageSource, /card\.focus\(\{ preventScroll: true \}\)/);
assert.doesNotMatch(pageSource, /setActiveSegment\(\"credits\"\)[\s\S]{0,80}highlightedPlan/);
});
test("page main title is 套餐与会员", () => {
assert.match(pageSource, /<h1 className="membership-title">套餐与会员<\/h1>/);
assert.doesNotMatch(pageSource, /<h1 className="membership-title">会员与点数<\/h1>/);
});
test("payment errors sit in a common position above the visible segment", () => {
const errorAt = pageSource.indexOf("membership-payment-error");
const plansPanelAt = pageSource.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(pageSource, /<p className="form-error" role="alert">\{paymentError\}<\/p>/);
});
test("summary stays on the membership page and links to the independent orders page", () => {
assert.match(pageSource, /会员状态/);
assert.match(pageSource, /当前余额/);
assert.match(pageSource, /membership-summary-card/);
assert.match(pageSource, /href="\/membership\/orders"[^>]*>订单记录<\/Link>/);
assert.doesNotMatch(pageSource, /membership-order-row|orderStatusLabel/);
});
test("independent orders page preserves loading, empty, error, refresh and list states", () => {
assert.equal(existsSync(projectFile("src/app/membership/orders/page.tsx")), true);
assert.match(ordersPageSource, /fetch\("\/api\/payment\/orders"/);
assert.match(ordersPageSource, /response\.status === 401[\s\S]*window\.location\.assign\("\/login"\)/);
assert.match(ordersPageSource, /正在加载订单…/);
assert.match(ordersPageSource, /订单记录暂时不可用,请稍后刷新。/);
assert.match(ordersPageSource, /暂无订单。/);
assert.match(ordersPageSource, /onClick=\{\(\) => void fetchOrders\(\)\}>刷新/);
assert.match(ordersPageSource, /membership-order-row/);
assert.match(ordersPageSource, /orderStatusLabel\(order\.status\)/);
assert.match(ordersPageSource, /formatPrice\(order\.moneyCents, order\.currency\)/);
assert.match(membershipLib, /paid: "已支付"/);
});
test("orders return replaces its history entry so membership back cannot reopen orders", () => {
assert.match(ordersPageSource, /<Link className="membership-back" href="\/membership" replace/);
});
test("finished starter questions open the redeem paywall only for accounts without access", () => {
assert.match(homePageSource, /!starterHomeVisible/);
assert.match(homePageSource, /account\.credits > 0/);
assert.match(homePageSource, /account\.activeSubscription\?\.status === "active"/);
assert.match(homePageSource, /setOnboardingPaywallOpen\(true\)/);
assert.match(homePageSource, /<OnboardingRedeemPaywall/);
assert.match(onboardingPaywallSource, /入门问题已经准备好/);
assert.match(onboardingPaywallSource, /fetch\("\/api\/redeem"/);
assert.match(onboardingPaywallSource, /兑换并继续/);
assert.match(onboardingPaywallSource, /membershipHref\("onboarding-paywall"\)/);
});
test("the onboarding paywall reuses the shared account dialog header", () => {
// The close button only sits beside the title when the header carries the shared
// flex class. A bespoke wrapper leaves it stacked under the heading on mobile.
assert.match(onboardingPaywallSource, /<header className="account-modal-header">/);
assert.match(onboardingPaywallSource, /<\/header>/);
assert.doesNotMatch(onboardingPaywallSource, /className="dialog-header"/);
assert.match(onboardingPaywallSource, /className="account-modal-overlay"/);
assert.match(onboardingPaywallSource, /className="account-modal paywall-modal"/);
assert.match(onboardingPaywallSource, /className="dialog-close"/);
});
test("every static class the onboarding paywall renders has a rule in globals.css", () => {
// Guards the whole defect class: a class name with no matching selector renders
// as an unstyled block and silently breaks the layout.
const used = new Set(
[...onboardingPaywallSource.matchAll(/className="([^"{}]+)"/g)]
.flatMap((hit) => hit[1].split(/\s+/))
.filter(Boolean),
);
assert.ok(used.size >= 12, `expected the paywall to carry its usual classes, saw ${used.size}`);
const undefinedClasses = [...used].filter((name) => (
!new RegExp(`\\.${name}(?![\\w-])`).test(globalStyles)
));
assert.deepEqual(undefinedClasses, []);
});
test("payment and plan copy points users to the separate orders page", () => {
assert.match(pageSource, /本页会自动检查支付状态并刷新余额。/);
assert.match(pageSource, /「订单记录」页面/);
assert.doesNotMatch(pageSource, /本页会自动刷新余额与订单记录|本页「订单记录」|用于咨询、报告与生时校正等服务/);
});
test("membership hero stays content-led without decorative orbit artwork", () => {
assert.doesNotMatch(pageSource, /membership-celestial-visual|membership-orbit|membership-celestial-mark/);
assert.doesNotMatch(globalStyles, /membership-celestial-visual|membership-orbit|membership-celestial-mark/);
assert.match(cssBlock(".membership-hero"), /background:\s*var\(--color-canvas-soft\)/);
});
test("redeem modal surface contains title, balance, input, redeem, rules and close", () => {
assert.match(pageSource, /id="membership-redeem-title"/);
assert.match(pageSource, /<h2 id="membership-redeem-title">兑换点数<\/h2>/);
assert.match(pageSource, /className="redeem-balance"/);
assert.match(pageSource, /id="membership-redeem-code"/);
assert.match(pageSource, /立即兑换/);
assert.match(pageSource, /<summary>兑换规则<\/summary>/);
assert.match(pageSource, /aria-label="关闭"/);
});
test("redeem modal reuses the existing account dialog classes", () => {
assert.match(pageSource, /className="account-modal-overlay"/);
assert.match(pageSource, /className="account-modal membership-redeem"/);
assert.match(pageSource, /className="account-modal-header"/);
assert.match(pageSource, /className="dialog-close"/);
assert.match(pageSource, /className="redeem-form"/);
assert.match(pageSource, /className="form-error" role="alert"/);
assert.match(pageSource, /className="form-success" role="status"/);
assert.match(pageSource, /className="button-primary"/);
});
test("desktop uses three columns, tablet two and mobile one with monthly first", () => {
assert.match(globalStyles, /\.membership-plan-grid,[\s\S]*\.membership-credit-grid\s*\{[^}]*grid-template-columns:\s*1fr/);
assert.match(globalStyles, /@media \(min-width:\s*640px\)[\s\S]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
assert.match(globalStyles, /@media \(min-width:\s*1024px\)[\s\S]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/);
assert.match(globalStyles, /@media \(max-width:\s*639px\)[\s\S]*\.membership-plan-card--recommended\s*\{[^}]*order:\s*-1/);
assert.doesNotMatch(cssBlock(".membership-plan-card--recommended"), /grid-column/);
});
test("segment tabs use a quiet underline treatment", () => {
assert.match(cssBlock(".membership-tabs"), /border-bottom:\s*1px solid var\(--color-border\)/);
assert.doesNotMatch(cssBlock(".membership-tabs"), /border-radius|background|box-shadow/);
assert.match(cssBlock(".membership-tab[data-active]"), /border-bottom-color:\s*var\(--color-action\)/);
assert.match(cssBlock(".membership-tab[data-active]"), /background:\s*transparent/);
assert.match(cssBlock(".membership-tab[data-active]"), /box-shadow:\s*none/);
assert.match(cssBlock(".membership-plan-card--highlighted"), /outline:\s*2px solid color-mix\(in srgb, var\(--color-action\)/);
assert.match(cssBlock(".membership-payment-error"), /background:\s*transparent/);
});
test("membership shell is capped within the 1120-1200 range", () => {
assert.match(cssBlock(".membership-shell"), /width:\s*min\(1160px, 100%\)/);
assert.match(cssBlock(".membership-shell"), /margin:\s*0 auto/);
});
test("membership page reuses the warm parchment / terracotta tokens", () => {
assert.match(cssBlock(".membership-hero"), /background:\s*var\(--color-canvas-soft\)/);
assert.doesNotMatch(cssBlock(".membership-hero"), /linear-gradient/);
assert.doesNotMatch(cssBlock(".membership-hero"), /#fbfaf7|#f5f1eb/);
assert.match(cssBlock(".membership-plan-card--current"), /border-color:\s*var\(--color-action\)/);
assert.match(cssBlock(".membership-title"), /var\(--color-ink\)/);
assert.match(cssBlock(".membership-status"), /color:\s*var\(--color-action\)/);
assert.doesNotMatch(globalStyles, /membership-badge--recommended/);
});
test("membership page scrolls within a fixed viewport and cards stay flat", () => {
assert.match(cssBlock(".membership-page"), /height:\s*100dvh/);
assert.match(cssBlock(".membership-page"), /overflow-y:\s*auto/);
assert.match(cssBlock(".membership-plan-card, .membership-credit-card"), /box-shadow:\s*none/);
assert.match(cssBlock(".membership-plan-card--recommended"), /background:\s*var\(--color-canvas\)/);
assert.doesNotMatch(globalStyles, /membership-plan-card--recommended::before/);
});
test("membership lib keeps helpers and the balance sync contract", () => {
assert.match(membershipLib, /export const BALANCE_SYNC_KEY = "jyotisha:balance:updated"/);
assert.match(membershipLib, /export const BALANCE_CHANGED_EVENT = "jyotisha:balance:changed"/);
assert.match(membershipLib, /export function notifyBalanceChanged\(credits: number\)/);
assert.match(membershipLib, /localStorage\.setItem\(BALANCE_SYNC_KEY/);
assert.match(membershipLib, /new CustomEvent\(BALANCE_CHANGED_EVENT/);
assert.match(membershipLib, /export function formatPrice\(priceCents: number, currency = "CNY"\)/);
assert.match(membershipLib, /export function orderStatusLabel/);
assert.match(membershipLib, /export function planDisplayLabel/);
assert.match(membershipLib, /export function membershipHref\(source: string, options: \{ plan\?: MembershipPlanAlias \} = \{\}\)/);
assert.doesNotMatch(membershipLib, /params\.set\("redeem"/);
});