import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleAnchorPack } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { CreatePuzzleAgentSessionRequest, PuzzleAgentSessionSnapshot, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import type { MiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress'; import { createFailedMiniGameDraftGenerationStateForRestoredDraft, createMiniGameDraftGenerationStateForRestoredDraft, createPuzzleDraftGenerationStateFromPayload, isMiniGameDraftGenerating, isMiniGameDraftReady, mergeMatch3DGeneratedAssetsIntoGenerationState, mergePuzzleSessionProgressIntoGenerationState, rebaseMiniGameDraftBackgroundCompileTaskForDisplay, rebaseMiniGameDraftGenerationStateForDisplay, resolveFinishedMiniGameDraftGenerationState, resolvePuzzlePhaseFromSessionProgress, } from './platformMiniGameDraftGenerationStateModel'; const NOW = Date.parse('2026-06-04T03:00:00.000Z'); const SESSION_UPDATED_AT = '2026-06-01T10:00:00.000Z'; const SESSION_UPDATED_AT_MS = Date.parse(SESSION_UPDATED_AT); function buildAnchorPack(): PuzzleAnchorPack { const item = { key: 'theme', label: '主题', value: '星桥机关', status: 'confirmed' as const, }; return { themePromise: item, visualSubject: item, visualMood: item, compositionHooks: item, tagsAndForbidden: item, }; } function buildPuzzleSession( overrides: Partial = {}, ): PuzzleAgentSessionSnapshot { const anchorPack = buildAnchorPack(); return { sessionId: 'puzzle-session-1', seedText: '星桥', currentTurn: 1, progressPercent: 90, stage: 'draft_ready', anchorPack, draft: { workTitle: '星桥拼图', workDescription: '修复星桥机关。', levelName: '星桥机关', summary: '把星桥碎片拼回原位。', themeTags: ['星桥'], forbiddenDirectives: [], creatorIntent: null, anchorPack, candidates: [], selectedCandidateId: null, coverImageSrc: null, coverAssetId: null, generationStatus: 'generating', levels: [], }, messages: [], lastAssistantReply: null, publishedProfileId: null, suggestedActions: [], resultPreview: null, updatedAt: SESSION_UPDATED_AT, ...overrides, }; } function buildState( overrides: Partial = {}, ): MiniGameDraftGenerationState { return { kind: 'puzzle', phase: 'compile', startedAtMs: 100, completedAssetCount: 0, totalAssetCount: 0, error: null, metadata: { puzzleAiRedraw: true, puzzleActivePhaseId: 'compile', puzzleActiveStepStartedAtMs: 200, puzzleProgressPercent: 20, }, ...overrides, }; } function buildMatch3DAsset( overrides: Partial = {}, ): Match3DGeneratedItemAsset { return { itemId: 'item-1', itemName: '红宝石', status: 'pending', ...overrides, }; } beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); }); afterEach(() => { vi.useRealTimers(); }); describe('platformMiniGameDraftGenerationStateModel', () => { test('creates restored generation state with metadata and explicit start time', () => { expect( createMiniGameDraftGenerationStateForRestoredDraft( 'match3d', { puzzleAiRedraw: false }, 123, ), ).toMatchObject({ kind: 'match3d', phase: 'match3d-work-title', startedAtMs: 123, metadata: { puzzleAiRedraw: false, }, }); }); test('creates failed restored state from backend updated time', () => { expect( createFailedMiniGameDraftGenerationStateForRestoredDraft( 'puzzle', SESSION_UPDATED_AT, '生成失败', { puzzleAiRedraw: true }, ), ).toMatchObject({ kind: 'puzzle', phase: 'failed', startedAtMs: SESSION_UPDATED_AT_MS, finishedAtMs: NOW, error: '生成失败', metadata: { puzzleAiRedraw: true, }, }); }); test('rebases finished state for display without changing other fields', () => { const state = buildState({ phase: 'ready', finishedAtMs: 300, completedAssetCount: 2, totalAssetCount: 3, }); expect(rebaseMiniGameDraftGenerationStateForDisplay(state)).toEqual({ ...state, finishedAtMs: undefined, }); expect( rebaseMiniGameDraftBackgroundCompileTaskForDisplay({ sessionId: 'task-1', generationState: state, }), ).toEqual({ sessionId: 'task-1', generationState: { ...state, finishedAtMs: undefined, }, }); }); test('creates puzzle generation state from payload and compiled session', () => { const payload: CreatePuzzleAgentSessionRequest = { seedText: '星桥', aiRedraw: false, }; expect(createPuzzleDraftGenerationStateFromPayload(payload)).toMatchObject({ kind: 'puzzle', phase: 'compile', startedAtMs: NOW, metadata: { puzzleAiRedraw: false, puzzleActivePhaseId: undefined, puzzleActiveStepStartedAtMs: undefined, puzzleProgressPercent: undefined, }, }); expect( createPuzzleDraftGenerationStateFromPayload(payload, buildPuzzleSession()), ).toMatchObject({ kind: 'puzzle', phase: 'compile', startedAtMs: SESSION_UPDATED_AT_MS, metadata: { puzzleAiRedraw: false, puzzleActivePhaseId: 'compile', puzzleActiveStepStartedAtMs: NOW, puzzleProgressPercent: 90, }, }); }); test('resolves puzzle phase from backend progress thresholds', () => { const state = buildState(); expect( resolvePuzzlePhaseFromSessionProgress( state, buildPuzzleSession({ progressPercent: 96 }), ), ).toBe('puzzle-select-image'); expect( resolvePuzzlePhaseFromSessionProgress( state, buildPuzzleSession({ progressPercent: 94 }), ), ).toBe('puzzle-ui-assets'); expect( resolvePuzzlePhaseFromSessionProgress( buildState({ metadata: { puzzleAiRedraw: false } }), buildPuzzleSession({ progressPercent: 88 }), ), ).toBe('puzzle-level-scene'); expect( resolvePuzzlePhaseFromSessionProgress( state, buildPuzzleSession({ progressPercent: 88 }), ), ).toBe('puzzle-cover-image'); expect( resolvePuzzlePhaseFromSessionProgress( state, buildPuzzleSession({ progressPercent: 20 }), ), ).toBe('compile'); }); test('merges compiled puzzle session progress into generation state', () => { expect( mergePuzzleSessionProgressIntoGenerationState( buildState({ metadata: { puzzleAiRedraw: false, puzzleActivePhaseId: 'compile', puzzleActiveStepStartedAtMs: 200, puzzleProgressPercent: 20, }, }), buildPuzzleSession({ progressPercent: 90 }), ), ).toMatchObject({ metadata: { puzzleAiRedraw: false, puzzleActivePhaseId: 'puzzle-level-scene', puzzleActiveStepStartedAtMs: SESSION_UPDATED_AT_MS, puzzleProgressPercent: 90, }, }); expect( mergePuzzleSessionProgressIntoGenerationState( buildState(), buildPuzzleSession({ draft: { ...buildPuzzleSession().draft!, formDraft: { pictureDescription: '星桥', }, }, }), ).metadata, ).toMatchObject({ puzzleActivePhaseId: 'compile', puzzleActiveStepStartedAtMs: 200, puzzleProgressPercent: 20, }); }); test('merges match3d generated assets into active generation state', () => { const state = buildState({ kind: 'match3d', phase: 'match3d-material-sheet', completedAssetCount: 0, totalAssetCount: 0, error: '旧错误', }); expect( mergeMatch3DGeneratedAssetsIntoGenerationState(state, [ buildMatch3DAsset({ itemId: 'item-with-view', imageViews: [ { viewId: 'front', viewIndex: 0, imageObjectKey: 'objects/front.png', }, ], }), buildMatch3DAsset({ itemId: 'item-with-src', imageSrc: '/generated/item.png', }), buildMatch3DAsset({ itemId: 'item-with-error', error: '切图失败', }), ]), ).toMatchObject({ phase: 'match3d-generate-views', completedAssetCount: 2, totalAssetCount: 5, error: '切图失败', }); }); test('keeps match3d generated asset merge away from finished states', () => { const readyState = buildState({ kind: 'match3d', phase: 'ready', completedAssetCount: 5, totalAssetCount: 5, }); const failedState = buildState({ kind: 'match3d', phase: 'failed', error: '已失败', }); expect( mergeMatch3DGeneratedAssetsIntoGenerationState(readyState, [ buildMatch3DAsset({ imageSrc: '/generated/new.png' }), ]), ).toBe(readyState); expect( mergeMatch3DGeneratedAssetsIntoGenerationState(failedState, [ buildMatch3DAsset({ imageSrc: '/generated/new.png' }), ]), ).toBe(failedState); expect( mergeMatch3DGeneratedAssetsIntoGenerationState(null, [ buildMatch3DAsset({ imageSrc: '/generated/new.png' }), ]), ).toBeNull(); }); test('finishes generation state and resolves ready/generating flags', () => { const failedState = resolveFinishedMiniGameDraftGenerationState( buildState({ error: '旧错误' }), 'failed', { completedAssetCount: 1, totalAssetCount: 2, }, ); expect(failedState).toMatchObject({ phase: 'failed', finishedAtMs: NOW, error: '旧错误', completedAssetCount: 1, totalAssetCount: 2, }); expect(isMiniGameDraftReady(failedState)).toBe(false); expect(isMiniGameDraftGenerating(failedState)).toBe(false); expect(isMiniGameDraftReady({ ...failedState, phase: 'ready' })).toBe(true); expect(isMiniGameDraftGenerating(buildState())).toBe(true); expect(isMiniGameDraftGenerating(null)).toBe(false); }); });