设置弹窗的固定高度只用 dvh 写、没有回退。不认识该单位的引擎会把整条 height 与 max-height 作废,盒子退回按内容撑开,于是切分区就跳大小—— 这正是 BUG-554 现象的复发,而 BUG-554 的防复发「必须同时声明 width 与 height」只检查声明存不存在,挡不住「写了但没生效」。 实测(Chrome 151,真实产物 CSS + 复刻 DOM,1440×900):dvh 正常时四个 分区恒定 866.80×640px,**事故不复现**;摘掉 dvh 后变成 313/313/378/1130, 宽度不动——与用户描述的形状完全一致。因此机制已证实,但用户当时的浏览器 未定位,BUG-698 记为 investigating 而非 resolved。 附带发现:任务书要求照抄的重复声明式回退 `height: 100vh; height: 100dvh;` 在本仓根本发布不出去——Lightning CSS 会合并同名属性的重复声明只留最后一条, 全仓唯一那处回退(sidebar-provider)在线上早就是死的,还有一条测试专门守着 这个从未发布过的写法。改用 @supports (height: 1dvh):vh 作基线,dvh 作升级。 修复后不支持 dvh 的引擎也收敛到恒定 640px,支持的逐像素无变化。 同轮按产品决策去掉设置分区菜单的左侧/下方强调色条,选中与悬停改用面与 墨色等级区分,不用色相、不用字重。左侧会话列表的色条本轮不动。 - 新增 viewport-unit-fallback-contract(3 条,全文件),三次破坏性验证各自打红 - account-dialog-overlay 新增同尺寸契约与分区菜单契约 - 三条钉死旧 dvh 字面量的既有断言按「原值/新值/原因」更新,均未弱化 - tsc 0 错;lint 0 error / 118 warning(持平);npm test 3346/3300/fail 31, 失败清单与基线逐字相同;/ 仍 ○ Static;样式 gzip +0.38%; 快速门 pytest 段 792 passed / 1 skipped / 0 failed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
128 lines
5.5 KiB
TypeScript
128 lines
5.5 KiB
TypeScript
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";
|
|
|
|
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/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}`);
|
|
});
|