Files
Genarrative/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
T
suzmii 6cc929e47d 修复陶泥儿对话外框没把输入区包住:外框底边不再与输入区重合
- 根因:`.project-supervisor-message-list` 在文件末尾还留着一条同选择器、同权重的
  `bottom: 156px`("列表在上、输入区在下"那版两盒布局的残留)。它按"后写胜出"把上一轮
  bdb07c141 改的 `bottom: 0` 悄悄顶掉,外框底边落在输入区腰上、输入区下半截露在框外,
  真机上看到的就是"消息区外框底边与输入区压在一起、外框没把输入区完整包住"。
- styles.css:把那条残留规则换成对话框盒子的最终几何(top/right/bottom/left 全 0,
  `padding-bottom` 与 `scroll-padding-bottom` 都是 256px)。它位置在文件靠后,同权重后写
  胜出,顺带把 `:has(.plan-gdd-surface, .planning-lane-runtime-strip)` 给列表留的
  `padding-bottom: 12px` 压回去,滚到底时最后一条消息不会被输入区盖住。
- styles.css:输入区补上第四条边——`max-height: calc(100% - 24px)`(24px = 上下各 12px)。
  左右下三边仍显式内缩 12px,上边由 max-height 兜底,于是外框完整包住输入区:四条边都不
  与外框描边重合,底边留出 12px 边距(外框底边 0 / 输入区底边 12px)。
- styles.css:窄屏(≤760px)改成一个模型——外框画在列表与输入区共同的父节点
  `.project-supervisor-conversation` 上,列表撤掉自己那只框(框里不套框),输入区回到
  文档流,靠外框 12px 内边距四周留白、随列高增长不溢出。原来那条窄屏输入区规则权重低于
  `.project-supervisor-composer.is-direct-codex` 那条,其实一直是死声明,这次才真正生效。
- tests/chatDialogFrameLayout.test.ts:新增用例,先按真实层叠(媒体查询是否命中 -> 特异性
  -> 源码顺序)求生效值,再断言外框四边归 0、输入区四边内缩同一个值、底边与左右留白严格
  大于 0、留白 256 = 12px 内缩 + 228px 输入区最高高度 + 16px 间距(228 由编辑器
  max-height 140、输入框行距 8、操作排 28px 方钮、输入框下内边距 4、输入区上下内边距 16、
  操作条 2 + 30 方钮逐项从真实声明里算出来),以及窄屏下外框/列表/输入区各自落位、输入区
  仍在外框节点子树里且交互控件原位。
- tests/appSurface/project-development.suite.ts:把那条按"第一条匹配规则"取值、用 230 估
  算的留白断言同步成按声明算出的 228 与实际间距 16,并注明层叠生效值的守卫在新用例里。
- 变异验证:输入区 `bottom: 12px -> 0`(还原"贴死重合")时新断言失败;外框
  `bottom: 0 -> 156px`(还原上一轮残留)时"外框底边必须严格低于输入区底边"失败(-144)。
- 说明:本机无无头浏览器、vitest 未开 `css: true`,像素级观感只能真机复核;这里钉的是
  层叠生效的声明级几何关系与"输入区仍在外框节点的子树里"。
2026-09-11 10:36:45 +08:00

569 lines
21 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';
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<string, string>;
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<string, string>();
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<string, string> {
const winners = new Map<string, { value: string; rank: [number, number] }>();
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<string, string>,
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<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;
};
}
/* ============================================================
选择器身份
============================================================ */
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(
'<div className="project-supervisor-conversation">',
);
expect(conversationOpen, '会话列容器缺失').toBeGreaterThanOrEqual(0);
// 用 div 配对找出会话列容器的闭合位置(属性里的箭头函数不影响 <div / </div> 计数)。
const before = source.slice(conversationOpen);
const divOpeners = (before.match(/<div(?=[\s>])/gu) ?? []).length;
expect(divOpeners).toBeGreaterThan(0);
let depth = 0;
const cursor = conversationOpen;
let conversationEnd = -1;
const tokenPattern = /<div(?=[\s>])|<\/div>/gu;
tokenPattern.lastIndex = conversationOpen;
let token = tokenPattern.exec(source);
while (token) {
depth += token[0] === '</div>' ? -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('<form'),
conversationSource.indexOf('</form>') + '</form>'.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"');
});
});