92db1e7065
- ProjectSupervisorView:direct-codex 面板改成三段式(顶部极简条 / 唯一滚动区消息列表 / 文档流输入盒) - ProjectSupervisorView:顶部只留状态点 + 状态文案(aria-live=polite)+ 设置齿轮,移除头像、标题、副标题、审批按钮与泥点钱包槽 - ProjectSupervisorView:新增 direct-codex 空态(线稿图标 + 主标题「你想让陶泥儿做什么游戏?」+ 短指令副标题),整体在消息区垂直水平居中 - ProjectSupervisorView:输入盒改成三行结构(`项目名` · `本地` 信息标签 / ResourceReferenceInput 文本区 / 控制排) - ProjectSupervisorView:控制排左为 `+` 附件钮与 `@` 引用钮(都复用 composerRef.openPicker),右为模型下拉与圆形主色发送钮 - ProjectSupervisorView:新增 walletEntry 可选属性,透传到设置浮层的「泥点」行 - ProjectSupervisorSettingsDialog(新增):设置齿轮打开的独立浮层(role=dialog + aria-modal + Esc 关闭 + 点遮罩关闭),承载运行配置入口、操作权限入口与泥点 - approvalMode(新增):把「严格 / 风险 / 无」审批模式选项与文案抽成可复用模块 - ApprovalModeDialog(新增):审批模式对话框改成可复用组件,index.tsx 改为复用它 - project-development/index.tsx:`!uiEditorRoute` 的 game-workbench-chat 头部精简掉标题/审批按钮/钱包槽,并用 cloneElement 把 walletEntry 透传给 supervisor 元素 - project-development/index.tsx:`planningStartMode` 分支保持原样 - ConversationModelSelect:触发按钮只显示已选模型 displayName,未加载完时显示「模型」占位(菜单内加载提示保留) - styles.css:消息列表去掉绝对定位与 196px/256px 底边留白,改成唯一滚动区 - styles.css:输入盒去掉绝对定位,改成文档流三行网格,成为唯一有边框的容器(1px 边框、14px 圆角) - styles.css:助手消息去掉整块底色,用户消息保留右对齐浅底气泡 + 主色描边 - styles.css:新增顶栏、空态、设置浮层与暖色执行过程卡的样式区块,颜色全部取自现有 platform 变量 - styles.css:窄屏(760px/1000px 断点)下输入盒留在文档流并保留四周边距 - chatDialogFrameLayout.test.ts:把「输入区浮在消息列表之上」的旧契约改写成「输入盒在消息列表下方、文档流、列表无浮层留白」的新契约 - project-development.suite.ts:同步更新表单布局、提交按钮尺寸与钱包槽的断言,并新增空态 + 设置浮层用例
363 lines
15 KiB
TypeScript
363 lines
15 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 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 COMPOSER_CONTEXT = `${COMPOSER_PLAIN} .project-supervisor-composer-context`;
|
||
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 context = desktopDeclarations(COMPOSER_CONTEXT);
|
||
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);
|
||
// 信息标签行只在 direct-codex 渲染,也只占一行,不设固定高度。
|
||
expect(context.has('height')).toBe(false);
|
||
expect(context.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).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);
|
||
});
|
||
});
|