// @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, ) { 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( , ); } /** 已有标签按 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) { 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('编辑标签'); 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']); }); });