Files
Jyotisha/frontend/tests/membership-page.test.ts
T
Jesse_ChenandClaude Opus 5 34628ecb67
Independent Staging Quality Gate / validate (push) Canceled after 6m18s
Independent Staging Quality Gate / publish (push) Canceled after 0s
feat(settings): 表单分区收窄到 440px,套餐卡收敛成两种互斥状态
任务书 TASK-cend-surfaces-claude-alignment-20260916 §R8(T8.1 / T8.2)。

T8.1(E13):880px 弹窗里「个人资料」「通用设置」的一列表单铺满约 690px,
字段与标签被拉断。表单分区的内容区加 `settings-dialog-content--form`,
子元素上限 440px 并左对齐;「星盘资料」「账户与点数」两个列表分区保持铺满。
上限只挂在滚动容器的子元素上,不挂 `.settings-modal` / `.account-settings-shell`,
因此传不到固定尺寸的弹窗(BUG-554 / BUG-698 的现象无法复现)。

T8.2(D13):套餐卡的三个修饰符可同时命中,叠出没有设计定义的边框与底色。
收敛成两种互斥状态 `is-current`(当前套餐)与 `is-recommended`(推荐),
「推荐且当前」判给当前套餐(该卡的动作是续费)。
`highlighted` 不再进 className,删掉 `--highlighted` 那条描边规则,
定位交给 use-billing-panel 里已有的 scrollIntoView + focus。
手机端「月卡置顶」改挂 `[data-plan-alias="monthly"]`——版位不是状态,
月卡成为当前套餐后排序不变。

测试:3350 → 3354(+4),fail 31 → 31 且失败清单与基线逐条一致。
6 处既有断言改动均就地写了「原值/新值/原因」,另有 3 次破坏性验证。
tsc 0 错;lint 0 error / 118 warning(持平);干净 next build 后 `/` 仍 `○ Static`,
产物 CSS gzip 39,034 → 38,882 B(−0.39%),三条新规则在产物里逐条取证。

环境缺口(未记为通过):四个分区切换不跳尺寸、支付下单流程、`?plan=` 滚动定位的
视觉确认——无 Chrome、无受控账号,见 PROGRESS 的环境缺口段。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 06:24:11 +00:00

164 lines
9.6 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/);
});
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, /兑换并继续/);
// 原值: 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-recommendedtoken 与改前一致。
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/);
});