bcbfe92afa
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m36s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m57s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m26s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m24s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m54s
Project CI / Frontend tests (pull_request) Failing after 3m21s
Project CI / Repository checks (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Successful in 6m36s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m11s
Project CI / Backend tests (pull_request) Successful in 7m24s
同步空对话、输入盒、资源删除工具条的当前结构断言。 Windows 无法创建符号链接时跳过仅针对符号链接权限的测试。
409 lines
18 KiB
TypeScript
409 lines
18 KiB
TypeScript
// @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 SURFACE = CHAT;
|
||
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);
|
||
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, actionsRowHeight } = composerMaxHeight();
|
||
|
||
// 输入盒最高高度由真实声明算出来:编辑器 140 + 行距 8 + 操作排 28 + 输入框下内边距 4
|
||
// + 输入盒内边距 24(四边同为 12:文字左内缩必须等于上内缩)+ 操作条(2 + 28 方钮)= 234。
|
||
expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算式').toBe(28);
|
||
expect(pixelValue(desktopDeclarations(COMPOSER_SUBMIT), 'height')).toBe(28);
|
||
expect(total).toBe(234);
|
||
|
||
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');
|
||
|
||
// 输入盒仍在文档流(本来就在),四边留白与宽屏同一套值(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-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-reference-trigger');
|
||
expect(formSource).toContain('ConversationModelSelect');
|
||
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);
|
||
});
|
||
});
|