Files
Genarrative/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
T
suzmii bc796c1db4 删除对话输入盒内的项目信息标签行
- ProjectSupervisorView:移除输入盒内「项目名 · 本地」信息标签行,删除随之无用的 projectDisplayName 辅助函数
- styles.css:输入盒网格从三行改回两行(文本区 / 控制排),删除信息标签行及其胶囊样式规则
- chatDialogFrameLayout.test.ts:把「表单子树含信息标签行」的断言反转成不再包含,并删除相关 CSS 常量与取值
- project-development.suite.ts:输入盒断言从「含项目名/本地标签」改为「不再渲染该标签行」,网格行数断言同步为两行
2026-09-14 14:17:20 +08:00

393 lines
17 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 { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
const STYLES_PATH = resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/styles.css',
);
const VIEW_PATH = resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.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) };
}
/* ============================================================
选择器身份
============================================================ */
const CHAT = '.game-workbench-chat .project-supervisor-surface.is-direct-codex';
const CONVERSATION = `${CHAT} .project-supervisor-conversation`;
const MESSAGE_LIST = `${CHAT} .project-supervisor-message-list`;
const MESSAGE_LIST_PLAIN =
'.game-workbench-chat .project-supervisor-message-list';
const COMPOSER = `${CHAT} .project-supervisor-composer.is-direct-codex`;
const COMPOSER_PLAIN = `${CHAT} .project-supervisor-composer`;
const CONVERSATION_COMPOSER = `${CONVERSATION} .project-supervisor-composer.is-direct-codex`;
const COMPOSER_INPUT = `${COMPOSER} .resource-reference-input`;
const COMPOSER_INPUT_PLAIN = `${COMPOSER_PLAIN} .resource-reference-input`;
const COMPOSER_EDITOR = `${COMPOSER_PLAIN} .resource-reference-input-editor`;
const COMPOSER_CONTROLS = `${COMPOSER} .project-supervisor-composer-controls`;
const COMPOSER_SUBMIT = `${COMPOSER_CONTROLS} .project-supervisor-submit-button`;
const INPUT_ACTIONS = `${COMPOSER} .resource-reference-input-actions`;
const TOPBAR = `${CHAT} .project-supervisor-topbar`;
// 策划链在会话列里额外挂了一条规划面/窄条时,列表会命中这条 `:has(...)` 规则——
// 它同样声明了 `padding-bottom`,是这套几何里唯一"外来的"留白来源。
const LIST_WITH_PLAN_SURFACE = `.game-workbench-chat .project-supervisor-conversation:has(.plan-gdd-surface, .planning-lane-runtime-strip) .project-supervisor-message-list`;
/** 列表实际会命中的全部选择器:direct-codex 直连、`:has(...)` 变体、以及基础规则。 */
const LIST_SELECTORS = [
MESSAGE_LIST,
LIST_WITH_PLAN_SURFACE,
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,
'.resource-reference-input-editor',
);
const actions = desktopDeclarations(
INPUT_ACTIONS,
'.resource-reference-input-actions',
);
const polishButton = desktopDeclarations(
`${INPUT_ACTIONS} .resource-reference-input-polish`,
'.resource-reference-input-polish',
);
const controls = desktopDeclarations(COMPOSER_CONTROLS);
const submit = desktopDeclarations(COMPOSER_SUBMIT);
const editorMaxHeight = pixelValue(editor, 'max-height');
const actionsRowHeight = pixelValue(polishButton, 'height');
const inputRowGap = pixelValue(input, 'gap');
const inputPaddingBottom = paddingBox(input).bottom;
const composerPaddingTop = paddingBox(composer).top;
const composerPaddingBottom = paddingBox(composer).bottom;
const controlsRowHeight =
pixelValue(controls, 'padding-top') + pixelValue(submit, 'height');
expect(
pixelValue(editor, 'min-height'),
'编辑器最小高度是输入盒高度的下界,改了要同步改这里的算式',
).toBe(96);
// 操作排的高度就是那三只 28px 方钮自己撑起来的:它自己不设 height / min-height
// 一旦设了,网格行高会被顶起来、编辑器的 min-height 也跟着变,算式随之作废。
expect(actions.has('height')).toBe(false);
expect(actions.has('min-height')).toBe(false);
return {
actionsRowHeight,
total:
editorMaxHeight +
inputRowGap +
actionsRowHeight +
inputPaddingBottom +
composerPaddingTop +
composerPaddingBottom +
controlsRowHeight,
};
}
describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文档流输入盒)', () => {
it('输入盒在消息列表下方、处于文档流,不再浮在列表之上(宽屏)', () => {
const list = desktopDeclarations(...LIST_SELECTORS);
const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN);
// 列表是唯一滚动区:自身不再绝对定位,靠 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');
const insets = marginBox(composer);
expect(insets.top).toBe(0);
expect(insets.left).toBe(0);
expect(insets.right).toBe(0);
expect(insets.bottom).toBe(0);
// 也不再靠 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, actionsRowHeight } = composerMaxHeight();
// 输入盒最高高度由真实声明算出来:编辑器 140 + 行距 8 + 操作排 28 + 输入框下内边距 4
// + 输入盒上下内边距 18 + 操作条(2 + 28 方钮)= 228。
expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算式').toBe(28);
expect(pixelValue(desktopDeclarations(COMPOSER_SUBMIT), 'height')).toBe(28);
expect(total).toBe(228);
const paddingBottom = paddingBox(list).bottom;
// 新契约:留白归 0。旧版这里是一条 `inset + 输入区最高高度 + 间距` 的算式,
// 输入盒回到文档流之后,那段留白会变成凭空多出来的空白,把最后一条消息顶出可视区。
expect(paddingBottom, '输入盒在文档流里,列表底边不允许再给浮层留白').toBe(
0,
);
expect(pixelValue(list, 'scroll-padding-bottom')).toBe(paddingBottom);
// 留白必须严格小于输入盒高度,否则就是把旧模型的数字又写回来了。
expect(paddingBottom).toBeLessThan(total);
});
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');
// 输入盒仍在文档流(本来就在),四周由自己的外边距让出一圈留白。
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.left, '窄屏输入盒左右必须有边距').toBeGreaterThan(0);
expect(composerMargin.right).toBe(composerMargin.left);
expect(composerMargin.bottom, '窄屏输入盒底部必须有边距').toBeGreaterThan(
0,
);
// 列表在窄屏也不画框、不留浮层空白,它仍是唯一滚动区。
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-supervisor-composer 子树里,交互控件原位', () => {
const source = readFileSync(VIEW_PATH, 'utf8');
const formOpen = source.indexOf('<form');
expect(formOpen, '输入盒(form)缺失').toBeGreaterThanOrEqual(0);
const formEnd = source.indexOf('</form>');
expect(formEnd, '输入盒(form)没有闭合').toBeGreaterThan(formOpen);
const formSource = source.slice(formOpen, formEnd + '</form>'.length);
// 编辑器、`+` / `@` 引用、模型选择、提交按钮全在这棵子树里,位置只由样式挪。
expect(formSource).toContain('ResourceReferenceInput');
// 输入盒里不再有「项目名 · 本地」信息标签行(按需求删除)。
expect(formSource).not.toContain('project-supervisor-composer-context');
expect(formSource).toContain('project-supervisor-composer-controls');
expect(formSource).toContain('project-supervisor-attachment-trigger');
expect(formSource).toContain('project-supervisor-reference-trigger');
expect(formSource).toContain('ConversationModelSelect');
expect(formSource).toContain('{submitButton}');
expect(formSource).toContain('onSubmit={');
// 提交按钮本体还是那只按钮,只是以变量形式引用进这棵子树。
expect(source).toContain('className="project-supervisor-submit-button"');
// 面板三段式的顺序:顶栏 → 消息列表 → 输入盒。
const conversationOpen = source.indexOf(
'<div className="project-supervisor-conversation">',
);
expect(conversationOpen).toBeGreaterThanOrEqual(0);
const topbarIndex = source.indexOf(
'project-supervisor-topbar',
conversationOpen,
);
const listIndex = source.indexOf(
'className="message-list project-supervisor-message-list"',
conversationOpen,
);
expect(topbarIndex).toBeGreaterThan(conversationOpen);
expect(listIndex, '消息列表必须排在顶栏之后').toBeGreaterThan(topbarIndex);
expect(formOpen, '输入盒必须排在消息列表之后').toBeGreaterThan(listIndex);
});
/**
* 面板纵向必须撑满 `.game-workbench-chat` 的高度。
*
* 这里锁的是一次真实渲染事故:`.game-workbench-chat` 是
* `grid-template-rows: auto minmax(0, 1fr)`(原本给「头部 + 内容」两行用),direct-codex
* 精简掉头部后 supervisor 面板成了第一个子元素、落进第一行 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-supervisor-surface',
);
const list = desktopDeclarations('.project-supervisor-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-supervisor-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);
});
});