// @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, property: string, ): number { return lengthPx(declaration(declarations, property), property); } /** 展开 `padding`:长写优先(后面的长写会覆盖简写),没有长写再拆简写。 */ function paddingBox(declarations: Map) { const sides: Array<['top' | 'right' | 'bottom' | 'left', number]> = [ ['top', 0], ['right', 0], ['bottom', 0], ['left', 0], ]; const longhand = new Map(); 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(); 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; 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) { 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(''); expect(formEnd, '输入盒(form)没有闭合').toBeGreaterThan(formOpen); const formSource = source.slice(formOpen, formEnd + ''.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( '
', ); 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); }); });