修复「编辑素材标签」保存后关窗:点「添加」成功后面板保持打开,可连续添加
Project CI / Repository checks (pull_request) Successful in 2m44s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / Backend tests (pull_request) Successful in 7m5s
Project CI / Native shell tests (pull_request) Successful in 17m30s

- 定位关窗来源:面板的 saveResourceClassification 只调 onSaved、从不调 onClose;真正卸载面板的是宿主 index.tsx 的 setResourceClassificationAssetId(null)(在 reloadManifestAfterAssetCommand 里,由 handleResourceClassificationSaved 触发)
- 在宿主层给 reloadManifestAfterAssetCommand 加 keepClassificationPanelOpen 开关:默认仍然收起,删除素材 / 版本替换 / 重命名的既有行为不变,只有标签保存显式传 true
- 标签保存后不再清 resourceClassificationAssetId:面板保持打开,新标签已落成 pill、输入框已清空,用户可以接着加下一个标签
- 关闭面板仍只走头部 ×;标签 pill 内的「删除标签」按钮与删除素材流程都不受影响
- 面板用例:添加后不得调用 onClose、面板仍在、新标签出现在已有标签列表、输入框清空;连续两次「添加」各自写盘且第二次带上累计标签、category 仍原样回传落盘值
- 工台用例(resourceVersionReplacement.test.tsx)补真宿主链路:经工具条「编辑标签」打开面板 → 添加 → 断言写入载荷与面板仍在 → 再添加一次 → 两次写入各自发生且面板依然打开(只测面板组件看不出宿主把面板卸载了)
This commit is contained in:
2026-09-12 15:59:44 +08:00
parent dc243b0d57
commit 46788edbb8
3 changed files with 160 additions and 4 deletions
@@ -2766,10 +2766,22 @@ export default function ProjectDevelopmentView({
/** /**
* manifest revision * manifest revision
* *
*
* `options.keepClassificationPanelOpen`****
*
*
* `true`宿`resourceClassificationAssetId`
* `onClose`
*/ */
const reloadManifestAfterAssetCommand = useCallback( const reloadManifestAfterAssetCommand = useCallback(
async (committedProjectRevision: number, commitId: string) => { async (
setResourceClassificationAssetId(null); committedProjectRevision: number,
commitId: string,
options: { keepClassificationPanelOpen?: boolean } = {},
) => {
if (!options.keepClassificationPanelOpen) {
setResourceClassificationAssetId(null);
}
setResourceRenameAssetId(null); setResourceRenameAssetId(null);
const invoke = window.__TAURI__?.core?.invoke; const invoke = window.__TAURI__?.core?.invoke;
if (!invoke || !onManifestChange) return; if (!invoke || !onManifestChange) return;
@@ -2801,6 +2813,8 @@ export default function ProjectDevelopmentView({
await reloadManifestAfterAssetCommand( await reloadManifestAfterAssetCommand(
result.committedProjectRevision, result.committedProjectRevision,
`asset-classification:${result.asset.id}`, `asset-classification:${result.asset.id}`,
// 保存标签后保持面板打开:用户接着加下一个标签,关闭只走头部 ×。
{ keepClassificationPanelOpen: true },
); );
}, },
[reloadManifestAfterAssetCommand], [reloadManifestAfterAssetCommand],
@@ -304,14 +304,20 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
* *
* 变异验证:点「添加」只保存 `tags`、不合并 `tagDraft`,本用例必须失败。 * 变异验证:点「添加」只保存 `tags`、不合并 `tagDraft`,本用例必须失败。
*/ */
test('点「添加」把没按回车的尾巴一起落成 pill 并保存', async () => { test('点「添加」把没按回车的尾巴一起落成 pill 并保存,且不自己关窗', async () => {
const invoke = installInvoke(async (command) => { const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') { if (command === 'get_local_game_project_revision') {
return { revision: 7 }; return { revision: 7 };
} }
return { asset, committedProjectRevision: 8 }; 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('新增标签,多个用逗号分隔'); const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔');
fireEvent.change(field, { target: { value: '未回车的尾巴' } }); 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 () => { test('surfaces the native rejection without reporting a save', async () => {
@@ -293,6 +293,23 @@ function renderReplacementWorkbench(options: RenderOptions = {}) {
if (command === 'get_local_game_project_revision') { if (command === 'get_local_game_project_revision') {
return { revision: EXPECTED_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 ( if (
command === 'read_local_project_version_resource_replacement_candidates' command === 'read_local_project_version_resource_replacement_candidates'
) { ) {
@@ -383,6 +400,13 @@ async function selectCardAndOpenToolbar(label: string) {
return screen.findByRole('toolbar', { name: '图片工具栏' }); 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。 * 资源卡预览用的 IntersectionObserver stub。
* *
@@ -896,4 +920,70 @@ describe('版本级资源替换', () => {
within(toolbar).getByRole('button', { name: '信息' }), within(toolbar).getByRole('button', { name: '信息' }),
).not.toBeNull(); ).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();
});
}); });