Files
Genarrative/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
T
suzmii 69a5b868ef 修掉润色状态行的死声明:margin-top 6px 不再被后面的 0 盖掉
- 评审 D-A:上一轮给状态行加的 `margin-top: 6px` 被紧接着的旧 `margin-top: 0`(styles.css 两条 Direct 状态规则)覆盖 → 实际间距是 0、状态紧贴编辑器末行;注释与测试注释也跟着说错
- 改法:删掉那两行 `margin-top: 0`,保留 6px,并把 CSS 注释与测试注释改成事实(输入盒 `gap` 的 row 分量为 0,间距由状态元素自带)
- 实测(无头 Chromium 夹具,438px 面板):无状态行时输入盒 **72px** / 表单 **136px**;出现「AI 润色失败,可重试」时状态行 `margin-top` 计算值 = **6px**、状态行高 16.8px,输入盒 **95px** / 表单 **159px**(= 136 + 6 + 16.8);空态仍 0 高,222 算式与空态断言不变
- 测试:`statusRowHeight = 0` 不再写死——断言 `ResourceReferenceInput` 里状态行仍是条件渲染(`polishing || polishError || polishNotice`)+ 状态规则 `margin-top` 解出 6px(谁改回常驻渲染或把间距写回 0 都会红)

Refs #600, PR #607
2026-10-06 01:25:42 +08:00

787 lines
36 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.
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { repoPath } from './repoPath';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
const STYLES_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
const CHAT_VIEW_PATH = repoPath(
'apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx',
);
const CHAT_CONVERSATION_PATH = repoPath(
'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx',
);
const CHAT_COMPOSER_PATH = repoPath(
'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx',
);
/** 共享输入区实现(「AI 润色 / 恢复原文」与状态行都在这里渲染)。 */
const INPUT_SOURCE_PATH = repoPath(
'apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx',
);
/** 同目录的控件实现(推理强度滑块在这里渲染)。 */
const CHAT_COMPOSER_CONTROLS_PATH = repoPath(
'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx',
);
function lengthPx(rawValue: string, label: string): number {
const value = rawValue.trim();
// CSS 里 0 可以不带单位,其余一律要求 px(本文件不写 rem/em 几何)。
if (value === '0') {
return 0;
}
const match = /^(-?\d+(?:\.\d+)?)px$/u.exec(value);
expect(match, `${label} 不是像素值:${value}`).not.toBeNull();
return Number(match![1]);
}
function pixelValue(
declarations: Map<string, string>,
property: string,
): number {
return lengthPx(declaration(declarations, property), property);
}
/** 展开 `padding`:长写优先(后面的长写会覆盖简写),没有长写再拆简写。 */
function paddingBox(declarations: Map<string, string>) {
const sides: Array<['top' | 'right' | 'bottom' | 'left', number]> = [
['top', 0],
['right', 0],
['bottom', 0],
['left', 0],
];
const longhand = new Map<string, number>();
for (const [side] of sides) {
if (declarations.has(`padding-${side}`)) {
longhand.set(side, pixelValue(declarations, `padding-${side}`));
}
}
const shorthand = declarations.get('padding');
const shorthandValues = new Map<string, number>();
if (shorthand) {
const parts = shorthand.split(' ').filter(Boolean);
const value = (index: number) => {
const part = parts[index] ?? parts[parts.length - 1] ?? parts[0];
return lengthPx(part!, `padding 简写 ${shorthand}`);
};
if (parts.length === 1) {
const only = value(0);
for (const [side] of sides) {
shorthandValues.set(side, only);
}
} else if (parts.length === 2) {
shorthandValues.set('top', value(0));
shorthandValues.set('bottom', value(0));
shorthandValues.set('right', value(1));
shorthandValues.set('left', value(1));
} else if (parts.length === 3) {
shorthandValues.set('top', value(0));
shorthandValues.set('right', value(1));
shorthandValues.set('bottom', value(2));
shorthandValues.set('left', value(1));
} else {
shorthandValues.set('top', value(0));
shorthandValues.set('right', value(1));
shorthandValues.set('bottom', value(2));
shorthandValues.set('left', value(3));
}
}
const resolved = {} as Record<string, number>;
for (const [side] of sides) {
const value = longhand.get(side) ?? shorthandValues.get(side);
expect(value, `缺少生效声明 padding-${side}`).toBeDefined();
resolved[side] = value!;
}
return resolved as {
top: number;
right: number;
bottom: number;
left: number;
};
}
/** 展开 `margin` 简写,只关心四边数值。 */
function marginBox(declarations: Map<string, string>) {
const shorthand = declarations.get('margin') ?? '0';
const parts = shorthand.split(' ').filter(Boolean);
const value = (index: number) => {
const part = parts[index] ?? parts[parts.length - 1] ?? parts[0];
return lengthPx(part!, `margin 简写 ${shorthand}`);
};
if (parts.length === 1) {
const only = value(0);
return { top: only, right: only, bottom: only, left: only };
}
if (parts.length === 2) {
return { top: value(0), right: value(1), bottom: value(0), left: value(1) };
}
if (parts.length === 3) {
return { top: value(0), right: value(1), bottom: value(2), left: value(1) };
}
return { top: value(0), right: value(1), bottom: value(2), left: value(3) };
}
/**
* 展开 `gap` 简写,只关心主/交叉两轴。
*
* 一值写四个方向同一个数;两值先 `row-gap` 后 `column-gap`(与 CSS 的 `gap: <row> <column>`
* 一致)。三层输入区里两条行间距分别写在输入盒与输入盒自己身上,读值时必须分得清是哪一条。
*/
function gapBox(declarations: Map<string, string>) {
const shorthand = declarations.get('gap') ?? '0';
const parts = shorthand.split(' ').filter(Boolean);
const value = (index: number) => {
const part = parts[index] ?? parts[parts.length - 1] ?? parts[0];
return lengthPx(part!, `gap 简写 ${shorthand}`);
};
return { row: value(0), column: value(1) };
}
/* ============================================================
选择器身份
============================================================ */
const CHAT = '.game-workbench-chat .project-chat-surface.is-direct-codex';
const SURFACE = CHAT;
const CONVERSATION = `${CHAT} .project-chat-conversation`;
const MESSAGE_LIST = `${CHAT} .project-chat-message-list`;
const MESSAGE_LIST_PLAIN = '.game-workbench-chat .project-chat-message-list';
const COMPOSER = `${CHAT} .project-chat-composer.is-direct-codex`;
const COMPOSER_PLAIN = `${CHAT} .project-chat-composer`;
const CONVERSATION_COMPOSER = `${CONVERSATION} .project-chat-composer.is-direct-codex`;
const COMPOSER_INPUT = `${COMPOSER} .resource-reference-input`;
const COMPOSER_INPUT_PLAIN = `${COMPOSER_PLAIN} .resource-reference-input`;
const COMPOSER_EDITOR = `${COMPOSER_INPUT} .resource-reference-input-editor`;
const COMPOSER_EDITOR_PLAIN = `${COMPOSER_PLAIN} .resource-reference-input-editor`;
const COMPOSER_CONTROLS = `${COMPOSER} .project-chat-composer-controls`;
const COMPOSER_SUBMIT = `${COMPOSER_CONTROLS} .project-chat-submit-button`;
/** 模型触发钮:工具栏那一排里最高的一颗(30px),行高由它决定而不是发送钮(28px)。 */
const COMPOSER_TRIGGER = `${COMPOSER_CONTROLS} .conversation-model-trigger`;
/** 触发钮的基础几何规则挂在 `.project-chat-composer`(不带 `.is-direct-codex`)上,同样命中。 */
const COMPOSER_TRIGGER_BASE =
'.project-chat-surface.is-direct-codex .project-chat-composer .conversation-model-trigger';
const INPUT_ACTIONS = `${COMPOSER_INPUT} .resource-reference-input-actions`;
const INPUT_ACTIONS_PLAIN = `${COMPOSER} .resource-reference-input-actions`;
/** 模型菜单的共享样式面(选择器只用组件自带类名,不带宿主祖先链;首页与工作台共用)。 */
const SHARED_CARD = '.conversation-model-select .conversation-model-menu';
const SHARED_SHELL =
'.conversation-model-select .conversation-model-menu-shell';
const SHARED_CARD_IN_SHELL = `${SHARED_SHELL} .conversation-model-menu`;
/** 强度区与滑块(issue #600 的复核点;档位圆点按用户口径已删除)。
这些规则与卡片一样在共享样式面(组件自带类名,不带宿主祖先链)。 */
const STRENGTH_SECTION = '.project-chat-reasoning-effort.is-section';
const STRENGTH_RANGE = '.project-chat-composer-strength-range';
const STRENGTH_THUMB = `${STRENGTH_RANGE}::-webkit-slider-thumb`;
/** 已删除的档位点容器:留着它做反向守卫(谁都别把点写回来)。 */
const STRENGTH_STOPS = '.project-chat-composer-strength-stops';
const TOPBAR = `${CHAT} .project-chat-topbar`;
/** 列表实际会命中的全部选择器:direct-codex 直连与基础规则。 */
const LIST_SELECTORS = [MESSAGE_LIST, MESSAGE_LIST_PLAIN];
const DESKTOP = 1440;
const MOBILE = 390;
const styles = readFileSync(STYLES_PATH, 'utf8');
const rules = parseStyleSheet(styles);
function desktopDeclarations(...elementSelectors: string[]) {
return resolveDeclarations(rules, elementSelectors, DESKTOP);
}
function mobileDeclarations(...elementSelectors: string[]) {
return resolveDeclarations(rules, elementSelectors, MOBILE);
}
/**
* 输入盒在文档流里的最高高度。
*
* 新结构里输入盒**不再**需要消息列表给它让位,这个值只用来给「列表底边不留白」做
* 下界校验:留白只要不小于它就说明旧模型的数字被写回来了。
*/
function composerMaxHeight() {
const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN);
const input = desktopDeclarations(
COMPOSER_INPUT,
COMPOSER_INPUT_PLAIN,
'.resource-reference-input',
);
const editor = desktopDeclarations(
COMPOSER_EDITOR,
COMPOSER_EDITOR_PLAIN,
'.resource-reference-input-editor',
);
const actions = desktopDeclarations(INPUT_ACTIONS, INPUT_ACTIONS_PLAIN);
const polishButton = desktopDeclarations(
`${INPUT_ACTIONS} .resource-reference-input-polish`,
'.resource-reference-input-polish',
);
const controls = desktopDeclarations(COMPOSER_CONTROLS);
const submit = desktopDeclarations(COMPOSER_SUBMIT);
const trigger = desktopDeclarations(COMPOSER_TRIGGER, COMPOSER_TRIGGER_BASE);
const editorMaxHeight = pixelValue(editor, 'max-height');
// 「AI 润色 / 恢复原文」已经排进下方工具条(`.project-chat-composer-controls-actions`
// 挂点 + `actionsPortalTarget`),不再是输入盒里的第二行:这里只留它的按钮高度断言,
// 输入盒的第二行现在只可能放润色状态(没内容时为 0 高)。
expect(
pixelValue(polishButton, 'height'),
'润色钮是 28px 方块,改了要同步改工具条那一排的高度',
).toBe(28);
// 输入盒与输入盒外各有一条行间距:前者是「文字区 / 润色状态」之间(状态没内容时为 0),
// 后者是「输入内容区 / 工具栏」之间。
const inputRowGap = gapBox(input).row;
const composerRowGap = gapBox(composer).row;
const inputPaddingBottom = paddingBox(input).bottom;
const composerPaddingTop = paddingBox(composer).top;
const composerPaddingBottom = paddingBox(composer).bottom;
// 工具栏行高由这一排里**最高的一颗控件**决定:模型触发钮 30px 高于发送 / 语音的
// 28px(`align-items: center` 只做居中,不会把行高压回 28)。所以这里取两者最大值,
// 而不是写死某一颗钮的高度——改任一颗的尺寸都会自动反映到算式里。
expect(
controls.has('padding-top'),
'工具栏与输入区之间的间距由输入盒的 row-gap 提供,不再用 padding-top',
).toBe(false);
expect(
pixelValue(trigger, 'height'),
'工具栏行高由模型触发钮决定,改了要重新核对这条算式',
).toBe(30);
const controlsRowHeight = Math.max(
pixelValue(submit, 'height'),
pixelValue(trigger, 'height'),
);
expect(
pixelValue(editor, 'min-height'),
'编辑器最小高度是输入盒高度的下界,改了要同步改这里的算式',
).toBe(72);
// 输入盒第二行是**条件渲染**的润色状态行:`ResourceReferenceInput` 只在
// `polishing || polishError || polishNotice` 时才渲染它,空态这一行高度为 0(`gap` 的
// row 分量已归 0,有内容时由状态元素自带的 6px 上间距提供间隔)。这里断言那条条件
// 还在(谁把它改成常驻渲染,这条就红),所以 `statusRowHeight = 0` 是真实依据而不是写死。
const inputSource = readFileSync(INPUT_SOURCE_PATH, 'utf8');
expect(
inputSource,
'润色状态行必须是条件渲染(空态不occupy第二行)',
).toContain('polishing || polishError || polishNotice');
const statusRule = desktopDeclarations(
`${COMPOSER_INPUT} .resource-reference-input-status`,
);
expect(pixelValue(statusRule, 'margin-top')).toBe(6);
const statusRowHeight = 0;
return {
polishButtonHeight: pixelValue(polishButton, 'height'),
total:
composerPaddingTop +
editorMaxHeight +
inputRowGap +
statusRowHeight +
inputPaddingBottom +
composerRowGap +
controlsRowHeight +
composerPaddingBottom,
};
}
describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文档流输入盒)', () => {
it('输入盒在消息列表下方、处于文档流,四边留白一致且与消息内容左右对齐(宽屏)', () => {
const list = desktopDeclarations(...LIST_SELECTORS);
const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN);
const surface = desktopDeclarations(SURFACE);
// 列表是唯一滚动区:自身不再绝对定位,靠 flex 吸收会话列的剩余高度。
expect(declaration(list, 'position')).toBe('relative');
expect(declaration(list, 'overflow-y')).toBe('auto');
expect(declaration(list, 'flex')).toContain('1 1 auto');
// 输入盒在文档流里:不绝对定位;关键是它不再落在列表**内部**覆盖消息。
expect(declaration(composer, 'position')).toBe('relative');
// 用户可见契约:四边留白必须一致。
// 面板自身 padding 必须归 0,否则「左右留白」会等于「面板 padding + 输入盒左右外边距」,
// 而「上/下留白」只等于外边距或面板 padding——两边口径不同就一定会再次出现
// 「左右一个值、上下另一个值」的不齐(实测就是这个现象)。
expect(paddingBox(surface).top).toBe(0);
expect(paddingBox(surface).right).toBe(0);
expect(paddingBox(surface).bottom).toBe(0);
expect(paddingBox(surface).left).toBe(0);
const insets = marginBox(composer);
expect(insets.top, '输入盒与消息列表之间必须有留白').toBe(16);
expect(insets.right).toBe(16);
expect(insets.bottom).toBe(16);
expect(insets.left).toBe(16);
// 左右留白还要与消息内容一致:列表自身左右各 16px(下面也有断言),三者同一基准。
expect(paddingBox(list).left).toBe(insets.left);
expect(paddingBox(list).right).toBe(insets.right);
// 盒内上下内边距同样必须相等,不能再写 `8px … 10px` 那种上下不一致的写法。
{
const composerPadding = paddingBox(composer);
expect(composerPadding.top).toBe(composerPadding.bottom);
}
// 也不再靠 bottom/left/right 偏移量定位(那是旧浮层几何的残余)。
expect(pixelValue(composer, 'bottom')).toBe(0);
expect(pixelValue(composer, 'left')).toBe(0);
expect(pixelValue(composer, 'right')).toBe(0);
// 输入盒自己那只盒子还在(它现在是整块面板里唯一有边框的容器)。
expect(declaration(composer, 'border')).toContain('1px solid');
expect(declaration(composer, 'background')).toContain(
'var(--platform-input-fill)',
);
expect(declaration(composer, 'border-radius')).toBe('14px');
// 对话内容左右留白 16px,消息之间 14px。
expect(paddingBox(list).left).toBe(16);
expect(paddingBox(list).right).toBe(16);
expect(declaration(list, 'gap')).toBe('14px');
});
it('消息列表底部不留给浮层的空白,滚动到底不会多出一段空白(宽屏)', () => {
const list = desktopDeclarations(...LIST_SELECTORS);
const { total, polishButtonHeight } = composerMaxHeight();
// 输入盒最高高度由真实声明算出来(自上而下):
// 盒内上内边距 12 + 编辑器 160 + 输入区行距 0(状态行没内容时为 0 高,不留幻影行距)
// + 状态行 0 + 输入区下内边距 0 + 输入盒与工具栏的行距 8
// + 工具栏 30(模型触发钮撑起来的行高)+ 盒内下内边距 12 = **222**。
expect(polishButtonHeight, '润色钮在工具条里仍是 28px 方块').toBe(28);
expect(pixelValue(desktopDeclarations(COMPOSER_SUBMIT), 'height')).toBe(28);
expect(total).toBe(222);
const paddingBottom = paddingBox(list).bottom;
// 新契约:留白归 0。旧版这里是一条 `inset + 输入区最高高度 + 间距` 的算式,
// 输入盒回到文档流之后,那段留白会变成凭空多出来的空白,把最后一条消息顶出可视区。
expect(paddingBottom, '输入盒在文档流里,列表底边不允许再给浮层留白').toBe(
0,
);
expect(pixelValue(list, 'scroll-padding-bottom')).toBe(paddingBottom);
// 留白必须严格小于输入盒高度,否则就是把旧模型的数字又写回来了。
expect(paddingBottom).toBeLessThan(total);
});
it('思维强度并入模型菜单:外壳向上展开、强度区在菜单顶部(宽屏)', () => {
const trigger = desktopDeclarations(
COMPOSER_TRIGGER,
COMPOSER_TRIGGER_BASE,
);
const select = desktopDeclarations(
`${COMPOSER_CONTROLS} .conversation-model-select`,
);
// 外壳:锚点是工作台专属几何;卡片观感来自共享样式面(见下面的 `sharedCard`),
// 所以这里把"这个元素会命中的全部选择器"都传进去——只传工作台那条会漏掉搬家后的声明。
const shell = desktopDeclarations(
`${COMPOSER} .conversation-model-menu-shell`,
SHARED_SHELL,
);
const menuInShell = desktopDeclarations(
`${COMPOSER} .conversation-model-menu-shell .conversation-model-menu`,
SHARED_CARD,
SHARED_CARD_IN_SHELL,
);
const section = desktopDeclarations(STRENGTH_SECTION);
const range = desktopDeclarations(STRENGTH_RANGE);
/**
* 共享样式面为了压过基础规则里那几条 `!important`,自己也是按 `!important` 声明的;
* 断言时只看最终值,去掉后缀。
*/
const value = (declarations: Map<string, string>, property: string) =>
declaration(declarations, property).replace(/ !important$/u, '');
// 弹层锚在模型控件上、从工具条上方展开(与原来的模型菜单同一朝向,不盖输入框)。
expect(declaration(select, 'position')).toContain('relative');
expect(declaration(shell, 'position')).toBe('absolute');
expect(declaration(shell, 'bottom')).toBe('calc(100% + 8px)');
expect(declaration(shell, 'right')).toBe('0');
// 卡片观感由共享样式面提供(与首页那颗同一份规则)。
expect(pixelValue(shell, 'border-radius')).toBe(16);
expect(paddingBox(shell).top).toBe(12);
expect(declaration(shell, 'box-shadow')).toContain(
'var(--platform-desktop-hover-shadow)',
);
expect(declaration(shell, 'max-height')).toBe('none');
// 里层菜单回到文档流(位置 / 描边 / 底色归外壳),强度区是菜单顶部的一块网格,滑块占满宽度。
expect(value(menuInShell, 'position')).toBe('static');
expect(value(menuInShell, 'max-height')).toBe('170px');
expect(value(menuInShell, 'background')).toBe('transparent');
expect(value(menuInShell, 'border-radius')).toBe('12px');
expect(declaration(section, 'display')).toBe('grid');
expect(declaration(range, 'width')).toBe('100%');
// 底行没有第二颗强度控件:那一排的高度仍由模型触发钮(30px)决定,见上面的算式。
expect(pixelValue(trigger, 'height')).toBe(30);
});
it('档位圆点已删除,滑块契约与「两端贴齐」的强度区内边距仍在(宽屏)', () => {
const section = desktopDeclarations(STRENGTH_SECTION);
const range = desktopDeclarations(STRENGTH_RANGE);
const thumb = desktopDeclarations(STRENGTH_THUMB);
const stops = desktopDeclarations(STRENGTH_STOPS);
// 反向守卫①:样式表里**不再有**档位点的规则(谁把点写回来,这条就红)。
expect(stops.has('position')).toBe(false);
expect(stops.size).toBe(0);
// 反向守卫②:组件源码里也不再渲染那组点。
const controlsSource = readFileSync(CHAT_COMPOSER_CONTROLS_PATH, 'utf8');
expect(controlsSource).not.toContain(
'project-chat-composer-strength-stops',
);
// 滑块本体契约不变:铺满卡片内容宽度,轨道 6px、圆钮 20px。
expect(declaration(range, 'width')).toBe('100%');
expect(pixelValue(range, 'height')).toBe(26);
expect(pixelValue(thumb, 'width')).toBe(20);
expect(pixelValue(thumb, 'height')).toBe(20);
// 强度区左右不留内边距(那条 padding 原先只服务档位点排布):填充段与圆钮两端与轨道贴齐,
// 中间不出现多余留白。上下留白不动(4 / 2)。
const sectionPadding = paddingBox(section);
expect(
sectionPadding.left,
'滑块左右不留白:点删掉后这条内边距必须归零',
).toBe(0);
expect(sectionPadding.right).toBe(0);
expect(sectionPadding.top).toBe(4);
expect(sectionPadding.bottom).toBe(2);
// 已选段终点必须与**圆钮中心**重合(`10px + ratio * (100% - 20px)`):写成 `20px + …`
// (= 圆钮**右缘**)时圆钮右侧会露出一小截橙色(4× 设备像素实拍对照见 PR)。
const track = desktopDeclarations(
`${STRENGTH_RANGE}::-webkit-slider-runnable-track`,
);
const trackBackground = declaration(track, 'background');
expect(trackBackground).toContain(
'calc(10px + var(--strength-ratio, 0) * (100% - 20px))',
);
expect(trackBackground).not.toContain(
'calc(20px + var(--strength-ratio, 0) * (100% - 20px))',
);
});
it('工具条只有一行:七个宽度下都不换行,控件排与外壳带单行最小宽度(含 AI 润色钮)', () => {
// 宽度扫描:任何面板宽下工具条都必须 `nowrap`,且样式表里**不再有** Direct 作用域的
// `flex-wrap: wrap`(窄面板曾经靠换行避免重叠,现在改成 min-width + 溢出可见)。
const CONTROLS_LEFT = `${COMPOSER} .project-chat-composer-controls-left`;
const CONTROLS_RIGHT = `${COMPOSER} .project-chat-composer-controls-right`;
const CONTROLS_MODEL_SELECT = `${COMPOSER_CONTROLS} .conversation-model-select`;
const WIDTHS = [240, 280, 296, 320, 360, 438, 900] as const;
for (const width of WIDTHS) {
const controls = resolveDeclarations(rules, [COMPOSER_CONTROLS], width);
expect(
declaration(controls, 'flex-wrap'),
`${width}px 下工具条必须保持单行`,
).toBe('nowrap');
const left = resolveDeclarations(rules, [CONTROLS_LEFT], width);
expect(declaration(left, 'flex-wrap'), `${width}px 下左组不换行`).toBe(
'nowrap',
);
const right = resolveDeclarations(rules, [CONTROLS_RIGHT], width);
expect(right.has('flex-wrap'), '右组本来就不换行').toBe(false);
const modelSelect = resolveDeclarations(
rules,
[CONTROLS_MODEL_SELECT, `${CONTROLS_RIGHT} .conversation-model-select`],
width,
);
expect(declaration(modelSelect, 'flex-wrap')).toBe('nowrap');
}
// 单行下限:控件排 252px(Σ 控件最小宽度 204 + 5×8 间距 = 244,再留 8 余量),
// 外壳 252 + 左右内边距各 12 + 边框各 1 = 278px。夹具实测:自然宽度 290.4。
const controls = desktopDeclarations(COMPOSER_CONTROLS);
expect(pixelValue(controls, 'min-width')).toBe(252);
const composer = desktopDeclarations(
COMPOSER,
COMPOSER_PLAIN,
CONVERSATION_COMPOSER,
);
expect(
pixelValue(composer, 'min-width'),
'外壳最小宽度 = 工具条下限 + 左右内边距 + 边框',
).toBe(278);
// 空输入态:组件源码里「AI 润色 / 恢复原文」是渲染到工具条那个挂点里的
// (`actionsPortalTarget`),不再是输入盒里的第二行。
const composerSource = readFileSync(CHAT_COMPOSER_PATH, 'utf8');
const controlsIndex = composerSource.indexOf(
'className="project-chat-composer-controls"',
);
const slotIndex = composerSource.indexOf(
'project-chat-composer-controls-actions',
);
expect(controlsIndex).toBeGreaterThanOrEqual(0);
expect(slotIndex).toBeGreaterThan(controlsIndex);
expect(composerSource).toContain(
'actionsPortalTarget={controlsActionsSlot}',
);
});
it('窄屏(390px)输入盒仍在文档流、四周有边距,列表底部没有留白', () => {
const conversation = mobileDeclarations(CONVERSATION);
const list = mobileDeclarations(...LIST_SELECTORS);
const composer = mobileDeclarations(
COMPOSER,
COMPOSER_PLAIN,
CONVERSATION_COMPOSER,
);
const topbar = mobileDeclarations(TOPBAR);
// 会话列不再自画一只外框:那块框交给输入盒,避免"框里再套一只框"。
const framePadding = paddingBox(conversation);
expect(framePadding.top).toBe(0);
expect(framePadding.right).toBe(0);
expect(framePadding.bottom).toBe(0);
expect(framePadding.left).toBe(0);
expect(declaration(conversation, 'border')).toBe('0');
expect(declaration(conversation, 'background')).toBe('transparent');
// 输入盒仍在文档流(本来就在),四边留白与宽屏同一套值(16px),窄屏不再另给一组
// `8px 12px 12px` 的不对称外边距。
expect(declaration(composer, 'position')).toBe('relative');
expect(declaration(composer, 'bottom')).toBe('auto');
expect(declaration(composer, 'left')).toBe('auto');
expect(declaration(composer, 'right')).toBe('auto');
const composerMargin = marginBox(composer);
expect(composerMargin.top, '窄屏输入盒四周都要有边距').toBe(16);
expect(composerMargin.left).toBe(16);
expect(composerMargin.right).toBe(16);
expect(composerMargin.bottom).toBe(16);
// 列表在窄屏也不画框、不留浮层空白,它仍是唯一滚动区。
expect(declaration(list, 'background')).toBe('transparent');
expect(pixelValue(list, 'padding-bottom')).toBe(0);
expect(declaration(list, 'overflow-y')).toBe('auto');
// 顶栏在窄屏也在场(状态点 + 设置钮),高度不塌。
expect(declaration(topbar, 'display')).toBe('flex');
expect(pixelValue(topbar, 'min-height')).toBe(44);
});
it('输入盒仍落在 form.project-chat-composer 子树里,交互控件原位', () => {
const viewSource = readFileSync(CHAT_VIEW_PATH, 'utf8');
const conversationSource = readFileSync(CHAT_CONVERSATION_PATH, 'utf8');
const composerSource = readFileSync(CHAT_COMPOSER_PATH, 'utf8');
// 面板三段式的顺序:顶栏 → 会话区 → 输入盒,组合顺序由视图持有。
const conversationOpen = viewSource.indexOf(
'<div className="project-chat-conversation">',
);
expect(conversationOpen).toBeGreaterThanOrEqual(0);
const headerIndex = viewSource.indexOf(
'<DirectProjectChatHeader',
conversationOpen,
);
const bodyIndex = viewSource.indexOf(
'<DirectProjectConversation',
conversationOpen,
);
const composerIndex = viewSource.indexOf(
'<DirectProjectComposer',
conversationOpen,
);
expect(headerIndex).toBeGreaterThan(conversationOpen);
expect(bodyIndex, '会话区必须排在顶栏之后').toBeGreaterThan(headerIndex);
expect(composerIndex, '输入盒必须排在会话区之后').toBeGreaterThan(
bodyIndex,
);
// 唯一滚动区仍是消息列表;`[overflow-anchor:none]` 是滚动补偿契约的一部分
// (见 `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`),
// 原生 scroll anchoring 一旦重新打开就会和会话侧补偿互相抵消。
expect(conversationSource).toContain(
'className="message-list project-chat-message-list [overflow-anchor:none]"',
);
const formOpen = composerSource.indexOf('<form');
expect(formOpen, '输入盒(form)缺失').toBeGreaterThanOrEqual(0);
const formEnd = composerSource.indexOf('</form>');
expect(formEnd, '输入盒(form)没有闭合').toBeGreaterThan(formOpen);
const formSource = composerSource.slice(
formOpen,
formEnd + '</form>'.length,
);
// 编辑器、`+` / `@` 引用、模型选择、提交按钮全在这棵子树里,位置只由样式挪。
expect(formSource).toContain('ResourceReferenceInput');
// 输入盒里不再有「项目名 · 本地」信息标签行(按需求删除)。
expect(formSource).not.toContain('project-chat-composer-context');
expect(formSource).toContain('project-chat-composer-controls');
expect(formSource).toContain('project-chat-reference-trigger');
expect(formSource).toContain('ConversationModelSelect');
expect(formSource).toContain('onSubmit={');
// 提交按钮本体还是那只按钮,只是以变量形式引用进这棵子树。
expect(composerSource).toContain('className="project-chat-submit-button"');
});
/**
* 面板纵向必须撑满 `.game-workbench-chat` 的高度。
*
* 这里锁的是一次真实渲染事故:`.game-workbench-chat` 是
* `grid-template-rows: auto minmax(0, 1fr)`(原本给「头部 + 内容」两行用),direct-codex
* 精简掉头部后对话面板成了第一个子元素、落进第一行 auto 轨道。面板高度一旦大于
* 内容(新会话只有一条欢迎消息时就是如此),面板就被压成内容高度——消息列表塌成几十像素、
* 输入盒被拉伸到两百多像素。实测修法是让面板跨全部行(`grid-row: 1 / -1`)并靠
* `height: 100%` 撑满轨道;`flex: 0 1 auto` 之类的 flex 简写会把 basis 变回 auto,同样塌。
*/
it('面板撑满对话列高度,消息列表不再被压成内容高度', () => {
const chat = desktopDeclarations('.game-workbench-chat');
const surface = desktopDeclarations(
'.game-workbench-chat .project-chat-surface',
);
const list = desktopDeclarations('.project-chat-message-list');
const listInPanel = desktopDeclarations(MESSAGE_LIST_PLAIN);
const listInPanelDirectCodex = desktopDeclarations(MESSAGE_LIST);
// 对话列仍是两行 grid:面板必须跨全部行,否则只能拿到 auto 行的高度。
expect(declaration(chat, 'display')).toBe('grid');
expect(declaration(surface, 'grid-row')).toBe('1 / -1');
expect(declaration(surface, 'height')).toBe('100%');
// flex 简写会把 basis 变回 auto,面板又会塌成内容高度。
expect(surface.has('flex')).toBe(false);
// 基础规则 `.project-chat-message-list { min-height: 340px }` 给列表压了下限;
// 面板里真正生效的是后写的 96px,而 direct-codex 面板必须把它解除到 0——
// 否则空对话时列表被顶高、输入盒被挤出面板(实测就是 panel 的 4564px 那次)。
expect(pixelValue(list, 'min-height')).toBe(340);
expect(pixelValue(listInPanel, 'min-height')).toBe(96);
expect(pixelValue(listInPanelDirectCodex, 'min-height')).toBe(0);
});
});
describe('策划对话:消息伸缩与长内容边界', () => {
function planningDeclarations(className: string, width: number) {
const root = document.createElement('div');
root.innerHTML = `<div class="window-chrome"><div class="window-chrome__content">
<div class="launcher-shell platform-theme"><aside></aside><main class="launcher-main">
<section class="launcher-page launcher-project-development game-project-workbench game-project-workbench--design">
<div class="game-workbench-layout game-workbench-layout--design">
<section class="game-workbench-stage"><div class="design-workspace-panel">
<div class="design-workspace-panel__body"><div class="design-workspace-tree"></div></div>
</div></section>
<aside class="game-workbench-chat"><header>策划</header>
<section class="project-chat-surface"><div class="project-chat-conversation">
<section class="design-agent-controls"></section>
<header class="project-chat-topbar"><span class="project-chat-topbar-status">
<span class="project-chat-topbar-dot"></span><span>已打开:项目</span>
</span></header>
<div class="message-list project-chat-message-list"></div>
<section class="design-agent-pending-actions"></section>
<form class="project-chat-composer"><p class="project-chat-composer-notice"></p></form>
</div></section>
</aside></div></section></main></div></div></div>`;
const element = root.querySelector(className)!;
// 从 DOM 匹配完整选择器,防止遗漏基础规则或误把 Direct 专用样式算到策划入口。
const selectors = rules.flatMap((rule) =>
rule.selectors.filter((selector) => {
try {
return element.matches(selector);
} catch {
// jsdom 不支持的浏览器伪类与本面板无关。
return false;
}
}),
);
return resolveDeclarations(rules, selectors, width);
}
it.each([1440, 390])(
'%ipx:消息吸收剩余高度,状态和输入区不参与拉伸',
(width) => {
const conversation = planningDeclarations(
'.project-chat-conversation',
width,
);
const list = planningDeclarations('.project-chat-message-list', width);
const composer = planningDeclarations('.project-chat-composer', width);
const topbar = planningDeclarations('.project-chat-topbar', width);
expect(declaration(conversation, 'display')).toBe('flex');
expect(declaration(conversation, 'flex-direction')).toBe('column');
expect(declaration(conversation, 'height')).toBe('100%');
expect(declaration(conversation, 'overflow')).toBe('visible');
expect(declaration(list, 'flex')).toBe('1 1 0');
expect(declaration(list, 'min-height')).toBe('0');
expect(declaration(list, 'overflow-y')).toBe('auto');
expect(declaration(composer, 'flex')).toBe('0 0 auto');
expect(declaration(topbar, 'flex')).toBe('0 0 auto');
expect(declaration(topbar, 'min-height')).toBe('44px');
expect(
declaration(
planningDeclarations('.project-chat-topbar-status', width),
'font-size',
),
).toBe('12px');
expect(
declaration(
planningDeclarations(
'.project-chat-topbar-status > span:last-child',
width,
),
'overflow-wrap',
),
).toBe('anywhere');
for (const selector of [
'.design-agent-controls',
'.design-agent-pending-actions',
]) {
const panel = planningDeclarations(selector, width);
expect(declaration(panel, 'max-height')).toBe('30%');
expect(declaration(panel, 'min-height')).toBe('0');
expect(declaration(panel, 'overflow')).toBe('auto');
}
},
);
it.each([390, 760])(
'%ipx:固定外壳内工作台可滚动到对话,两块面板保持明确高度',
(width) => {
const workbench = planningDeclarations('.game-project-workbench', width);
const layout = planningDeclarations('.game-workbench-layout', width);
const stage = planningDeclarations('.game-workbench-stage', width);
const chat = planningDeclarations('.game-workbench-chat', width);
expect(declaration(workbench, 'min-height')).toBe('0');
expect(declaration(workbench, 'overflow-y')).toBe('auto');
expect(declaration(workbench, 'overflow-x')).toBe('hidden');
expect(declaration(layout, 'height')).toBe('auto');
expect(declaration(layout, 'grid-template-columns')).toBe(
'minmax(0, 1fr)',
);
expect(declaration(stage, 'height')).toBe('560px');
expect(declaration(chat, 'height')).toBe(
'clamp(560px, calc(100dvh - 154px), 900px)',
);
expect(
declaration(
planningDeclarations('.design-workspace-panel', width),
'height',
),
).toBe('100%');
expect(
declaration(
planningDeclarations('.design-workspace-tree', width),
'overflow',
),
).toBe('auto');
},
);
it.each([761, 1280])('%ipx:双栏继续填满外壳剩余高度', (width) => {
const workbench = planningDeclarations('.game-project-workbench', width);
const layout = planningDeclarations('.game-workbench-layout', width);
expect(declaration(workbench, 'overflow')).toBe('hidden');
expect(workbench.has('overflow-y')).toBe(false);
expect(declaration(layout, 'height')).toBe('100%');
expect(declaration(layout, 'grid-template-columns')).toBe(
'minmax(0, 1fr) minmax(380px, 0.42fr)',
);
});
it('批量导入失败提示可滚动且可换行,不撑高输入区或撑宽面板', () => {
const notice = planningDeclarations('.project-chat-composer-notice', 390);
expect(declaration(notice, 'max-height')).toBe('96px');
expect(declaration(notice, 'overflow')).toBe('auto');
expect(declaration(notice, 'overflow-wrap')).toBe('anywhere');
});
});