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
176 lines
11 KiB
TypeScript
176 lines
11 KiB
TypeScript
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/);
|
||
});
|