341 lines
19 KiB
TypeScript
341 lines
19 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 globalStyles = readProjectFile("src/app/globals.css");
|
|
const homePageSource = readProjectFile("src/app/page.tsx");
|
|
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");
|
|
|
|
function cssBlock(selector: string) {
|
|
const start = globalStyles.indexOf(`${selector} {`);
|
|
assert.notEqual(start, -1, `missing CSS selector: ${selector}`);
|
|
const end = globalStyles.indexOf("}", start);
|
|
assert.notEqual(end, -1, `unterminated CSS selector: ${selector}`);
|
|
return globalStyles.slice(start, end);
|
|
}
|
|
|
|
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\) 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("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"/);
|
|
});
|