diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 50b98eccb..779889167 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -2766,10 +2766,22 @@ export default function ProjectDevelopmentView({ /** * 资源分类更新与素材删除都只改 manifest,成功后重读一次并按新 revision 投影; * 两者共用同一条重载路径,避免出现两份略有差异的刷新逻辑。 + * + * `options.keepClassificationPanelOpen`:**标签保存成功后不收起「编辑素材标签」面板**。 + * 「添加」是可连续执行的动作(加完一个接着加下一个),保存即关窗会打断这条连续路径; + * 删除与版本替换则相反 —— 操作对象在重载后已经变了,收起面板才是对的。所以默认仍然收起, + * 只有标签保存显式传 `true`。关闭面板本身属于宿主状态(`resourceClassificationAssetId`), + * 不是面板自己调 `onClose`,所以这个开关只能放在这里。 */ const reloadManifestAfterAssetCommand = useCallback( - async (committedProjectRevision: number, commitId: string) => { - setResourceClassificationAssetId(null); + async ( + committedProjectRevision: number, + commitId: string, + options: { keepClassificationPanelOpen?: boolean } = {}, + ) => { + if (!options.keepClassificationPanelOpen) { + setResourceClassificationAssetId(null); + } setResourceRenameAssetId(null); const invoke = window.__TAURI__?.core?.invoke; if (!invoke || !onManifestChange) return; @@ -2801,6 +2813,8 @@ export default function ProjectDevelopmentView({ await reloadManifestAfterAssetCommand( result.committedProjectRevision, `asset-classification:${result.asset.id}`, + // 保存标签后保持面板打开:用户接着加下一个标签,关闭只走头部 ×。 + { keepClassificationPanelOpen: true }, ); }, [reloadManifestAfterAssetCommand], diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx index 5a72c8d65..19d568950 100644 --- a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx @@ -304,14 +304,20 @@ describe('ResourceClassificationPanel 编辑素材标签', () => { * * 变异验证:点「添加」只保存 `tags`、不合并 `tagDraft`,本用例必须失败。 */ - test('点「添加」把没按回车的尾巴一起落成 pill 并保存', async () => { + test('点「添加」把没按回车的尾巴一起落成 pill 并保存,且不自己关窗', async () => { const invoke = installInvoke(async (command) => { if (command === 'get_local_game_project_revision') { return { revision: 7 }; } return { asset, committedProjectRevision: 8 }; }); - renderPanel({ asset: { ...asset, tags: ['主页'] } }); + const onClose = vi.fn(); + const onSaved = vi.fn(); + renderPanel({ + asset: { ...asset, tags: ['主页'] }, + onClose, + onSaved, + }); const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔'); fireEvent.change(field, { target: { value: '未回车的尾巴' } }); @@ -329,6 +335,52 @@ describe('ResourceClassificationPanel 编辑素材标签', () => { '主页', '未回车的尾巴', ]); + // 保存成功后面板自己不能关窗(关窗是头部 × 与宿主的职责),输入框清空便于接着加。 + 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 () => { diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index 1c220d41f..b7aa4f934 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -293,6 +293,23 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { if (command === 'get_local_game_project_revision') { return { revision: EXPECTED_REVISION }; } + if (command === 'update_local_project_resource_classification') { + const input = args?.input as + | { assetId?: string; category?: string; tags?: string[] } + | undefined; + return { + asset: { + id: input?.assetId, + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/legacy.png', + source: { kind: 'generated' as const }, + category: input?.category, + tags: input?.tags, + }, + committedProjectRevision: 6, + }; + } if ( command === 'read_local_project_version_resource_replacement_candidates' ) { @@ -383,6 +400,13 @@ async function selectCardAndOpenToolbar(label: string) { return screen.findByRole('toolbar', { name: '图片工具栏' }); } +/** 标签写入的调用明细:`(command, args)`,用来断言"点了几次、每次写什么"。 */ +function classificationWrites(invoke: { mock: { calls: unknown[][] } }) { + return invoke.mock.calls.filter( + ([command]) => command === 'update_local_project_resource_classification', + ); +} + /** * 资源卡预览用的 IntersectionObserver stub。 * @@ -896,4 +920,70 @@ describe('版本级资源替换', () => { within(toolbar).getByRole('button', { name: '信息' }), ).not.toBeNull(); }); + + /** + * 「添加」保存成功后「编辑素材标签」面板**必须保持打开**(连续添加),关窗只能走头部 ×。 + * + * 关窗发生在宿主层:面板自己的 `saveResourceClassification` 只调 `onSaved`、不调 `onClose`, + * 真正清掉 `resourceClassificationAssetId` 的是宿主的 `reloadManifestAfterAssetCommand`。 + * 所以这条判据必须走真宿主链路才钉得住(只测面板组件看不出宿主把面板卸载了)。 + * + * 变异验证:去掉 `handleResourceClassificationSaved` 上的 `keepClassificationPanelOpen` + * (即恢复"保存后关窗"),本用例在第一次「添加」后就会失败。 + */ + it('标签保存成功后编辑素材标签面板保持打开,可以接着连续添加', async () => { + const { invoke } = renderReplacementWorkbench(); + + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' })); + const dialog = await screen.findByRole('dialog', { + name: '编辑素材标签', + }); + const tagField = () => + screen.getByPlaceholderText('新增标签,多个用逗号分隔'); + + // 第一次添加:草稿只是填进去,刻意不按回车也不失焦 —— 「添加」必须自己把尾巴并进来。 + fireEvent.change(tagField(), { target: { value: '主角' } }); + fireEvent.click(within(dialog).getByRole('button', { name: '添加' })); + + await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1)); + expect(classificationWrites(invoke)[0]?.[1]).toEqual({ + input: { + projectPath: PROJECT_PATH, + expectedProjectId: PROJECT_ID, + expectedProjectRevision: EXPECTED_REVISION, + assetId: 'asset-legacy', + // 分类不由这个面板编辑:回传的就是 manifest 里的落盘原值。 + category: 'character', + tags: ['主角'], + }, + }); + // 面板仍在(保存不得关窗),新标签已落成 pill,输入框已清空。 + expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull(); + expect( + within(dialog).getByRole('list', { name: '已有标签' }).textContent, + ).toContain('主角'); + expect((tagField() as HTMLInputElement).value).toBe(''); + + // 第二次添加:各自保存一次,且累计标签一起去写;面板依然开着。 + fireEvent.change(tagField(), { target: { value: '待定稿' } }); + fireEvent.click(within(dialog).getByRole('button', { name: '添加' })); + + await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(2)); + expect( + ( + classificationWrites(invoke)[1]?.[1] as { + input: { tags: string[]; category: string }; + } + ).input.tags, + ).toEqual(['主角', '待定稿']); + expect( + ( + classificationWrites(invoke)[1]?.[1] as { + input: { tags: string[]; category: string }; + } + ).input.category, + ).toBe('character'); + expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull(); + }); });