1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-11 20:57:16 +08:00
81 changed files with 3410 additions and 132 deletions

View File

@@ -205,10 +205,16 @@ describe('Match3DResultView', () => {
];
const profile = createProfile({ generatedItemAssets });
const savedProfile = createProfile({ generatedItemAssets: [] });
const persistedProfile = createProfile({ generatedItemAssets });
const onStartTestRun = vi.fn();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: savedProfile,
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: persistedProfile,
});
render(
<Match3DResultView
@@ -221,10 +227,34 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).toHaveBeenCalledWith(
profile.profileId,
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
modelSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
status: 'model_ready',
}),
],
}),
);
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
profileId: profile.profileId,
generatedItemAssets,
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
modelSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
status: 'model_ready',
}),
],
}),
);
});
@@ -246,6 +276,105 @@ describe('Match3DResultView', () => {
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
test('发布前会先把历史草稿 3D 模型素材写回 profile', async () => {
const generatedItemAssets = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
modelFileName: 'strawberry.glb',
taskUuid: 'task-strawberry',
subscriptionKey: 'sub-strawberry',
status: 'model_ready',
error: null,
},
];
const profile = createProfile({
summary: '轻松消除水果',
coverImageSrc: 'data:image/png;base64,cover',
generatedItemAssets,
});
const savedProfile = createProfile({
...profile,
generatedItemAssets: [],
});
const onPublished = vi.fn();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: savedProfile,
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: profile,
});
vi.mocked(match3dWorksService.publishMatch3DWork).mockResolvedValue({
item: createProfile({
...profile,
publicationStatus: 'published',
generatedItemAssets: [],
}),
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onPublished={onPublished}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '发布' }));
await waitFor(() => {
expect(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).toHaveBeenCalledWith(
profile.profileId,
expect.objectContaining({
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
status: 'model_ready',
}),
],
}),
);
expect(match3dWorksService.publishMatch3DWork).toHaveBeenCalledWith(
profile.profileId,
);
expect(onPublished).toHaveBeenCalledWith(
expect.objectContaining({
publicationStatus: 'published',
generatedItemAssets: [
expect.objectContaining({
itemId: 'match3d-item-1',
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
}),
],
}),
);
});
const assetPersistCallOrder = vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mock.invocationCallOrder[0];
const publishCallOrder = vi.mocked(
match3dWorksService.publishMatch3DWork,
).mock.invocationCallOrder[0];
expect(assetPersistCallOrder).toBeDefined();
expect(publishCallOrder).toBeDefined();
expect(assetPersistCallOrder!).toBeLessThan(publishCallOrder!);
});
test('结果页提供多 Tab并能进入 Rodin 3D 素材详情', () => {
render(
<Match3DResultView
@@ -483,6 +612,11 @@ describe('Match3DResultView', () => {
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: createProfile({ generatedItemAssets: [] }),
});
vi.mocked(
match3dWorksService.updateMatch3DGeneratedItemAssets,
).mockResolvedValue({
item: createProfile({ generatedItemAssets: [profileAsset] }),
});
render(
<Match3DResultView

View File

@@ -178,6 +178,70 @@ function hasMatch3DGeneratedModelSource(asset: Match3DGeneratedItemAsset) {
return Boolean(asset.modelSrc?.trim() || asset.modelObjectKey?.trim());
}
function hasPersistableMatch3DGeneratedItemAsset(
asset: Match3DGeneratedItemAsset,
) {
return Boolean(
asset.imageSrc?.trim() ||
asset.imageObjectKey?.trim() ||
asset.modelSrc?.trim() ||
asset.modelObjectKey?.trim() ||
asset.taskUuid?.trim() ||
asset.subscriptionKey?.trim() ||
asset.backgroundMusic ||
asset.clickSound,
);
}
function getMatch3DGeneratedItemAssetPersistenceSignature(
asset: Match3DGeneratedItemAsset,
) {
return [
asset.itemId.trim(),
asset.itemName.trim(),
asset.imageSrc?.trim() ?? '',
asset.imageObjectKey?.trim() ?? '',
asset.modelSrc?.trim() ?? '',
asset.modelObjectKey?.trim() ?? '',
asset.modelFileName?.trim() ?? '',
asset.taskUuid?.trim() ?? '',
asset.subscriptionKey?.trim() ?? '',
asset.status.trim(),
asset.backgroundMusic?.audioSrc?.trim() ??
asset.backgroundMusic?.assetObjectId?.trim() ??
asset.backgroundMusic?.taskId?.trim() ??
'',
asset.clickSound?.audioSrc?.trim() ??
asset.clickSound?.assetObjectId?.trim() ??
asset.clickSound?.taskId?.trim() ??
'',
asset.error?.trim() ?? '',
].join('\u001f');
}
function shouldPersistGeneratedItemAssets(
currentAssets: readonly Match3DGeneratedItemAsset[],
savedAssets: readonly Match3DGeneratedItemAsset[] = [],
) {
if (currentAssets.length <= 0) {
return false;
}
if (currentAssets.length !== savedAssets.length) {
return true;
}
return currentAssets.some(
(asset, index) => {
const savedAsset = savedAssets[index];
return (
!savedAsset ||
getMatch3DGeneratedItemAssetPersistenceSignature(asset) !==
getMatch3DGeneratedItemAssetPersistenceSignature(savedAsset)
);
},
);
}
function mergeMatch3DGeneratedItemAsset(
base: Match3DGeneratedItemAsset,
override: Match3DGeneratedItemAsset,
@@ -636,6 +700,19 @@ function attachMatch3DGeneratedItemAssets(
};
}
function buildPersistableGeneratedItemAssets(
assetDrafts: Match3DRodinAssetDraft[],
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
if (generatedItemAssets.length <= 0) {
return [];
}
return createGeneratedAssetsFromDrafts(assetDrafts, generatedItemAssets).filter(
hasPersistableMatch3DGeneratedItemAsset,
);
}
function Match3DResultHeader({
autoSaveState,
isBusy,
@@ -1459,8 +1536,12 @@ export function Match3DResultView({
if (cancelled) {
return;
}
const playableItem = attachMatch3DGeneratedItemAssets(
item,
generatedItemAssets,
);
setAutoSaveState('saved');
onSaved?.(item);
onSaved?.(playableItem);
})
.catch((saveError) => {
if (cancelled) {
@@ -1477,7 +1558,7 @@ export function Match3DResultView({
cancelled = true;
window.clearTimeout(timer);
};
}, [editState, onSaved, profile]);
}, [editState, generatedItemAssets, onSaved, profile]);
const saveNow = async () => {
const payload = buildSavePayload(editState);
@@ -1489,10 +1570,34 @@ export function Match3DResultView({
setAutoSaveState('saving');
setLocalError(null);
const { item } = await updateMatch3DWork(profile.profileId, payload);
const playableItem = attachMatch3DGeneratedItemAssets(
item,
const currentGeneratedItemAssets = buildPersistableGeneratedItemAssets(
assetDrafts,
generatedItemAssets,
);
let playableItem = attachMatch3DGeneratedItemAssets(
item,
currentGeneratedItemAssets.length > 0
? currentGeneratedItemAssets
: generatedItemAssets,
);
if (
shouldPersistGeneratedItemAssets(
currentGeneratedItemAssets,
item.generatedItemAssets ?? [],
)
) {
// 中文注释:历史草稿可能只在页面 draft 中带 3D 模型;试玩和发布前必须先把当前可见素材写回 profile。
const { item: persistedItem } = await updateMatch3DGeneratedItemAssets(
profile.profileId,
{
generatedItemAssets: currentGeneratedItemAssets,
},
);
playableItem = attachMatch3DGeneratedItemAssets(
persistedItem,
currentGeneratedItemAssets,
);
}
setAutoSaveState('saved');
onSaved?.(playableItem);
return playableItem;
@@ -1829,7 +1934,12 @@ export function Match3DResultView({
const { item } = await publishMatch3DWork(
savedProfile?.profileId ?? profile.profileId,
);
onPublished?.(item);
onPublished?.(
attachMatch3DGeneratedItemAssets(
item,
savedProfile?.generatedItemAssets ?? generatedItemAssets,
),
);
setLocalError(null);
} catch (caughtError) {
setLocalError(