// @vitest-environment jsdom import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; 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', ); /* ============================================================ 声明级层叠求值器。 jsdom 不加载 styles.css,所以"外框有没有把输入区包住"这类几何只能在声明上验; 但只读"第一条匹配到的规则"会被后面的同权重规则悄悄顶掉——本用例要防的就是这种事 (`bottom: 156px` 那条就是这样把上一轮修的 `bottom: 0` 变成死声明的), 所以这里按真实层叠(媒体查询是否命中 -> 特异性 -> 源码顺序)算出最终生效的值。 ============================================================ */ type StyleRule = { selectors: string[]; declarations: Map; media: string | null; order: number; }; function parseStyleSheet(css: string): StyleRule[] { const rules: StyleRule[] = []; const source = css.replace(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replace(/[^\n]/gu, ' '), ); const pushDeclarationBlock = ( selectorText: string, body: string, media: string | null, ) => { const selectors = splitSelectorList(selectorText); if (selectors.length === 0) { return; } const declarations = new Map(); for (const chunk of body.split(';')) { const separator = chunk.indexOf(':'); if (separator < 0) { continue; } const property = chunk.slice(0, separator).trim(); const value = chunk .slice(separator + 1) .trim() .replace(/\s+/gu, ' '); if (property) { declarations.set(property, value); } } rules.push({ selectors, declarations, media, order: rules.length }); }; const readBlock = (text: string, start: number) => { let depth = 1; let index = start; while (index < text.length && depth > 0) { if (text[index] === '{') { depth += 1; } else if (text[index] === '}') { depth -= 1; } index += 1; } return { body: text.slice(start, index - 1), end: index }; }; let cursor = 0; let buffer = ''; while (cursor < source.length) { const char = source[cursor]!; if (char === '{') { const prelude = buffer.trim().replace(/\s+/gu, ' '); buffer = ''; const { body, end } = readBlock(source, cursor + 1); cursor = end; if (prelude.startsWith('@media')) { // 媒体查询体内只陈述"顶层选择器 + 声明",这里就够了:查询用的选择器都在顶层。 let inner = 0; let innerBuffer = ''; while (inner < body.length) { if (body[inner] === '{') { const innerSelector = innerBuffer.trim().replace(/\s+/gu, ' '); innerBuffer = ''; const { body: innerBody, end: innerEnd } = readBlock( body, inner + 1, ); pushDeclarationBlock(innerSelector, innerBody, prelude); inner = innerEnd; continue; } innerBuffer += body[inner]; inner += 1; } continue; } if (prelude.startsWith('@')) { continue; } pushDeclarationBlock(prelude, body, null); continue; } if (char === '}') { buffer = ''; cursor += 1; continue; } buffer += char; cursor += 1; } return rules; } function splitSelectorList(selectorText: string): string[] { const selectors: string[] = []; let depth = 0; let current = ''; for (const char of selectorText) { if (char === '(' || char === '[') { depth += 1; } else if (char === ')' || char === ']') { depth -= 1; } if (char === ',' && depth === 0) { if (current.trim()) { selectors.push(current.trim().replace(/\s+/gu, ' ')); } current = ''; continue; } current += char; } if (current.trim()) { selectors.push(current.trim().replace(/\s+/gu, ' ')); } return selectors; } /** 特异性:只区分 id / 类(含属性与伪类)/ 元素,够排序本文件里的选择器。 */ function specificity(selector: string): number { let rest = selector; let classLike = 0; // `:has(...)` 的权重按它参数里最高的那条选择器算,这里等价于再加一个类。 const hasParts = rest.match(/:has\([^)]*\)/gu) ?? []; for (const part of hasParts) { classLike += 1; const inner = Math.max( ...splitSelectorList(part.slice(5, -1)).map((one) => specificity(one)), ); classLike += Math.floor(inner / 100) % 100; } rest = rest.replace(/:has\([^)]*\)/gu, ' '); const ids = (rest.match(/#[\w-]+/gu) ?? []).length; classLike += (rest.match(/\.[\w-]+/gu) ?? []).length; classLike += (rest.match(/\[[^\]]*\]/gu) ?? []).length; classLike += (rest.match(/:(?!:)[\w-]+/gu) ?? []).length; const types = (rest.match(/(?:^|[\s>+~])([a-zA-Z][\w-]*)/gu) ?? []).length + (rest.match(/::[\w-]+/gu) ?? []).length; return ids * 10000 + classLike * 100 + types; } function mediaMatches(media: string, viewportWidth: number): boolean { const conditions = media.match(/\((?:min|max)-width:\s*\d+px\)/gu) ?? []; if (conditions.length === 0) { // 只认识宽度条件:命中了别的媒体特性(例如 prefers-reduced-motion)就说明这条规则 // 的生效与否不是本用例能判定的,直接报错,避免悄悄算错一个几何值。 throw new Error(`测试求值器不认识这个媒体查询:${media}`); } for (const condition of conditions) { const parsed = /\((min|max)-width:\s*(\d+)px\)/u.exec(condition)!; const limit = Number(parsed[2]); if (parsed[1] === 'min' && viewportWidth < limit) { return false; } if (parsed[1] === 'max' && viewportWidth > limit) { return false; } } return true; } /** * 按层叠算出元素最终生效的声明。 * `elementSelectors` 是这个元素在 DOM 上会命中的全部选择器(含媒体查询里那几条)。 */ function resolveDeclarations( rules: StyleRule[], elementSelectors: readonly string[], viewportWidth: number, ): Map { const winners = new Map(); for (const rule of rules) { const matched = rule.selectors .filter((selector) => elementSelectors.includes(selector)) .map((selector) => specificity(selector)); if (matched.length === 0) { continue; } if (rule.media && !mediaMatches(rule.media, viewportWidth)) { continue; } const rank: [number, number] = [Math.max(...matched), rule.order]; for (const [property, value] of rule.declarations) { const current = winners.get(property); if ( !current || rank[0] > current.rank[0] || (rank[0] === current.rank[0] && rank[1] > current.rank[1]) ) { winners.set(property, { value, rank }); } } } return new Map( Array.from(winners, ([property, winner]) => [property, winner.value]), ); } function declaration( declarations: Map, property: string, ): string { const value = declarations.get(property); expect(value, `缺少生效声明 ${property}`).toBeDefined(); return value!; } 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; }; } /* ============================================================ 选择器身份 ============================================================ */ 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 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`; // 策划链在会话列里额外挂了一条规划面/窄条时,列表会命中这条 `:has(...)` 规则—— // 它同样声明了 `padding-bottom`,正是上一轮把留白改回 12px 的那种隐患。 const LIST_WITH_PLAN_SURFACE = `.game-workbench-chat .project-supervisor-conversation:has(.plan-gdd-surface, .planning-lane-runtime-strip) .project-supervisor-message-list`; 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('陶泥儿对话区:外框完整包住输入区', () => { it('外框四边贴会话区,输入区四边都在外框之内且不重合(宽屏)', () => { const list = desktopDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE); const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN); // 先看不重合关系再钉绝对位移:底边必须"外框在外、输入区在内",留白 > 0。 // (上一轮的残留 bug 就是外框底边被另一条 `bottom: 156px` 顶到输入区腰上。) const frameBottom = pixelValue(list, 'bottom'); const frameTop = pixelValue(list, 'top'); const frameLeft = pixelValue(list, 'left'); const frameRight = pixelValue(list, 'right'); const composerBottom = pixelValue(composer, 'bottom'); const composerLeft = pixelValue(composer, 'left'); const composerRight = pixelValue(composer, 'right'); const inset = composerBottom; expect(inset, '输入区必须与外框底边留出可见边距').toBeGreaterThan(0); expect( composerBottom - frameBottom, '外框底边必须严格低于输入区底边', ).toBeGreaterThan(0); expect( composerLeft - frameLeft, '外框左边必须在输入区左边之外', ).toBeGreaterThan(0); expect( composerRight - frameRight, '外框右边必须在输入区右边之外', ).toBeGreaterThan(0); expect(pixelValue(composer, 'right')).toBe(inset); expect(composerLeft - frameLeft).toBe(inset); expect(composerRight - frameRight).toBe(inset); // 外框四边就是会话区那只盒子——它才是"对话框"。 expect(frameTop).toBe(0); expect(frameRight).toBe(0); expect(frameBottom).toBe(0); expect(frameLeft).toBe(0); // 上边不钉死(底边锚定、向上生长),由 max-height 兜住:任何情况下上边至少离外框 12px。 expect(declaration(composer, 'position')).toBe('absolute'); expect(composer.has('top')).toBe(false); expect(declaration(composer, 'max-height')).toBe( `calc(100% - ${inset * 2}px)`, ); // 输入区自己那只盒子还在(拆掉边框/底色会让输入区变成没有边界的裸文本)。 expect(declaration(composer, 'border')).toContain('1px solid'); expect(declaration(composer, 'background')).toContain( 'var(--platform-input-fill)', ); }); it('消息列表底部留白 = 输入区内缩 + 输入区最高高度 + 间距,且与 scroll-padding 同值', () => { const list = desktopDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE); const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN); const { total, actionsRowHeight } = composerMaxHeight(); const inset = pixelValue(composer, 'bottom'); const gap = 16; const paddingBottom = pixelValue(list, 'padding-bottom'); // 输入区最高高度由真实声明算出来:编辑器 140 + 行距 8 + 操作排 28 + 输入框下内边距 4 // + 输入区上下内边距 16 + 操作条(2 + 30 方钮)= 228。 expect(actionsRowHeight, '操作排按钮高度变了就要重算下面的算术式').toBe(28); expect(total).toBe(228); // 留白 ≥ 输入区最高高度 + 内缩:滚到底时最后一条消息不会被输入区盖住。 expect(paddingBottom).toBeGreaterThanOrEqual(inset + total); // 且留白就该等于这条算术式,不允许多出一个"来历不明"的常量。 expect(paddingBottom).toBe(inset + total + gap); expect(pixelValue(list, 'scroll-padding-bottom')).toBe(paddingBottom); }); it('窄屏把外框交给会话列,输入区回到文档流,四周仍有一圈边距(390px)', () => { const conversation = mobileDeclarations(CONVERSATION); const list = mobileDeclarations(MESSAGE_LIST, LIST_WITH_PLAN_SURFACE); const composer = mobileDeclarations( COMPOSER, COMPOSER_PLAIN, CONVERSATION_COMPOSER, ); // 外框画在列表与输入区共同的父节点上,用的还是那套 token。 const framePadding = paddingBox(conversation); const inset = framePadding.top; expect(inset, '窄屏外框必须给输入区留出可见边距').toBeGreaterThan(0); expect(framePadding.right).toBe(inset); expect(framePadding.bottom).toBe(inset); expect(framePadding.left).toBe(inset); expect(declaration(conversation, 'border')).toContain( 'var(--platform-subpanel-border)', ); expect(declaration(conversation, 'background')).toContain( 'var(--platform-input-fill)', ); expect(declaration(conversation, 'border-radius')).toBe('12px'); // 列表在窄屏不再自画一只框:框里不套框,也就不会出现两条描边压在一起。 const listFrame = list.get('border'); expect(listFrame).toBeDefined(); expect(listFrame).not.toContain('1px'); expect(declaration(list, 'background')).toBe('transparent'); expect(declaration(list, 'position')).toBe('static'); // 输入区回到文档流:它是外框的子节点,四边由外框内边距让出,不会再盖住消息。 expect(declaration(composer, 'position')).toBe('relative'); expect(declaration(composer, 'bottom')).toBe('auto'); expect(declaration(composer, 'left')).toBe('auto'); expect(declaration(composer, 'right')).toBe('auto'); expect(declaration(composer, 'max-height')).toBe('none'); // 流内元素只与列表上下相邻,列表底边留 12px 与它分开。 expect(pixelValue(list, 'padding-bottom')).toBe(inset); }); it('输入区仍落在对话框外框节点的子树里,交互控件原位', () => { const source = readFileSync(VIEW_PATH, 'utf8'); const conversationOpen = source.indexOf( '
', ); expect(conversationOpen, '会话列容器缺失').toBeGreaterThanOrEqual(0); // 用 div 配对找出会话列容器的闭合位置(属性里的箭头函数不影响
计数)。 const before = source.slice(conversationOpen); const divOpeners = (before.match(/])/gu) ?? []).length; expect(divOpeners).toBeGreaterThan(0); let depth = 0; const cursor = conversationOpen; let conversationEnd = -1; const tokenPattern = /])|<\/div>/gu; tokenPattern.lastIndex = conversationOpen; let token = tokenPattern.exec(source); while (token) { depth += token[0] === '
' ? -1 : 1; if (depth === 0) { conversationEnd = token.index + token[0].length; break; } token = tokenPattern.exec(source); } expect(conversationEnd, '会话列容器没有闭合').toBeGreaterThan( conversationOpen, ); const conversationSource = source.slice(conversationOpen, conversationEnd); expect(conversationSource).toContain('project-supervisor-message-list'); expect(conversationSource).toContain('project-supervisor-composer'); const composerSource = conversationSource.slice( conversationSource.indexOf('') + ''.length, ); // 编辑器、@ 引用、AI 润色、模型选择、提交按钮全在这棵子树里,位置只由样式挪。 expect(composerSource).toContain('ResourceReferenceInput'); expect(composerSource).toContain('project-supervisor-composer-controls'); expect(composerSource).toContain('project-supervisor-reference-trigger'); expect(composerSource).toContain('ConversationModelSelect'); expect(composerSource).toContain('{submitButton}'); expect(composerSource).toContain('onSubmit={'); // 提交按钮本体还是那只按钮,只是以变量形式引用进这棵子树。 expect(source).toContain('className="project-supervisor-submit-button"'); }); });