Files
Genarrative/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx
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

805 lines
30 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 个合法分类各一项。
for (const label of [
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
]) {
expect(screen.getByRole('button', { name: label })).not.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/);
});
});
/**
* 素材类型(功能分类)的用户入口。这一组钉住本次改动的核心不变量,两个方向互为对照:
*
* - 用户**主动选过**类型 → 载荷 `category` 必须是用户选的那个值;
* - 用户**没碰过**类型控件(只改标签)→ 载荷 `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}`);
});
}
function segmentedTabItem(label: string) {
return screen
.getByRole('button', { name: label })
.closest('.platform-segmented-tabs');
}
test('选择器给出 6 个合法分类,中文名与画布栏目同一份口径', () => {
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
for (const label of [
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
]) {
// 6 项都在同一个分段页签容器里,用的是与资源筛选同一份中文展示名。
expect(segmentedTabItem(label)).not.toBeNull();
}
});
/**
* 选择器的选中项是**读显示口径**(与资源卡栏目同源):该资产落盘是 `unclassified`
* 但画布把它放在「UI 交互」栏,选择器必须也显示「UI 交互」。
*
* 变异验证:把选择器读数换成 `gameCreationAppAssetPersistedCategory(asset)`(落盘口径),
* 选中项会变成「待归类」,本用例必须失败 —— 那正是"面板说待归类、卡片在 UI 交互"的错位。
*/
test('选择器显示的是显示口径:落盘 unclassified + kind ui 的资产选中「UI 交互」', () => {
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
expect(
screen
.getByRole('button', { name: 'UI 交互' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen
.getByRole('button', { name: '待归类' })
.getAttribute('aria-pressed'),
).toBe('false');
});
/**
* 主动改类型:载荷 `category` 是用户选的那个值,而不是落盘原值。
*
* 变异验证:把写入改回"永远回传落盘原值"
* `category: gameCreationAppAssetPersistedCategory(asset)`),载荷会变成 `unclassified`
* 本用例必须失败。
*/
test('主动选中某个素材类型后保存:载荷 category 是用户选的那个值', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: selfHealingAsset, onSaved: vi.fn() });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
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: 'scene',
tags: [],
},
});
});
/**
* 只改标签(**没碰过**类型控件):载荷 `category` 必须仍是落盘原值 `unclassified`
* 即使选择器上显示的是自愈出来的 `ui-interaction`。
*
* 这是与上一条互为对照的关键用例:没有它,任何"顺手把显示值写回去"的实现都会绿。
*
* 变异验证:把写入改成"永远回传读显示口径"`gameCreationAppAssetCategory(asset)`),
* 载荷会变成 `ui-interaction` —— 也就是把自愈值写回落盘、让"只改标签"静默改分类,
* 本用例必须失败。
*/
test('只改标签时载荷 category 仍是落盘原值,不把选择器上的自愈值写回去', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: selfHealingAsset });
expect(
screen
.getByRole('button', { name: 'UI 交互' })
.getAttribute('aria-pressed'),
).toBe('true');
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: ['界面'],
},
});
});
/**
* 显式选「待归类」写下去的就是 `unclassified`。
*
* 注意这里只钉**写入值**,不给"卡片会挪到待归类栏目"的承诺:读显示口径会把
* `kind` 已能明确分类的资产自愈回派生栏目(`unclassified` 无法区分"没有明确分类"
* 与"用户显式选了待归类",这是 `gameCreationApp.ts` 记录的有意取舍)。
* 选一个**能**被派生值承认的分类(上一条的 `scene`)不受该取舍影响。
*/
test('显式选中「待归类」:载荷 category 是 unclassified', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: { ...asset, category: 'character' } });
await user.click(screen.getByRole('button', { name: '待归类' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const input = (
classificationWrites(invoke)[0]?.[1] as {
input: { category: string };
}
).input;
expect(input.category).toBe('unclassified');
});
/**
* 改类型与「点添加不关窗、可连续添加」不互斥:第一次改类型保存后,第二次接着改回另一类型
* 仍然各写一次盘,两次的 `category` 分别是用户当次选的值。
*
* 变异验证:把 `categoryChoice` 只在首次生效(保存后重置为落盘值),第二次写入的
* `category` 会退回落盘值,本用例必须失败。
*/
test('连续两次「添加」各带当次选择的类型,且面板不自己关窗', async () => {
const invoke = installClassificationInvoke();
const onClose = vi.fn();
renderPanel({ asset: selfHealingAsset, onClose });
const add = () => screen.getByRole('button', { name: '添加' });
fireEvent.click(screen.getByRole('button', { name: '角色与对象' }));
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: '音频' }));
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(2));
expect(
classificationWrites(invoke).map(
([, args]) => (args as { input: { category: string } }).input.category,
),
).toEqual(['character', 'audio']);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
});
});
describe('ResourceClassificationPanel 不再承载删除素材', () => {
/**
* 删除素材挪到资源卡选中工具条(破坏性动作与它要改的对象放在一起),面板里**一个入口都不该再有**:
* 旧版 footer 那个 `tone="danger"` 的「删除」删的是素材,属于放错地方。
*
* 顺序也一并钉住:头部关闭 → 素材类型选择器 6 项 → 标签 pill 内的删除标签 → 底部「添加」,
* 多出任何一个按钮都会红。
* 变异验证:把 footer 的删除素材按钮加回来,本用例必须失败。
*/
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([
'关闭编辑素材标签',
// 素材类型选择器:6 个合法分类,顺序就是画布栏目顺序。
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
'删除标签 主角',
'添加',
]);
// 打开面板不读引用、不写盘:删除那条链路在这里已经完全不存在。
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']);
});
});