Files
Genarrative/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx
T
suzmii 7ce4a3a9c6 把素材类型从「编辑素材标签」面板拆成独立入口
- 新增 ResourceTypePanel(src/view/project-development/ResourceTypePanel.tsx):类型选一项即落盘,category 传用户选中值、tags 逐字回传落盘原值,保存在飞时沿用 closeOnBackdrop/closeOnEscape = !saving
- 新增 resourceAssetDisplayName.ts:两块面板共用同一个素材名副标题口径(localPath basename),不再各写一份
- 新增 resourceTypePanel.css:类型面板的弹窗骨架与信息浮层「分类」行入口样式,按 AGC 内独立文件约定,不动 packages/** 与 styles.css
- ResourceClassificationPanel 删掉类型 chip 与 categoryChoice:保存时恒回传 gameCreationAppAssetPersistedCategory(asset),「只改标签不动分类」由结构保证
- index.tsx 新增 resourceTypeAssetId 与 resourceTypeAsset;工具条在「编辑标签」旁新增「素材类型」按钮(Shapes 图标)
- index.tsx 用合并后的 resourceClassificationOverlayOpen 纳入点外部/Esc 浮层遮挡判据,并同步 useEffect 依赖项
- index.tsx 渲染新面板:保存成功后由宿主收起面板并沿用 manifest 重载路径,让卡片立刻落到新栏目
- 信息浮层「分类」行接出第二入口:按钮渲染在 dd 之外,不污染 dt/dd 字段读取口径;非 manifest 资产不给入口
- resourceCanvasInfoModel 导出 RESOURCE_INFO_CATEGORY_FIELD_LABEL,模型与入口共用同一行标识
- 新增 tests/resourceTypePanel.test.tsx:选中即落盘、tags 逐字回传、没碰过不写盘、显式待归类、失败回退、saving 锁、Esc/遮罩、面板骨架共 11 条
- 迁移「没碰过分类就回传落盘原值」对照用例并保留两条(标签面板侧),chip 用例搬到新面板文件
- 集成用例改走新入口:选中即落盘并自动关窗,另补 Esc/点外部不串台、只改标签后 manifest category 逐字不变两条宿主级用例
- appSurface 工具条清单补上「素材类型」
2026-09-14 17:07:15 +08:00

687 lines
26 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 {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
import { ResourceClassificationPanel } from '../src/view/project-development/ResourceClassificationPanel';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
const asset: GameCreationAppAssetManifestEntry = {
id: 'asset-hero',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'uploaded' },
category: 'character',
tags: ['主角'],
};
function installInvoke(
implementation: (command: string, args?: unknown) => Promise<unknown>,
) {
const invoke = vi.fn(implementation);
(
window as unknown as {
__TAURI__?: { core?: { invoke?: typeof invoke } };
}
).__TAURI__ = { core: { invoke } };
return invoke;
}
function removeInvoke() {
delete (
window as unknown as {
__TAURI__?: { core?: { invoke?: unknown } };
}
).__TAURI__;
}
function renderPanel(
overrides: {
asset?: GameCreationAppAssetManifestEntry;
onClose?: () => void;
onSaved?: (result: unknown) => void;
} = {},
) {
render(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={overrides.asset ?? asset}
onClose={overrides.onClose ?? vi.fn()}
onSaved={overrides.onSaved ?? vi.fn()}
/>,
);
}
/** 已有标签按 pill 文本读出(pill 内的删除按钮文本是 `×`)。 */
function pillLabels() {
const list = screen.queryByRole('list', { name: '已有标签' });
if (!list) return [];
return within(list)
.getAllByRole('listitem')
.map((item) => item.textContent?.replace('×', '').trim());
}
function classificationWrites(invoke: ReturnType<typeof vi.fn>) {
return invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_classification',
);
}
afterEach(() => {
cleanup();
removeInvoke();
});
describe('ResourceClassificationPanel 编辑素材标签', () => {
test('对齐用户截图:标题、素材名副标题、标签占位与底部唯一的「添加」', () => {
installInvoke(async () => undefined);
renderPanel({
asset: { ...asset, localPath: 'assets/UI Assets/生成UI设计图.png' },
});
expect(
screen.getByRole('heading', { name: '编辑素材标签' }),
).not.toBeNull();
// 副标题是素材名(localPath 的 basename),不再是资源路径。
expect(screen.getByText('生成UI设计图.png')).not.toBeNull();
expect(screen.queryByText('assets/UI Assets/生成UI设计图.png')).toBeNull();
expect(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
).not.toBeNull();
// 底部只留一个「添加」:保存 / 取消 / 删除三个旧按钮都撤了
// (关闭走头部 ×,删除素材在资源卡选中工具条上)。
expect(screen.getByRole('button', { name: '添加' })).not.toBeNull();
expect(screen.queryByRole('button', { name: '保存标签' })).toBeNull();
expect(screen.queryByRole('button', { name: '取消' })).toBeNull();
expect(screen.queryByRole('button', { name: '删除资源' })).toBeNull();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
// 「管理全部标签」没有实现。
expect(screen.queryByRole('button', { name: '管理全部标签' })).toBeNull();
// 素材类型(功能分类)不再长在这个弹窗里:6 个分类项一个都不该出现,
// 它们是「设置素材类型」面板的内容(见 tests/resourceTypePanel.test.tsx)。
for (const label of [
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
]) {
expect(screen.queryByRole('button', { name: label })).toBeNull();
}
});
test('每个已有标签渲染成自带删除按钮的 pill,且 aria-label 可区分', () => {
installInvoke(async () => undefined);
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] } });
expect(pillLabels()).toEqual(['主页', '节日']);
// 删除按钮在 pill 内部(不是飘在外面的独立图标)。
const pill = screen.getByRole('button', { name: '删除标签 主页' });
expect(pill.closest('.game-resource-tag-pill')).not.toBeNull();
expect(
screen.getByRole('button', { name: '删除标签 节日' }),
).not.toBeNull();
});
test('点某个 pill 的删除按钮只删掉那一个标签', async () => {
const user = userEvent.setup();
installInvoke(async () => undefined);
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] } });
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
expect(pillLabels()).toEqual(['节日']);
expect(screen.queryByRole('button', { name: '删除标签 主页' })).toBeNull();
expect(
screen.getByRole('button', { name: '删除标签 节日' }),
).not.toBeNull();
});
test('删掉 pill 之后只关面板不写盘:撤销不需要额外按钮', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const invoke = installInvoke(async () => undefined);
renderPanel({
asset: { ...asset, tags: ['主页', '节日'] },
onClose,
});
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
expect(pillLabels()).toEqual(['节日']);
// 关闭只有头部 × 一个入口(底部不再有「取消」)。
await user.click(screen.getByRole('button', { name: '关闭编辑素材标签' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(classificationWrites(invoke)).toHaveLength(0);
});
test('新增标签沿用同一套提交方式:回车 / 中英文逗号 / 顿号都切分', async () => {
const user = userEvent.setup();
installInvoke(async () => undefined);
renderPanel({ asset: { ...asset, tags: [] } });
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
await user.type(field, '主页,');
expect(pillLabels()).toEqual(['主页']);
await user.type(field, '节日,');
expect(pillLabels()).toEqual(['主页', '节日']);
await user.type(field, '新春{Enter}');
expect(pillLabels()).toEqual(['主页', '节日', '新春']);
// 纯空白草稿不会落成空标签。
await user.type(field, ' {Enter}');
expect(pillLabels()).toEqual(['主页', '节日', '新春']);
});
test('点「添加」把输入框与剩余标签写成数组,并原样回传读到的分类', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
if (command === 'update_local_project_resource_classification') {
return {
asset: { ...asset, tags: ['节日', '新春'] },
committedProjectRevision: 8,
};
}
throw new Error(`unexpected command: ${command}`);
});
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] }, onSaved });
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'新春',
);
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(onSaved).toHaveBeenCalledTimes(1);
});
const [, args] = classificationWrites(invoke)[0]!;
expect(args).toEqual({
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
assetId: 'asset-hero',
// 本面板不编辑分类:落盘原值原样回传,不因为只改标签而漂移。
category: 'character',
tags: ['节日', '新春'],
},
});
});
/**
* 漂移防线:落盘 `unclassified` 而 `kind` 能派生出明确分类时,读显示口径会自愈成
* `ui-interaction`,但**回写必须是落盘原值** `unclassified`。否则「编辑标签」面板
* 会在用户只改标签时静默改掉分类——这正是真机上同一条 `kind:"ui"` 资产同时出现
* `unclassified` 与 `ui-interaction` 两种落盘值的成因。
*
* 变异验证:把面板改回 `gameCreationAppAssetCategory(asset)`,本用例必须失败。
*/
test('添加标签不改动落盘 category:自愈值不得被回写', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return {
asset: { ...asset, kind: 'ui', category: 'unclassified' },
committedProjectRevision: 8,
};
});
renderPanel({
asset: {
...asset,
kind: 'ui',
category: 'unclassified',
tags: [],
},
});
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'界面',
);
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const [, args] = classificationWrites(invoke)[0]!;
// 读显示口径下该资产是 ui-interaction;回写口径必须是落盘值 unclassified。
expect(args).toEqual({
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
assetId: 'asset-hero',
category: 'unclassified',
tags: ['界面'],
},
});
});
test('归一化口径不变:重复与空白标签在落盘前被收敛', async () => {
const user = userEvent.setup();
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset, committedProjectRevision: 8 };
});
renderPanel({ asset: { ...asset, tags: [] } });
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
await user.type(field, ' 主舞台 ,日夜,主舞台、');
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const [, args] = classificationWrites(invoke)[0]!;
expect((args as { input: { tags: string[] } }).input.tags).toEqual([
'主舞台',
'日夜',
]);
});
/**
* 「添加」的语义包含**输入框里还没按回车的尾巴**:用户打了字直接点按钮,不能白输入。
*
* 这里刻意用 `fireEvent.click` 而不是 `user.click`:前者不会先让输入框失焦,所以只有
* 「点击处理器自己把草稿并进来」这一条路能让用例通过
* `onBlur` 的 `commitTagDraft` 兜不住它)。
*
* 变异验证:点「添加」只保存 `tags`、不合并 `tagDraft`,本用例必须失败。
*/
test('点「添加」把没按回车的尾巴一起落成 pill 并保存,且不自己关窗', async () => {
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset, committedProjectRevision: 8 };
});
const onClose = vi.fn();
const onSaved = vi.fn();
renderPanel({
asset: { ...asset, tags: ['主页'] },
onClose,
onSaved,
});
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
fireEvent.change(field, { target: { value: '未回车的尾巴' } });
fireEvent.click(screen.getByRole('button', { name: '添加' }));
// 落 pill 是即时反馈,保存是这一步的语义本身。
await waitFor(() => {
expect(pillLabels()).toEqual(['主页', '未回车的尾巴']);
});
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const [, args] = classificationWrites(invoke)[0]!;
expect((args as { input: { tags: string[] } }).input.tags).toEqual([
'主页',
'未回车的尾巴',
]);
// 保存成功后面板自己不能关窗(关窗是头部 × 与宿主的职责),输入框清空便于接着加。
expect(onSaved).toHaveBeenCalledTimes(1);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
expect((field as HTMLInputElement).value).toBe('');
});
/**
* 「添加」是可连续执行的动作:点第二次必须真的再写一次盘(各自保存),
* 第二次的载荷要带上累计标签,分类仍原样回传落盘原值。
*
* 变异验证:让「添加」只在第一次写盘(例如保存后把按钮禁用不再恢复),本用例必须失败。
*/
test('连续两次「添加」各自保存,第二次带上累计标签', async () => {
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
return { asset, committedProjectRevision: 8 };
});
const onClose = vi.fn();
renderPanel({ asset: { ...asset, tags: [] }, onClose });
const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
const add = () => screen.getByRole('button', { name: '添加' });
fireEvent.change(field, { target: { value: '主角' } });
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
fireEvent.change(field, { target: { value: '待定稿' } });
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(2));
const secondInput = (
classificationWrites(invoke)[1]?.[1] as {
input: { tags: string[]; category: string };
}
).input;
expect(secondInput.tags).toEqual(['主角', '待定稿']);
// 分类不是这个面板的编辑对象:两次写入都回传同一条落盘原值。
expect(secondInput.category).toBe('character');
expect(pillLabels()).toEqual(['主角', '待定稿']);
expect((field as HTMLInputElement).value).toBe('');
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
});
test('surfaces the native rejection without reporting a save', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
throw '非法资源分类:future-category';
});
renderPanel({ onSaved });
await user.click(screen.getByRole('button', { name: '添加' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('非法资源分类');
expect(onSaved).not.toHaveBeenCalled();
});
test('does not attempt a write outside the native client', async () => {
const user = userEvent.setup();
const onSaved = vi.fn();
renderPanel({ onSaved });
await user.click(screen.getByRole('button', { name: '添加' }));
await screen.findByRole('alert');
expect(screen.getByRole('alert').textContent).toContain('客户端');
expect(onSaved).not.toHaveBeenCalled();
});
test('资源卡工具条入口 label 与面板标题一致,不再是「分类与标签」', () => {
const viewSource = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
);
// 工具条写着「分类与标签」却打开纯标签面板会误导用户,入口必须与面板同名。
expect(viewSource).toContain('label="编辑标签"');
expect(viewSource).toContain('title="编辑标签"');
expect(viewSource).toContain('<span>编辑标签</span>');
expect(viewSource).not.toContain('label="分类与标签"');
});
test('标签 pill 的删除按钮热区不小于 32px', () => {
const panelStyles = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/project-workspace/resourceClassificationTagPanel.css',
),
'utf8',
);
const rule = panelStyles.match(
/\.game-resource-tag-remove\s*\{([^}]*)\}/s,
)?.[1];
expect(rule).toBeDefined();
// 视觉图标可以小,热区不能小:这是"看得到却点不中"的防线。
expect(rule).toMatch(/width:\s*32px/);
expect(rule).toMatch(/height:\s*32px/);
expect(rule).toMatch(/flex:\s*0 0 32px/);
});
});
/**
* 素材类型(功能分类)**不再是这个面板的编辑对象**:它拆到了独立的「设置素材类型」面板
* `tests/resourceTypePanel.test.tsx`)。这一组钉住拆分后留下来的那条不变量 ——
*
* - 用户**没碰过**类型(本面板里根本不存在类型控件)→ 载荷 `category` 必须是**落盘原值**
* 不能等于读显示口径自愈出来的值;
* - 对照的另一半(用户**主动选过**类型 → 载荷是用户选的那个值)在新面板那一侧,
* 两边各自都有一条例用,任何一边被改成另一边的口径都会变红。
*/
describe('ResourceClassificationPanel 不再编辑素材类型', () => {
/**
* 落盘 `unclassified` 而 `kind:"ui"` 能派生出 `ui-interaction`:这是读时自愈的触发条件,
* 也正是"显示值 ≠ 落盘值"的现场。真机 57 条 `kind:"ui"` 资产就是这个形状。
*/
const selfHealingAsset: GameCreationAppAssetManifestEntry = {
...asset,
id: 'asset-ui',
kind: 'ui',
category: 'unclassified',
tags: [],
};
function installClassificationInvoke() {
return installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
if (command === 'update_local_project_resource_classification') {
return { asset, committedProjectRevision: 8 };
}
throw new Error(`unexpected command: ${command}`);
});
}
/**
* 拆分的验收判据之一:类型 chip 从这个弹窗里彻底消失(一个都不许剩)。
*
* 变异验证:把 chips 加回面板,本用例必须失败。
*/
test('面板里没有任何素材类型控件', () => {
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
for (const label of [
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
]) {
expect(screen.queryByRole('button', { name: label })).toBeNull();
}
expect(document.querySelector('.platform-segmented-tabs')).toBeNull();
});
/**
* 只改标签(本面板**没有**类型控件,用户不可能在这里改类型):载荷 `category` 必须仍是
* 落盘原值 `unclassified`,即使读显示口径会把这个 `kind:"ui"` 资产自愈成 `ui-interaction`。
*
* 这是与新面板「选中即落盘 → 载荷是用户选的值」互为对照的关键用例:
* 没有它,任何"顺手把显示值写回去"的实现都会绿。
*
* 变异验证:把写入改成"永远回传读显示口径"`gameCreationAppAssetCategory(asset)`),
* 载荷会变成 `ui-interaction` —— 也就是把自愈值写回落盘、让"只改标签"静默改分类,
* 本用例必须失败。
*/
test('只改标签时载荷 category 仍是落盘原值,不把读显示口径的自愈值写回去', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: selfHealingAsset });
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'界面',
);
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
assetId: 'asset-ui',
category: 'unclassified',
tags: ['界面'],
},
});
});
/*
* 「显式选待归类」「连续改两次类型」这两条原属于本面板的用例已随 chip 一起迁到
* `tests/resourceTypePanel.test.tsx`(那里点一下就是一次落盘,只钉写入值,不给
* "卡片会挪到待归类栏目"的承诺 —— 读显示口径会把 `kind` 已能明确分类的资产自愈回派生栏目,
* `unclassified` 无法区分"没有明确分类"与"用户显式选了待归类",这是 `gameCreationApp.ts`
* 记录的有意取舍)。
*/
});
describe('ResourceClassificationPanel 不再承载删除素材', () => {
/**
* 删除素材挪到资源卡选中工具条(破坏性动作与它要改的对象放在一起),面板里**一个入口都不该再有**:
* 旧版 footer 那个 `tone="danger"` 的「删除」删的是素材,属于放错地方。
*
* 顺序也一并钉住:头部关闭 → 标签 pill 内的删除标签 → 底部「添加」。
* 类型 chip 已拆到「设置素材类型」面板,这里多出任何一个按钮都会红。
* 变异验证:把 footer 的删除素材按钮(或类型 chip)加回来,本用例必须失败。
*/
test('面板里没有任何删除素材入口,打开面板不读写删除相关命令', async () => {
const invoke = installInvoke(async (command) => {
throw new Error(`unexpected command: ${command}`);
});
renderPanel();
expect(
screen
.getAllByRole('button')
.map(
(button) => button.getAttribute('aria-label') ?? button.textContent,
),
).toEqual(['关闭编辑素材标签', '删除标签 主角', '添加']);
// 打开面板不读引用、不写盘:删除那条链路在这里已经完全不存在。
expect(invoke).not.toHaveBeenCalled();
});
/**
* 底部只有一个「添加」且靠右下角。
*
* 变异验证:把 `justify-content` 改回默认(或改 `flex-start`),本用例必须失败。
*/
test('底部只有一个「添加」并靠右下角', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const rule = styles.match(
/\.game-resource-classification-footer\s*\{([^}]*)\}/s,
)?.[1];
expect(rule).toBeDefined();
expect(rule).toMatch(/display:\s*flex/);
expect(rule).toMatch(/justify-content:\s*flex-end/);
installInvoke(async () => undefined);
renderPanel();
const footer = document.querySelector(
'.game-resource-classification-footer',
);
expect(footer).not.toBeNull();
expect(within(footer as HTMLElement).getAllByRole('button')).toHaveLength(
1,
);
});
});
/**
* 面板的高度契约:标题与底部「添加」常驻,只有标签区自己滚动。
*
* jsdom 没有布局引擎,所以这里用声明级层叠求值器(与 `chatDialogFrameLayout.test.ts`
* 同一只)算出真机上最终生效的声明——只搜文件里"出现过 max-height"会被后面的同权重
* 规则顶掉,也看不出 `1fr` 轨道是否真的可压缩。
*
* 变异验证:删掉面板的 `max-height`、或删掉 body 的 `min-height: 0`、或把
* `grid-template-rows` 的中间轨道改回 `auto`,本用例必须失败。
*/
describe('ResourceClassificationPanel 标签区可滚动', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const rules = parseStyleSheet(styles);
test('面板有高度上界,中间一行可收缩并自行滚动', () => {
// 面板在 DOM 上同时命中 `.game-approval-dialog`(给出 `display: grid`)与
// `.game-resource-classification-dialog`,选择器身份就是这两条。
const dialog = resolveDeclarations(
rules,
['.game-approval-dialog', '.game-resource-classification-dialog'],
1440,
);
expect(declaration(dialog, 'display')).toBe('grid');
// 上界:没有它,标签一多面板就长出视口,遮罩又是不安全居中,靠上的标签看不见也滚不到。
expect(declaration(dialog, 'max-height')).toBe(
'min(720px, calc(100dvh - 40px))',
);
// 三段轨道:header 常驻 / body 可压缩可滚动 / footer 常驻。
expect(declaration(dialog, 'grid-template-rows')).toBe(
'auto minmax(0, 1fr) auto',
);
const body = resolveDeclarations(
rules,
['.game-resource-classification-body'],
1440,
);
expect(declaration(body, 'min-height')).toBe('0');
expect(declaration(body, 'overflow-y')).toBe('auto');
expect(declaration(body, 'overscroll-behavior')).toBe('contain');
expect(declaration(body, 'scrollbar-gutter')).toBe('stable');
// 三条轨道按 DOM 顺序落到 header / body / footer 上。
installInvoke(async () => undefined);
renderPanel();
const panel = document.querySelector(
'.game-resource-classification-dialog',
);
expect(panel).not.toBeNull();
expect(
Array.from(panel!.children).map((child) =>
child.classList.contains('game-resource-classification-body')
? 'body'
: child.tagName.toLowerCase(),
),
).toEqual(['header', 'body', 'footer']);
});
});