修复「编辑素材标签」保存后关窗:点「添加」成功后面板保持打开,可连续添加
- 定位关窗来源:面板的 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:
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user