Files
Jyotisha/frontend/tests/membership-page.test.ts
T
Jesse_ChenandClaude Opus 5.5 dec36bce01 feat(birth-sky): poster-only dialog, date/time/city caption, slower reveal, paywall on first refused send (P1–P5, BUG-1080)
TASK-birth-sky-polish-20260928.
- P1: chart page dialog shows only the picture on a dimmed stage, 「保存图片」 below, icon × in the corner.
- P2: BFF returns one server-built `caption` (「1994年5月18日 07:40 · 杭州」); printed under the fact line in all three places; adopted time adds 「 · 校正后」.
- P3: convergence 3 s + words 2.5 s + fade 1 s; settled frame still equals the exported cover.
- P4: redeem paywall no longer opens when the starter home appears; first send refused for credits opens it, later ones open billing; convergence dialog portalled to <body>. BUG-1080 investigating (Chrome repro rules out the overlay covering the convergence).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-09-29 00:03:46 +08:00

176 lines
11 KiB
TypeScript
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.
import assert from "node:assert/strict";
import { 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 membershipLib = readProjectFile("src/lib/membership.ts");
const cssBlock = (selector: string) => cssDeclarations(selector, globalStyles);
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/);
});
// 原值: 名为 "finished starter questions open the redeem paywall only for accounts without access";
// page.tsx 里有「!starterHomeVisible / credits > 0 / activeSubscription active」守卫的 effect 调 setOnboardingPaywallOpen(true);
// 付费墙文案含「入门问题已经准备好」。
// 新值: 进首页不再自动弹;useConsultationRun 在本页第一次因点数不足发不出消息(前置余额检查或 402)时打开兑换弹窗,
// 之后照旧 billing;文案改为「问题还在输入框里。」。
// 原因: TASK-birth-sky-polish-20260928 P4 / BUG-1080:新账号首次建盘时兑换弹窗与群星过场同时出现,产品改为发第一条消息时再弹。
test("the redeem paywall opens on the first send refused for credits, not when the starter home appears", () => {
const hookSource = readProjectFile("src/hooks/use-consultation-run.ts");
const pageFile = readProjectFile("src/app/(app)/page.tsx");
assert.doesNotMatch(pageFile, /setOnboardingPaywallOpen\(true\)/);
assert.doesNotMatch(pageFile, /onboardingPaywallShown\.current = true/);
assert.match(pageFile, /onboardingPaywallShown, setOnboardingPaywallOpen,\n\s*\}\);/);
assert.match(hookSource, /const openRedeemPaywallOnce = \(\) => tryOpenRedeemPaywall\(\{\s*uiPreview: uiPreview\.current,\s*shown: onboardingPaywallShown,\s*open: setOnboardingPaywallOpen,\s*\}\);/);
// Both refusal paths (pre-flight balance check and 402) try the paywall first, then billing.
assert.equal(hookSource.match(/if \(!openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/g)?.length, 1);
assert.equal(hookSource.match(/if \(status === 402 && !openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/g)?.length, 1);
assert.match(homePageSource, /<OnboardingRedeemPaywall/);
assert.match(onboardingPaywallSource, /问题还在输入框里。/);
assert.doesNotMatch(onboardingPaywallSource, /入门问题已经准备好/);
assert.match(onboardingPaywallSource, /fetch\("\/api\/redeem"/);
assert.match(onboardingPaywallSource, /兑换并继续/);
// 原值: membershipHref("onboarding-paywall") 的 Link
// 新值: onOpenBilling 按钮,关掉付费墙后打开设置弹窗 billing 格
// 原因: 七处入口改回调,对话状态不丢
assert.match(onboardingPaywallSource, /onOpenBilling/);
assert.match(onboardingPaywallSource, /查看套餐与会员/);
assert.doesNotMatch(onboardingPaywallSource, /membershipHref\("onboarding-paywall"\)/);
assert.match(homePageSource, /openAccountDialog\("billing", \{ source: "onboarding-paywall" \}\)/);
});
test("the onboarding paywall reuses the shared account dialog header", () => {
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", () => {
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("membership hero stays content-led without decorative orbit artwork", () => {
assert.doesNotMatch(globalStyles, /membership-celestial-visual|membership-orbit|membership-celestial-mark/);
assert.doesNotMatch(globalStyles, /\.membership-hero|\.membership-page|\.membership-shell/);
});
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/);
// 原值: 会员栅格 min-width 640 / max-width 639,与校正的 640/641 差 1px。
// 新值: 统一为 640/641。
// 原因: BUG-697 噪音收敛,白名单只留一组内容级切点。
assert.match(globalStyles, /@media \(min-width:\s*641px\)[\s\S]*\.membership-plan-grid[\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\)\)/);
// 原值: /@media \(max-width: 640px\)[\s\S]*\.membership-plan-card--recommended\s*\{[^}]*order: -1/
// 新值: 同一条规则改用 .membership-plan-card[data-plan-alias="monthly"]。
// 原因: D13 后「推荐」是互斥状态,月卡一旦成为当前套餐就失去 is-recommended;
// 手机端「月卡排第一」是版位而不是状态,因此改挂在套餐身份上,排序与改前逐字一致。
assert.match(globalStyles, /@media \(max-width:\s*640px\)[\s\S]*\.membership-plan-card\[data-plan-alias="monthly"\]\s*\{[^}]*order:\s*-1/);
assert.doesNotMatch(cssBlock(".membership-plan-card.is-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: 2px solid color-mix\(in srgb, var\(--color-action\)/);
// 新值: 断言该规则已不存在——?plan= 高亮不再画描边。
// 原因: D13,URL 高亮改为滚动定位,不叠第三层外观。
assert.doesNotMatch(globalStyles, /membership-plan-card--highlighted/);
assert.match(cssBlock(".membership-payment-error"), /background:\s*transparent/);
});
test("membership shell is capped within the 1120-1200 range", () => {
// 原值: .membership-shell width min(1160px, 100%)
// 新值: 页面壳删除;账单装进 .settings-modal 固定尺寸
// 原因: 删除 /membership 整页
assert.doesNotMatch(globalStyles, /\.membership-shell/);
assert.match(cssBlock(".settings-modal"), /width:\s*min\(100vw - 32px, 880px\)/);
assert.match(cssBlock(".settings-modal"), /height:\s*min\(84dvh, 640px\)/);
});
test("membership page reuses the warm parchment / terracotta tokens", () => {
// 原值: cssBlock(".membership-plan-card--current")
// 新值: cssBlock(".membership-plan-card.is-current"),并补一条「两种状态外观不相同」。
// 原因: D13 状态类改名为互斥的 is-current / is-recommended;token 与改前一致。
assert.match(cssBlock(".membership-plan-card.is-current"), /border-color:\s*var\(--color-action\)/);
assert.notEqual(
cssBlock(".membership-plan-card.is-current"),
cssBlock(".membership-plan-card.is-recommended"),
"当前套餐与推荐必须有可区分的外观",
);
assert.match(cssBlock(".membership-status"), /color:\s*var\(--color-action\)/);
assert.doesNotMatch(globalStyles, /membership-badge--recommended/);
assert.doesNotMatch(globalStyles, /\.membership-title|\.membership-hero/);
});
test("membership page scrolls within a fixed viewport and cards stay flat", () => {
// 原值: .membership-page height 100dvh overflow-y auto
// 新值: 内容区 .settings-dialog-content 独立滚动;卡片仍无阴影
// 原因: 固定尺寸设置弹窗
assert.doesNotMatch(globalStyles, /\.membership-page/);
assert.match(cssBlock(".settings-dialog-content"), /overflow-y:\s*auto/);
assert.match(cssBlock(".membership-plan-card, .membership-credit-card"), /box-shadow:\s*none/);
// 原值: cssBlock(".membership-plan-card--recommended")
// 新值: cssBlock(".membership-plan-card.is-recommended")
// 原因: 同上,D13 状态类改名;声明本身未动。
assert.match(cssBlock(".membership-plan-card.is-recommended"), /background:\s*var\(--color-canvas\)/);
assert.doesNotMatch(globalStyles, /membership-plan-card(--recommended|\.is-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/);
// 原值: options: { plan?: MembershipPlanAlias } = {},href /membership
// 新值: 可带 tab;生成 /?settings=billing&source=…
// 原因: 深链接与重定向,不再指向已删页面
assert.match(membershipLib, /export function membershipHref\(/);
assert.match(membershipLib, /settings: "billing"/);
assert.doesNotMatch(membershipLib, /params\.set\("redeem"/);
assert.doesNotMatch(membershipLib, /"\/membership/);
});