import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { createElement, createRef } from "react"; import { renderToString } from "react-dom/server"; import test from "node:test"; import { AccountDialogOverlay, type AccountOverlayModel, } from "../src/components/account-dialog-overlay.tsx"; import { accountDialogClasses } from "../src/lib/home-types.ts"; import { cssDeclarations } from "./css-contract-test-support.ts"; function overlayModel(overrides: Partial = {}): AccountOverlayModel { const overlayRef = createRef() as AccountOverlayModel["overlayRef"]; const closeButtonRef = createRef() as AccountOverlayModel["closeButtonRef"]; return { title: "个人资料", dialogClass: "settings-modal", signingOut: false, close() {}, navigate() {}, overlayRef, closeButtonRef, renderProfile() { return null; }, renderChartLibrary() { return null; }, renderBilling() { return null; }, renderGeneral() { return null; }, renderLogout() { return null; }, ...overrides, }; } test("a closed account overlay does not render profile or logout content", () => { let profileRenders = 0; let logoutRenders = 0; const model = overlayModel({ renderProfile() { profileRenders += 1; return null; }, renderLogout() { logoutRenders += 1; return null; }, }); const html = renderToString(createElement(AccountDialogOverlay, { open: false, dialog: null, model, })); assert.equal(html, ""); assert.equal(profileRenders, 0); assert.equal(logoutRenders, 0); }); test("home does not write overlay epoch or chat actions during render", () => { const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8"); assert.match(overlay, /model: AccountOverlayModel \| null/); assert.doesNotMatch(overlay, /openEpoch/); assert.doesNotMatch(overlay, /modelRef/); assert.doesNotMatch(page, /accountOverlayEpochRef/); assert.doesNotMatch(page, /sessionsRef\.current = sessions/); assert.match(page, /queueMicrotask\(\(\) => setActiveChartId\(storedChartId\)\)/); assert.match(page, /useEffect\(\(\) => \{\s*chatActionsRef\.current = \{/); }); test("settings navigation lists four panes and billing can be current", () => { const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const html = renderToString(createElement(AccountDialogOverlay, { open: true, dialog: "billing", model: overlayModel({ title: "账户与点数" }), })); assert.match(overlay, /dialog: "profile"/); assert.match(overlay, /dialog: "chart-library"/); assert.match(overlay, /dialog: "billing"/); assert.match(overlay, /dialog: "general"/); assert.doesNotMatch(overlay, /openRedeem/); assert.match(html, /个人资料/); assert.match(html, /星盘资料/); assert.match(html, /账户与点数/); assert.match(html, /通用设置/); assert.match(html, /aria-current="page"/); // Existence check only, kept from BUG-554. It cannot see whether the declaration // survives to the browser, which is exactly how BUG-698 slipped through: the height // was declared but written only in dvh. The real guards are the same-size contract // below and frontend/tests/viewport-unit-fallback-contract.test.ts. assert.match(styles, /\.settings-modal \{[^}]*width:[^}]*height:/); assert.doesNotMatch(styles, /chart-library-modal|profile-modal/); }); test("all four settings panes share one dialog class, so the box cannot change size", () => { // BUG-554 root cause: each pane had its own width class. BUG-698 is the same symptom // from a different layer, so the shared-class invariant is asserted directly rather // than inferred from one pane's rendered output. const panes = ["profile", "chart-library", "billing", "general"] as const; const classes = new Set(panes.map((pane) => accountDialogClasses[pane])); assert.deepEqual([...classes], ["settings-modal"]); assert.notEqual(accountDialogClasses.logout, accountDialogClasses.profile); }); test("the settings pane menu separates hover from current without an accent bar", () => { const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const navRules = (styles.match(/[^{}\n]*\.settings-dialog-nav-item[^{}]*\{[^{}]*\}/g) ?? []); const currentRules = navRules.filter((rule) => rule.includes('[aria-current="page"]')); const hoverRules = navRules.filter((rule) => rule.includes(":hover")); assert.ok(currentRules.length > 0, "expected a rule for the current settings pane"); assert.ok(hoverRules.length > 0, "expected a rule for the hovered settings pane"); // Product decision 2026-09-15: the pane menu drops the action-colour bar. for (const rule of [...currentRules, ...hoverRules]) { assert.doesNotMatch(rule, /box-shadow/, `settings pane menu must not draw an accent bar: ${rule}`); assert.doesNotMatch(rule, /font-weight/, `current pane must not be expressed with weight: ${rule}`); } // Hover and current must not share one declaration, or they become indistinguishable // once the bar is gone. for (const rule of currentRules) assert.ok(!rule.includes(":hover"), `current and hover must be separate rules: ${rule}`); for (const rule of hoverRules) assert.ok(!rule.includes('[aria-current="page"]'), `current and hover must be separate rules: ${rule}`); }); test("form panes get a reading-width cap, list panes stay full-bleed", () => { // T8.1 / E13: the 880px dialog leaves ~690px of content, which pulls a one-column // form apart. The cap is applied per pane, inside the content box. const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const render = (pane: "profile" | "chart-library" | "billing" | "general") => renderToString( createElement(AccountDialogOverlay, { open: true, dialog: pane, model: overlayModel() }), ); for (const pane of ["profile", "general"] as const) { assert.match( render(pane), /class="settings-dialog-content settings-dialog-content--form"/, `${pane} is a form pane and must carry the cap`, ); } for (const pane of ["chart-library", "billing"] as const) { const html = render(pane); assert.match(html, /class="settings-dialog-content"/, `${pane} must stay full-bleed`); assert.doesNotMatch(html, /settings-dialog-content--form/, `${pane} must stay full-bleed`); } const cap = cssDeclarations(".settings-dialog-content--form > *", styles); assert.match(cap, /max-width:\s*4[2-6]\dpx/, "cap belongs in the 420-460px reading-width band"); assert.match(cap, /margin-right:\s*auto/, "capped content is left aligned"); }); test("the pane cap cannot resize the dialog box (BUG-554 / BUG-698 mechanism)", () => { // Both bugs were "the box changes size when I switch panes". This re-verification is // structural, not visual: (1) every pane still maps to the single .settings-modal // class (asserted in the test above); (2) .settings-modal still declares a fixed // width AND height, so the box is sized by the class, never by its content; (3) the // new cap is scoped strictly below .settings-dialog-content, so it cannot reach the // modal or the nav/content grid. A browser check of the four panes is an environment // gap, recorded in the progress note. const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const modal = cssDeclarations(".settings-modal", styles); assert.match(modal, /width:/); assert.match(modal, /height:/); const capRules = styles.match(/[^{}\n]*settings-dialog-content--form[^{}]*\{[^{}]*\}/g) ?? []; assert.ok(capRules.length > 0, "expected the form cap rule"); for (const rule of capRules) { const selector = rule.slice(0, rule.indexOf("{")); assert.ok( selector.includes(".settings-dialog-content--form"), `cap must stay scoped to the content box: ${rule}`, ); assert.doesNotMatch( selector, /settings-modal|account-settings-shell|account-modal|settings-dialog-nav/, `cap must not reach the dialog box, the shell grid or the nav: ${rule}`, ); assert.doesNotMatch(rule, /[^-]height:/, `cap must not set a height: ${rule}`); } // The grid that splits nav from content stays pane independent. assert.match(cssDeclarations(".account-settings-shell", styles), /grid-template-columns:/); });