Files
Jyotisha/frontend/tests/account-dialog-overlay.test.ts
T
jesse-uxandGrok (agent loop) b2a561b34c 被放宽的抽屉、外观和设置测试改回点按检查(U10)。
Co-Authored-By: Grok (agent loop)
2026-10-27 00:51:11 +08:00

236 lines
11 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 "./install-test-document";
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 { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
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/);
// 原值: page restores setActiveChartId from localStorage during render setup.
// 新值: that effect is gone.
// 原因: D11 cancels the active chart. The current person lives outside Home().
assert.doesNotMatch(page, /setActiveChartId/);
assert.match(page, /useEffect\(\(\) => \{\s*chatActionsRef\.current = \{/);
});
async function mountedSettings(dialog: "profile" | "billing" | "general") {
const h = createClientLifecycleHarness();
await h.render(createElement(AccountDialogOverlay, {
open: true,
dialog,
model: overlayModel({ title: dialog === "billing" ? "账户与点数" : "个人资料" }),
}));
const walk = (node: ReturnType<typeof h.elements>[number]): ReturnType<typeof h.elements> => [
node,
...node.childNodes.flatMap((child) => walk(child)),
];
const nodes = [...walk(h.container), ...[...h.document.body.childNodes].flatMap((child) => walk(child))];
return { h, nodes };
}
test("settings navigation lists three panes and billing can be current", async () => {
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");
// 原值: renderToString 里能看到三个分区名和 aria-current="page"
// 新值: 组件库 Dialog 挂上之后,页面文字里有三个分区名,当前页是账户与点数
// 原因: K1 设置弹窗改用组件库,服务端字符串是空的,所以改在测试宿主里挂上再看
// 原值: four panes, including 星盘资料.
// 新值: profile, billing, general. No chart-library nav item.
// 原因: D8 moves chart archives to /people and removes the settings pane.
assert.match(overlay, /dialog: "profile"/);
assert.doesNotMatch(overlay, /label: "星盘资料"/);
assert.match(overlay, /dialog: "billing"/);
assert.match(overlay, /dialog: "general"/);
assert.doesNotMatch(overlay, /openRedeem/);
const mounted = await mountedSettings("billing");
try {
const text = mounted.nodes.map((node) => node.textContent).join("");
assert.match(text, /个人资料/);
assert.doesNotMatch(text, /星盘资料/);
assert.match(text, /账户与点数/);
assert.match(text, /通用设置/);
const current = mounted.nodes.find((node) => node.getAttribute("aria-current") === "page");
assert.ok(current);
assert.match(current.text, /账户与点数/);
assert.deepEqual(mounted.h.errors, []);
} finally {
await mounted.h.close();
}
// 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.
// 原值: profile, chart-library, billing, general.
// 新值: profile, billing, general.
// 原因: the chart archive pane left the settings dialog.
const panes = ["profile", "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", async () => {
// 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");
// 原值:renderToString 的 class="settings-dialog-content settings-dialog-content--form"
// 新值:挂上 Dialog 之后,资料和通用设置的内容盒子带 --form,点数页不带
// 原因:K1 的 Dialog 在服务端不输出 HTML。表单变窄、点数页铺满的规则不变
const contentClass = async (pane: "profile" | "billing" | "general") => {
const mounted = await mountedSettings(pane);
try {
const node = mounted.nodes.find((item) => (item.getAttribute("class") ?? "").includes("settings-dialog-content"));
assert.ok(node, pane);
return node.getAttribute("class") ?? "";
} finally {
await mounted.h.close();
}
};
for (const pane of ["profile", "general"] as const) {
assert.equal(
await contentClass(pane),
"settings-dialog-content settings-dialog-content--form",
`${pane} is a form pane and must carry the cap`,
);
}
assert.equal(await contentClass("billing"), "settings-dialog-content", "billing 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:/);
});