Files
Jyotisha/frontend/tests/account-dialog-overlay.test.ts
T
jesse-uxandClaude Code eb8f5147f4
Independent Staging Quality Gate / validate (push) Failing after 8m47s
Independent Staging Quality Gate / publish (push) Skipped
fix(frontend): refine settings dialog layout
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 11:32:27 +08:00

198 lines
9.4 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 { 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> = {}): AccountOverlayModel {
const overlayRef = createRef<HTMLElement | null>() as AccountOverlayModel["overlayRef"];
const closeButtonRef = createRef<HTMLButtonElement | null>() 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/(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("settings navigation uses two columns without a misleading chevron", () => {
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");
assert.doesNotMatch(overlay, /<ChevronRight/);
assert.match(cssDeclarations(".settings-dialog-nav-item", styles), /grid-template-columns:\s*18px\s+minmax\(0,\s*1fr\)/);
assert.match(cssDeclarations(".settings-dialog-nav", styles), /border-right:\s*1px\s+solid\s+var\(--color-border\)/);
assert.match(cssDeclarations(".settings-dialog-content", styles), /padding:\s*0\s+var\(--space-8\)\s+var\(--space-8\)/);
});
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);
// 原值:420–460px;新值:560px;原因:右侧内容区增加内边距后,资料表单仍需使用舒适的阅读宽度,避免右侧留下过大的空白。
assert.match(cap, /max-width:\s*560px/, "cap uses the wider 560px reading width");
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:/);
});