Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
71 lines
4.7 KiB
TypeScript
71 lines
4.7 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import { tryOpenRedeemPaywall } from "../src/lib/redeem-paywall-once.ts";
|
||
|
||
// TASK-birth-sky-polish-20260928 P1 (poster-only dialog) and P4 (paywall timing, BUG-1080).
|
||
|
||
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
||
const converge = read("src/components/birth-sky/birth-sky-converge.tsx");
|
||
const hook = read("src/hooks/use-consultation-run.ts");
|
||
const page = read("src/app/(app)/page.tsx");
|
||
|
||
// 原值(测试名与断言):chart page dialog shows only the picture…——静态海报弹层:`<img>` + 图下「保存图片」+ 右上 ×,
|
||
// backdrop 点击关闭,`birth-sky.css` 的舞台与按钮尺寸
|
||
// 新值:chart page replay stays on the poster…——弹层删除,改为重放汇聚后定格(定格帧即海报);左上 ×、右上「分享」,
|
||
// 同样 44px、夜色底;断言移到 `birth-sky-replay.tsx` / `birth-sky-converge.css`
|
||
// 原因:TASK-mobile-chart-and-confirmed-edit-20260929 M4,产品 2026-09-29「点击那一刻的天空重新跑星星动画,定格到最后一帧,右上角分享」
|
||
test("chart page replay stays on the poster: × top left, 「分享」 top right, no title bar or panel", () => {
|
||
const replay = read("src/components/birth-sky/birth-sky-replay.tsx");
|
||
const convergeCss = read("src/components/birth-sky/birth-sky-converge.css");
|
||
assert.doesNotMatch(replay, /<h2|<header|birth-sky-panel|<img|保存图片/);
|
||
assert.match(replay, /mode="hold"/);
|
||
assert.match(replay, /<button className="birth-sky-control" type="button" onClick=\{onClose\} aria-label="关闭">\s*<X aria-hidden="true" \/>/);
|
||
assert.match(replay, /<button className="birth-sky-control birth-sky-share" type="button" onClick=\{\(\) => void share\(\)\}>/);
|
||
assert.match(replay, /await saveBirthSkyImage\(cover\.blob\)/);
|
||
assert.match(convergeCss, /\.birth-sky-controls \{[^}]*justify-content: space-between;/);
|
||
assert.match(convergeCss, /\.birth-sky-control \{[^}]*min-width: 44px;[^}]*height: 44px;/);
|
||
});
|
||
|
||
test("paywall gate: the first refusal opens the paywall, every later one falls through to billing", () => {
|
||
const shown = { current: false };
|
||
const opened: boolean[] = [];
|
||
const open = (value: boolean) => { opened.push(value); };
|
||
assert.equal(tryOpenRedeemPaywall({ uiPreview: false, shown, open }), true);
|
||
assert.equal(shown.current, true);
|
||
assert.deepEqual(opened, [true]);
|
||
assert.equal(tryOpenRedeemPaywall({ uiPreview: false, shown, open }), false, "second refusal → billing");
|
||
assert.deepEqual(opened, [true]);
|
||
});
|
||
|
||
test("paywall gate: UI preview or a caller without the paywall never opens it", () => {
|
||
const shown = { current: false };
|
||
const opened: boolean[] = [];
|
||
assert.equal(tryOpenRedeemPaywall({ uiPreview: true, shown, open: (value) => opened.push(value) }), false);
|
||
assert.equal(tryOpenRedeemPaywall({ uiPreview: false }), false);
|
||
assert.equal(tryOpenRedeemPaywall({ uiPreview: false, shown }), false);
|
||
assert.equal(shown.current, false);
|
||
assert.deepEqual(opened, []);
|
||
});
|
||
|
||
test("the paywall only opens from a refused send; the typed question stays in the composer", () => {
|
||
// No page effect opens it when the starter home appears.
|
||
assert.doesNotMatch(page, /setOnboardingPaywallOpen\(true\)/);
|
||
assert.doesNotMatch(page, /!starterHomeVisible\s*\n\s*\|\| !account/);
|
||
// Pre-flight balance check: before send() clears the draft, paywall first, else billing.
|
||
const guard = hook.slice(hook.indexOf("if (account.credits <= 0 && !account.activeSubscription) {"));
|
||
assert.match(guard, /^if \(account\.credits <= 0 && !account\.activeSubscription\) \{\s*if \(!openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);\s*return false;/);
|
||
assert.ok(hook.indexOf("if (!openRedeemPaywallOnce())") < hook.indexOf("conversationAnchor.pinLatestTurn();\n setDraft(\"\");"));
|
||
// 402: same order; the existing rollback restores the draft (reserve did not commit).
|
||
assert.match(hook, /if \(status === 402 && !openRedeemPaywallOnce\(\)\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);/);
|
||
assert.match(hook, /\|\| caught\.status === 402[\s\S]{0,200}if \(reserveDidNotCommit\) \{[\s\S]{0,300}setDraft\(originalQuestion\);/);
|
||
// Rectification's own 402 path is out of scope and unchanged.
|
||
assert.match(read("src/lib/rectification-chat-turn-run.ts"), /response\.status === 402/);
|
||
});
|
||
|
||
test("the convergence overlay is portalled to <body>, outside the inert SidebarInset (BUG-968, BUG-1080)", () => {
|
||
assert.match(converge, /import \{ createPortal \} from "react-dom";/);
|
||
assert.match(converge, /return createPortal\(\s*<dialog[\s\S]*<\/dialog>,\s*document\.body,\s*\);/);
|
||
});
|