Files
Genarrative/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts
T
suzmii 55fab1776a
Project CI / Repository checks (pull_request) Successful in 2m55s
Project CI / Frontend tests (pull_request) Successful in 3m59s
Project CI / Backend tests (pull_request) Successful in 6m50s
Project CI / Native shell tests (pull_request) Failing after 14m3s
AGC 画布验收问题修复与栏目画布底部工具栏
- 合并资源画布命名筛选与条件筛选为单一筛选浮层,Dock 只留放大镜入口
- 修复「编辑素材标签」面板标签多时不可见且不可滚:加高度上界与标签区独立滚动
- 替换素材新增点选替换模式,并在资源卡标注会话内替换血缘
- 新增栏目画布底部工具栏,按功能画布分流图片类生成、音频生成与上传入口
- 新增 Tauri IPC generate_local_project_asset,收口图片类无源生成的 kind 与提示词目录
- 同步 PRD、AGC 验收用例、决策日志、踩坑记录与待解决事项文档
2026-09-13 14:07:03 +08:00

350 lines
14 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';
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;
};
}
/* ============================================================
选择器身份
============================================================ */
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"');
});
});