This commit is contained in:
2026-05-13 00:28:07 +08:00
parent ef4f91a75e
commit 01c5ab985a
101 changed files with 10635 additions and 2292 deletions

View File

@@ -97,7 +97,10 @@ import {
dragLocalPuzzlePiece,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import { listPuzzleWorks } from '../../services/puzzle-works';
import {
listPuzzleWorks,
updatePuzzleWork,
} from '../../services/puzzle-works';
import {
createRpgCreationSession,
executeRpgCreationAction,
@@ -376,6 +379,7 @@ vi.mock('../../services/creationEntryConfigService', () => ({
vi.mock('../../services/puzzle-works', () => ({
listPuzzleWorks: vi.fn(),
updatePuzzleWork: vi.fn(),
}));
vi.mock('../../services/puzzle-gallery', () => ({
@@ -437,6 +441,10 @@ vi.mock('../../services/match3d-works', () => ({
updateMatch3DGeneratedItemAssets: vi.fn(),
}));
vi.mock('../../services/match3dGeneratedModelCache', () => ({
preloadMatch3DGeneratedModelAssets: vi.fn(() => Promise.resolve()),
}));
vi.mock('../../services/match3d-runtime', () => ({
clickMatch3DItem: vi.fn(),
finishMatch3DTimeUp: vi.fn(),
@@ -555,6 +563,7 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
PuzzleResultView: ({
isBusy,
onExecuteAction,
onStartTestRun,
session,
onBack,
}: {
@@ -564,7 +573,8 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
levelId?: string;
promptText?: string;
}) => void;
session: { draft?: { levelName: string } | null };
onStartTestRun?: (draft: PuzzleResultDraft) => void;
session: { draft?: PuzzleResultDraft | null };
onBack: () => void;
}) => (
<div className="puzzle-result-view-mock">
@@ -585,6 +595,17 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
>
</button>
<button
type="button"
disabled={!session.draft}
onClick={() => {
if (session.draft) {
onStartTestRun?.(session.draft);
}
}}
>
</button>
<button type="button" disabled={isBusy}>
</button>
@@ -660,6 +681,31 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
),
}));
vi.mock('../match3d-result/Match3DResultView', () => ({
Match3DResultView: ({
draft,
onBack,
onStartTestRun,
profile,
}: {
draft?: { gameName?: string | null } | null;
onBack: () => void;
onStartTestRun: (profile: Match3DWorkSummary) => void;
profile: Match3DWorkSummary;
}) => (
<div className="match3d-result-view-mock">
<div></div>
<div>{draft?.gameName ?? profile.gameName}</div>
<button type="button" onClick={() => onStartTestRun(profile)}>
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
Match3DAgentWorkspace: ({
session,
@@ -672,6 +718,7 @@ vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
referenceImageSrc: string | null;
clearCount: number;
difficulty: number;
generateClickSound?: boolean;
}) => void;
}) => (
<div className="match3d-agent-workspace-mock">
@@ -2246,6 +2293,31 @@ beforeEach(() => {
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [],
});
vi.mocked(updatePuzzleWork).mockImplementation(async (profileId, payload) => ({
item: {
workId: `puzzle-work-${profileId}`,
profileId,
ownerUserId: mockAuthUser.id,
sourceSessionId: null,
authorDisplayName: mockAuthUser.displayName,
workTitle: payload.workTitle ?? payload.levelName,
workDescription: payload.workDescription ?? payload.summary,
levelName: payload.levelName,
summary: payload.summary,
themeTags: payload.themeTags,
coverImageSrc: payload.coverImageSrc ?? null,
coverAssetId: payload.coverAssetId ?? null,
publicationStatus: 'draft',
updatedAt: '2026-05-12T10:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levels: payload.levels,
anchorPack: buildPuzzleAnchorPack(),
},
}));
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [],
});
@@ -2566,7 +2638,9 @@ test('running match3d form generation can return to draft tab and reopen progres
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(screen.getByRole('button', { name: '生成抓大鹅草稿' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
@@ -2585,6 +2659,280 @@ test('running match3d form generation can return to draft tab and reopen progres
});
});
test('match3d result trial passes generated models into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['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 match3dDraftWork: Match3DWorkSummary = {
workId: 'match3d-work-draft-1',
profileId: 'match3d-profile-draft-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-draft-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-01T10:30:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets,
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [match3dDraftWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-draft-1',
draft: {
profileId: 'match3d-profile-draft-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
}),
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dDraftWork,
});
vi.mocked(startMatch3DRun).mockResolvedValue({
run: buildMockMatch3DRun(match3dDraftWork.profileId),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: //u }),
);
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-draft-1');
});
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
});
test('match3d draft generation auto starts trial and runtime back opens draft result', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['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 generatedSession = buildMockMatch3DAgentSession({
stage: 'draft_ready',
draft: {
profileId: 'match3d-profile-auto-1',
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅', '试玩'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets,
},
});
const generatedProfile: Match3DWorkSummary = {
workId: 'match3d-work-auto-1',
profileId: 'match3d-profile-auto-1',
ownerUserId: 'user-1',
sourceSessionId: generatedSession.sessionId,
gameName: '自动试玩抓大鹅',
themeText: '水果',
summary: '',
tags: ['水果', '抓大鹅', '试玩'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-12T10:00:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets,
};
vi.mocked(match3dCreationClient.executeAction).mockResolvedValueOnce({
session: generatedSession,
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: generatedProfile,
});
vi.mocked(startMatch3DRun).mockResolvedValue({
run: buildMockMatch3DRun(generatedProfile.profileId),
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText(//u)).toBeTruthy();
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-auto-1');
expect(
await screen.findByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
expect(screen.getByText('自动试玩抓大鹅')).toBeTruthy();
});
test('puzzle draft generation auto starts trial and runtime back opens draft result', async () => {
const user = userEvent.setup();
const generatedDraft: PuzzleResultDraft = {
workTitle: '自动试玩拼图',
workDescription: '生成完成后直接试玩。',
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '拼图'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: buildPuzzleAnchorPack(),
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/auto-candidate.png',
assetId: 'asset-1',
prompt: '雨夜猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/auto-candidate.png',
coverAssetId: 'asset-1',
generationStatus: 'ready',
levels: [
{
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
pictureDescription: '屋檐下的猫与暖灯街角。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/auto-candidate.png',
assetId: 'asset-1',
prompt: '雨夜猫街',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/auto-candidate.png',
coverAssetId: 'asset-1',
generationStatus: 'ready',
},
],
};
const generatedSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-auto-1',
seedText: '屋檐下的猫与暖灯街角。',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack: buildPuzzleAnchorPack(),
draft: generatedDraft,
messages: [],
lastAssistantReply: '拼图草稿已经生成。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: {
draft: generatedDraft,
publishReady: true,
blockers: [],
qualityFindings: [],
},
updatedAt: '2026-05-12T10:00:00.000Z',
};
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'compile-puzzle-auto-1',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: generatedSession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('雨夜猫街')).toBeTruthy();
expect(updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-auto-1',
expect.objectContaining({
levelName: '雨夜猫街',
coverImageSrc: '/puzzle/auto-candidate.png',
}),
);
expect(screen.queryByText('拼图结果页')).toBeNull();
await user.click(screen.getByRole('button', { name: '返回上一页' }));
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
});
test('embedded puzzle form routes through requireAuth while logged out', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -3545,6 +3893,82 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
});
});
test('home recommendation Match3D runtime refetches detail when stale card only has image assets', async () => {
const match3dCard: Match3DWorkSummary = {
workId: 'match3d-work-card-image-only',
profileId: 'match3d-profile-card-image-only',
ownerUserId: 'user-2',
sourceSessionId: 'match3d-session-card-image-only',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '消除水果模型。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 3,
difficulty: 5,
publicationStatus: 'published',
playCount: 3,
updatedAt: '2026-04-25T10:30:00.000Z',
publishedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
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: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
};
const match3dDetail: Match3DWorkSummary = {
...match3dCard,
generatedItemAssets: [
{
...match3dCard.generatedItemAssets![0]!,
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',
},
],
};
vi.mocked(listMatch3DGallery).mockResolvedValue({
items: [match3dCard],
});
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
item: match3dDetail,
});
vi.mocked(startMatch3DRun).mockResolvedValue({
run: buildMockMatch3DRun(match3dCard.profileId),
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(getMatch3DWorkDetail).toHaveBeenCalledWith(
'match3d-profile-card-image-only',
);
});
await waitFor(() => {
expect(
screen.getByTestId('match3d-runtime-generated-model-count'),
).toHaveProperty('textContent', '1');
});
});
test('home recommendation surfaces start failure instead of staying in loading state', async () => {
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
@@ -3983,8 +4407,7 @@ test('published puzzle work card restores its source session for editing', async
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
});
test('first launch puzzle onboarding can be skipped from top right', async () => {
const user = userEvent.setup();
test('first launch hides puzzle onboarding by default', async () => {
window.localStorage.removeItem(
'genarrative.puzzle-onboarding.first-visit.v1',
);
@@ -4000,61 +4423,16 @@ test('first launch puzzle onboarding can be skipped from top right', async () =>
/>,
);
expect(await screen.findByText('待定待定待定')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '跳过' }));
await waitFor(() => {
expect(screen.queryByText('待定待定待定')).toBeNull();
});
expect(screen.queryByPlaceholderText('把你的梦讲给我听吧')).toBeNull();
expect(
window.localStorage.getItem('genarrative.puzzle-onboarding.first-visit.v1'),
).toBe('1');
).toBeNull();
expect(generatePuzzleOnboardingWork).not.toHaveBeenCalled();
});
test('first launch puzzle onboarding falls back to local run when generate route is missing', async () => {
const user = userEvent.setup();
window.localStorage.removeItem(
'genarrative.puzzle-onboarding.first-visit.v1',
);
vi.mocked(generatePuzzleOnboardingWork).mockRejectedValueOnce(
new ApiClientError({
message: '资源不存在',
status: 404,
code: 'NOT_FOUND',
}),
);
render(
<TestWrapper
authValue={createAuthValue({
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: () => {},
})}
/>,
);
await user.type(
await screen.findByPlaceholderText('把你的梦讲给我听吧'),
'我想飞上天',
);
await user.click(screen.getByRole('button', { name: '生成' }));
expect(
await screen.findByTestId('puzzle-board', undefined, { timeout: 3000 }),
).toBeTruthy();
expect(generatePuzzleOnboardingWork).toHaveBeenCalledWith({
promptText: '我想飞上天',
});
expect(screen.queryByText('资源不存在')).toBeNull();
expect(startPuzzleRun).not.toHaveBeenCalled();
expect(
window.localStorage.getItem('genarrative.puzzle-onboarding.first-visit.v1'),
).toBe('1');
});
test('formal puzzle runtime uses frontend move merge logic and backend leaderboard next level', async () => {
const user = userEvent.setup();
const clearedFirstLevel = buildClearedPuzzleRun({