import { describe, expect, test } from 'vitest'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf'; import { buildCreationWorkShelfRuntimeState, buildPendingPuzzleWorks, buildPuzzleResultProfileId, buildPuzzleResultWorkId, collectVisibleDraftNoticeKeys, countUnreadDraftGenerationUpdates, createPendingDraftShelfState, type DraftGenerationNoticeMap, getGenerationNoticeShelfKeys, hasUnreadDraftGenerationUpdates, mergeBigFishWorkSummary, mergePuzzleWorkSummary, resolveBigFishDraftOpenIntent, resolveJumpHopDraftOpenIntent, resolveMatch3DDraftOpenIntent, resolvePuzzleDraftOpenIntent, resolveSquareHoleDraftOpenIntent, resolveVisualNovelDraftOpenIntent, resolveWoodenFishDraftOpenIntent, } from './platformDraftGenerationShelfModel'; describe('platformDraftGenerationShelfModel', () => { test('resolvePuzzleDraftOpenIntent sends published puzzle without session to detail', () => { expect( resolvePuzzleDraftOpenIntent({ item: buildPuzzleWork({ sourceSessionId: null, publicationStatus: 'published', }), notices: {}, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'open-published-detail', }); }); test('resolvePuzzleDraftOpenIntent restores failed puzzle generation with notice copy', () => { expect( resolvePuzzleDraftOpenIntent({ item: buildPuzzleWork(), notices: { 'puzzle:puzzle-session-base': { status: 'failed', seen: false, message: '首图生成失败。', }, }, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'failed-generation', source: 'restored', errorMessage: '首图生成失败。', }); }); test('resolvePuzzleDraftOpenIntent prefers active generation before restoring draft', () => { expect( resolvePuzzleDraftOpenIntent({ item: buildPuzzleWork(), notices: {}, generation: emptyGenerationFacts({ activeSessionId: 'puzzle-session-base', hasActiveGenerationRunning: true, }), }), ).toMatchObject({ type: 'active-generation', }); }); test('resolvePuzzleDraftOpenIntent does not lock a puzzle draft that already has a cover', () => { expect( resolvePuzzleDraftOpenIntent({ item: buildPuzzleWork({ coverImageSrc: '/media/puzzle-cover.png', }), notices: { 'puzzle:puzzle-session-base': { status: 'generating', seen: false, }, }, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'restore-draft', }); }); test('resolvePuzzleDraftOpenIntent ignores stale generating notice after puzzle draft is ready', () => { expect( resolvePuzzleDraftOpenIntent({ item: buildPuzzleWork({ generationStatus: 'ready', coverImageSrc: '/generated-puzzle-assets/session/cover.png', levels: [ { levelId: 'level-1', levelName: '金色数字舞台', pictureDescription: '123456', pictureReference: null, candidates: [], selectedCandidateId: null, coverImageSrc: '/generated-puzzle-assets/session/cover.png', coverAssetId: 'asset-1', generationStatus: 'ready', }, ], }), notices: { 'puzzle:puzzle-session-base': { status: 'generating', seen: false, }, }, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'restore-draft', }); }); test('resolvePuzzleDraftOpenIntent restores generating page from notice even before persisted status catches up', () => { expect( resolvePuzzleDraftOpenIntent({ item: buildPuzzleWork({ generationStatus: 'idle', }), notices: { 'puzzle:puzzle-session-base': { status: 'generating', seen: false, }, }, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'restore-generating', }); }); test('resolveMatch3DDraftOpenIntent opens published work detail unless forced into draft', () => { const item = buildMatch3DWork({ publicationStatus: 'published', }); expect( resolveMatch3DDraftOpenIntent({ item, notices: {}, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'open-published-detail', }); expect( resolveMatch3DDraftOpenIntent({ item, notices: {}, forceDraft: true, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'restore-draft', }); }); test('resolveMatch3DDraftOpenIntent starts ready unread draft before failure fallback', () => { expect( resolveMatch3DDraftOpenIntent({ item: buildMatch3DWork(), notices: { 'match3d:match3d-session-base': { status: 'ready', seen: false, }, }, generation: emptyGenerationFacts({ hasBackgroundGenerationFailure: true, }), }), ).toMatchObject({ type: 'ready-unread', }); }); test('resolveMatch3DDraftOpenIntent restores persisted generating draft', () => { expect( resolveMatch3DDraftOpenIntent({ item: buildMatch3DWork({ generationStatus: 'generating', }), notices: {}, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'restore-generating', }); }); test('resolveBigFishDraftOpenIntent reopens active generating session before restoring draft', () => { expect( resolveBigFishDraftOpenIntent({ item: buildBigFishWork(), activeSessionId: 'big-fish-session-base', hasActiveGenerationRunning: true, }), ).toMatchObject({ type: 'active-generation', sourceSessionId: 'big-fish-session-base', }); expect( resolveBigFishDraftOpenIntent({ item: buildBigFishWork(), activeSessionId: 'other-session', hasActiveGenerationRunning: true, }), ).toMatchObject({ type: 'restore-draft', sourceSessionId: 'big-fish-session-base', }); }); test('resolveSquareHoleDraftOpenIntent handles published, missing, active and restore states', () => { expect( resolveSquareHoleDraftOpenIntent({ item: buildSquareHoleWork({ publicationStatus: 'published' }), activeSessionId: null, hasActiveGenerationRunning: false, isGenerationReady: false, }), ).toMatchObject({ type: 'open-published-detail', }); expect( resolveSquareHoleDraftOpenIntent({ item: buildSquareHoleWork({ sourceSessionId: null }), forceDraft: true, activeSessionId: null, hasActiveGenerationRunning: false, isGenerationReady: false, }), ).toMatchObject({ type: 'missing-session', }); expect( resolveSquareHoleDraftOpenIntent({ item: buildSquareHoleWork(), activeSessionId: 'square-hole-session-base', hasActiveGenerationRunning: true, isGenerationReady: false, }), ).toMatchObject({ type: 'active-generation', sourceSessionId: 'square-hole-session-base', }); expect( resolveSquareHoleDraftOpenIntent({ item: buildSquareHoleWork(), activeSessionId: 'other-session', hasActiveGenerationRunning: false, isGenerationReady: false, }), ).toMatchObject({ type: 'restore-draft', shouldClearGenerationState: true, }); }); test('resolveVisualNovelDraftOpenIntent handles published, active, current result and load detail states', () => { expect( resolveVisualNovelDraftOpenIntent({ item: buildVisualNovelWork({ publishStatus: 'published' }), activeSessionId: null, hasActiveGenerationRunning: false, hasActiveSessionDraft: false, }), ).toMatchObject({ type: 'open-published-detail', }); expect( resolveVisualNovelDraftOpenIntent({ item: buildVisualNovelWork(), forceDraft: true, activeSessionId: 'visual-novel-profile-base', hasActiveGenerationRunning: true, hasActiveSessionDraft: false, }), ).toMatchObject({ type: 'active-generation', profileId: 'visual-novel-profile-base', }); expect( resolveVisualNovelDraftOpenIntent({ item: buildVisualNovelWork(), forceDraft: true, activeSessionId: 'visual-novel-profile-base', hasActiveGenerationRunning: false, hasActiveSessionDraft: true, }), ).toMatchObject({ type: 'current-result', profileId: 'visual-novel-profile-base', }); expect( resolveVisualNovelDraftOpenIntent({ item: buildVisualNovelWork(), forceDraft: true, activeSessionId: 'other-profile', hasActiveGenerationRunning: false, hasActiveSessionDraft: false, }), ).toMatchObject({ type: 'load-detail', profileId: 'visual-novel-profile-base', }); }); test('resolveJumpHopDraftOpenIntent handles published, failed current generation, generating and detail states', () => { expect( resolveJumpHopDraftOpenIntent({ item: buildJumpHopWork({ publicationStatus: 'published' }), notices: {}, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'open-published-detail', }); expect( resolveJumpHopDraftOpenIntent({ item: buildJumpHopWork(), notices: { 'jump-hop:jump-hop-session-base': { status: 'failed', seen: false, }, }, generation: emptyGenerationFacts({ activeSessionId: 'jump-hop-session-base', hasActiveGenerationFailure: true, }), }), ).toMatchObject({ type: 'active-failed-generation', }); expect( resolveJumpHopDraftOpenIntent({ item: buildJumpHopWork({ generationStatus: 'generating' }), notices: {}, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'restore-generating', }); expect( resolveJumpHopDraftOpenIntent({ item: buildJumpHopWork({ generationStatus: 'generating' }), notices: { 'jump-hop:jump-hop-session-base': { status: 'failed', seen: false, }, }, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'load-detail', }); expect( resolveJumpHopDraftOpenIntent({ item: buildJumpHopWork({ sourceSessionId: null }), notices: { 'jump-hop:jump-hop-work-base': { status: 'failed', seen: false, }, }, generation: emptyGenerationFacts({ activeSessionId: null, hasActiveGenerationFailure: true, }), }), ).toMatchObject({ type: 'load-detail', }); }); test('resolveWoodenFishDraftOpenIntent uses profile fallback and failure fallback stage', () => { expect( resolveWoodenFishDraftOpenIntent({ item: buildWoodenFishWork({ sourceSessionId: null, generationStatus: 'generating', }), notices: { 'wooden-fish:wooden-fish-profile-base': { status: 'failed', seen: false, }, }, generation: emptyGenerationFacts({ activeSessionId: 'wooden-fish-profile-base', hasActiveGenerationFailure: true, }), }), ).toMatchObject({ type: 'active-failed-generation', }); expect( resolveWoodenFishDraftOpenIntent({ item: buildWoodenFishWork({ generationStatus: 'generating' }), notices: {}, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'restore-generating', }); expect( resolveWoodenFishDraftOpenIntent({ item: buildWoodenFishWork(), notices: { 'wooden-fish:wooden-fish-session-base': { status: 'failed', seen: false, }, }, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'load-detail', failureFallbackStage: 'wooden-fish-workspace', }); expect( resolveWoodenFishDraftOpenIntent({ item: buildWoodenFishWork(), notices: {}, generation: emptyGenerationFacts(), }), ).toMatchObject({ type: 'load-detail', failureFallbackStage: 'wooden-fish-generating', }); }); test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => { const pending = buildPendingPuzzleWorks( { 'puzzle-session-ocean': createPendingDraftShelfState( 'failed', false, '2026-06-03T08:00:00.000Z', ), }, [], ); expect(pending).toHaveLength(1); expect(pending[0]).toMatchObject({ workId: 'puzzle-work-ocean', profileId: 'puzzle-profile-ocean', sourceSessionId: 'puzzle-session-ocean', workTitle: '拼图草稿', summary: '拼图草稿生成失败,可重新打开处理。', generationStatus: 'failed', }); }); test('buildPendingPuzzleWorks skips pending item when backend shelf already has the session', () => { const pending = buildPendingPuzzleWorks( { 'puzzle-session-ocean': createPendingDraftShelfState( 'generating', false, '2026-06-03T08:00:00.000Z', ), }, [buildPuzzleWork({ sourceSessionId: 'puzzle-session-ocean' })], ); expect(pending).toEqual([]); }); test('mergePuzzleWorkSummary only replaces the matching profile', () => { const current = buildPuzzleWork({ profileId: 'puzzle-profile-1', workTitle: '旧拼图', }); const updated = buildPuzzleWork({ profileId: 'puzzle-profile-1', workTitle: '新拼图', }); const other = buildPuzzleWork({ profileId: 'puzzle-profile-2', workTitle: '别的拼图', }); expect(mergePuzzleWorkSummary(current, updated)).toBe(updated); expect(mergePuzzleWorkSummary(current, other)).toBe(current); }); test('mergeBigFishWorkSummary only replaces the matching source session', () => { const current = buildBigFishWork({ sourceSessionId: 'big-fish-session-1', title: '旧大鱼', }); const updated = buildBigFishWork({ sourceSessionId: 'big-fish-session-1', title: '新大鱼', }); const other = buildBigFishWork({ sourceSessionId: 'big-fish-session-2', title: '别的大鱼', }); expect(mergeBigFishWorkSummary(current, updated)).toBe(updated); expect(mergeBigFishWorkSummary(current, other)).toBe(current); }); test('buildCreationWorkShelfRuntimeState lets failure notice override persisted generating puzzle copy', () => { const [item] = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [ buildPuzzleWork({ workId: 'puzzle-work-empty', profileId: 'puzzle-profile-empty', sourceSessionId: 'puzzle-session-empty', workTitle: '', workDescription: '', levelName: '', summary: '正在生成拼图草稿。', generationStatus: 'generating', }), ], }); expect(item).toBeTruthy(); const noticeKeys = getGenerationNoticeShelfKeys(item!); const notices = Object.fromEntries( noticeKeys.map((key) => [ key, { status: 'failed', seen: false }, ]), ) as DraftGenerationNoticeMap; const state = buildCreationWorkShelfRuntimeState({ item: item!, notices, pendingShelfItems: { puzzle: { 'puzzle-session-empty': createPendingDraftShelfState( 'failed', false, '2026-06-03T08:00:00.000Z', { summary: '图片生成超时,可重新打开处理。' }, ), }, }, }); expect(state).toMatchObject({ isGenerating: false, hasGenerationFailure: true, generationFailureSummary: '拼图草稿生成失败,可重新打开处理。', hasUnreadUpdate: false, suppressPersistedGenerating: true, titleOverride: '拼图草稿', summaryOverride: '图片生成超时,可重新打开处理。', }); }); test('collectVisibleDraftNoticeKeys and hasUnreadDraftGenerationUpdates share unread dot rule', () => { const puzzle = buildPuzzleWork({ workId: 'puzzle-work-ocean', profileId: 'puzzle-profile-ocean', sourceSessionId: 'puzzle-session-ocean', }); const visibleKeys = collectVisibleDraftNoticeKeys({ rpgItems: [], bigFishItems: [], jumpHopItems: [], woodenFishItems: [], match3dItems: [], squareHoleItems: [], puzzleItems: [puzzle], visualNovelItems: [], barkBattleItems: [], babyObjectMatchItems: [], }); expect(visibleKeys).toContain('puzzle:puzzle-work-ocean'); expect(visibleKeys).toContain('puzzle:puzzle-profile-ocean'); expect(visibleKeys).toContain('puzzle:puzzle-session-ocean'); expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe( 'puzzle-work-ocean', ); expect(buildPuzzleResultProfileId('puzzle-session-ocean')).toBe( 'puzzle-profile-ocean', ); expect( hasUnreadDraftGenerationUpdates( { 'puzzle:puzzle-profile-ocean': { status: 'ready', seen: false, }, }, visibleKeys, ), ).toBe(true); expect( hasUnreadDraftGenerationUpdates( { 'puzzle:puzzle-profile-ocean': { status: 'ready', seen: true, }, }, visibleKeys, ), ).toBe(false); expect( countUnreadDraftGenerationUpdates( { 'puzzle:puzzle-work-ocean': { status: 'ready', seen: false, }, 'puzzle:puzzle-profile-ocean': { status: 'ready', seen: false, }, 'puzzle:puzzle-session-ocean': { status: 'ready', seen: false, }, }, { rpgItems: [], bigFishItems: [], jumpHopItems: [], woodenFishItems: [], match3dItems: [], squareHoleItems: [], puzzleItems: [puzzle], visualNovelItems: [], barkBattleItems: [], babyObjectMatchItems: [], }, ), ).toBe(1); expect( countUnreadDraftGenerationUpdates( { 'puzzle:puzzle-profile-ocean': { status: 'failed', seen: false, }, 'puzzle:puzzle-session-other': { status: 'ready', seen: false, }, }, { rpgItems: [], bigFishItems: [], jumpHopItems: [], woodenFishItems: [], match3dItems: [], squareHoleItems: [], puzzleItems: [puzzle], visualNovelItems: [], barkBattleItems: [], babyObjectMatchItems: [], }, ), ).toBe(0); }); }); function emptyGenerationFacts( overrides: Partial[0]['generation']> = {}, ): Parameters[0]['generation'] { return { activeSessionId: null, hasActiveGenerationFailure: false, hasActiveGenerationRunning: false, hasBackgroundGenerationFailure: false, hasBackgroundGenerationRunning: false, ...overrides, }; } function buildPuzzleWork( overrides: Partial = {}, ): PuzzleWorkSummary { return { workId: 'puzzle-work-base', profileId: 'puzzle-profile-base', ownerUserId: 'user-1', sourceSessionId: 'puzzle-session-base', authorDisplayName: '测试作者', workTitle: '潮雾拼图', workDescription: '潮雾港口拼图。', levelName: '潮雾拼图', summary: '潮雾港口拼图。', themeTags: [], coverImageSrc: null, coverAssetId: null, publicationStatus: 'draft', updatedAt: '2026-06-03T08:00:00.000Z', publishedAt: null, playCount: 0, remixCount: 0, likeCount: 0, publishReady: false, levels: [], ...overrides, }; } function buildMatch3DWork( overrides: Partial = {}, ): Match3DWorkSummary { return { workId: 'match3d-work-base', profileId: 'match3d-profile-base', ownerUserId: 'user-1', sourceSessionId: 'match3d-session-base', gameName: '潮雾抓大鹅', themeText: '潮雾港口', summary: '潮雾港口抓大鹅。', tags: [], coverImageSrc: null, referenceImageSrc: null, clearCount: 0, difficulty: 1, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-06-03T08:00:00.000Z', publishedAt: null, publishReady: false, generationStatus: 'ready', generatedItemAssets: [], ...overrides, }; } function buildBigFishWork( overrides: Partial = {}, ): BigFishWorkSummary { return { workId: 'big-fish-work-base', sourceSessionId: 'big-fish-session-base', ownerUserId: 'user-1', authorDisplayName: '测试作者', title: '潮雾大鱼', subtitle: '潮雾港口', summary: '潮雾港口大鱼吃小鱼。', coverImageSrc: null, status: 'draft', updatedAt: '2026-06-03T08:00:00.000Z', publishedAt: null, playCount: 0, remixCount: 0, likeCount: 0, publishReady: false, levelCount: 1, levelMainImageReadyCount: 0, levelMotionReadyCount: 0, backgroundReady: false, ...overrides, }; } function buildSquareHoleWork( overrides: Partial = {}, ): SquareHoleWorkSummary { return { workId: 'square-hole-work-base', profileId: 'square-hole-profile-base', ownerUserId: 'user-1', sourceSessionId: 'square-hole-session-base', gameName: '潮雾方洞', themeText: '潮雾港口', twistRule: '避开雾门', summary: '潮雾港口方洞挑战。', tags: [], coverImageSrc: null, backgroundPrompt: '潮雾港口', backgroundImageSrc: null, shapeOptions: [], holeOptions: [], shapeCount: 1, difficulty: 1, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-06-03T08:00:00.000Z', publishedAt: null, publishReady: false, ...overrides, }; } function buildVisualNovelWork( overrides: Partial = {}, ): VisualNovelWorkSummary { return { runtimeKind: 'visual-novel', profileId: 'visual-novel-profile-base', ownerUserId: 'user-1', title: '潮雾视觉小说', description: '潮雾港口视觉小说。', coverImageSrc: null, tags: [], publishStatus: 'draft', publishReady: false, playCount: 0, updatedAt: '2026-06-03T08:00:00.000Z', publishedAt: null, ...overrides, }; } function buildJumpHopWork( overrides: Partial = {}, ): JumpHopWorkSummaryResponse { return { runtimeKind: 'jump-hop', workId: 'jump-hop-work-base', profileId: 'jump-hop-profile-base', ownerUserId: 'user-1', sourceSessionId: 'jump-hop-session-base', themeText: '潮雾港口', workTitle: '潮雾跳一跳', workDescription: '潮雾港口跳一跳。', themeTags: [], difficulty: 'standard', stylePreset: 'minimal-blocks', coverImageSrc: null, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-06-03T08:00:00.000Z', publishedAt: null, publishReady: false, generationStatus: 'ready', ...overrides, }; } function buildWoodenFishWork( overrides: Partial = {}, ): WoodenFishWorkSummaryResponse { return { runtimeKind: 'wooden-fish', workId: 'wooden-fish-work-base', profileId: 'wooden-fish-profile-base', ownerUserId: 'user-1', sourceSessionId: 'wooden-fish-session-base', workTitle: '潮雾敲木鱼', workDescription: '潮雾港口敲木鱼。', themeTags: ['敲木鱼'], coverImageSrc: null, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-06-03T08:00:00.000Z', publishedAt: null, publishReady: false, generationStatus: 'ready', ...overrides, }; }