55fab1776a
- 合并资源画布命名筛选与条件筛选为单一筛选浮层,Dock 只留放大镜入口 - 修复「编辑素材标签」面板标签多时不可见且不可滚:加高度上界与标签区独立滚动 - 替换素材新增点选替换模式,并在资源卡标注会话内替换血缘 - 新增栏目画布底部工具栏,按功能画布分流图片类生成、音频生成与上传入口 - 新增 Tauri IPC generate_local_project_asset,收口图片类无源生成的 kind 与提示词目录 - 同步 PRD、AGC 验收用例、决策日志、踩坑记录与待解决事项文档
350 lines
14 KiB
TypeScript
350 lines
14 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;
|
||
};
|
||
}
|
||
|
||
/* ============================================================
|
||
选择器身份
|
||
============================================================ */
|
||
|
||
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"');
|
||
});
|
||
});
|