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`,像素级观感只能真机复核;这里钉的是
层叠生效的声明级几何关系与"输入区仍在外框节点的子树里"。
569 lines
21 KiB
TypeScript
569 lines
21 KiB
TypeScript
// @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"');
|
||
});
|
||
});
|