import { expect, test, vi } from 'vitest'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { buildCreationWorkShelfItems, getCreationWorkShelfItemTime, } from './creationWorkShelf'; test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [], visualNovelItems: [ { runtimeKind: 'visual-novel', profileId: 'vn-profile-demo-12345678', ownerUserId: 'user-1', title: '雨夜终章', description: '失踪列车上的选择。', coverImageSrc: '/vn-cover.png', tags: ['悬疑', '列车'], publishStatus: 'published', publishReady: true, playCount: 12, updatedAt: '2026-05-07T00:00:00.000Z', publishedAt: '2026-05-07T00:00:00.000Z', }, { runtimeKind: 'visual-novel', profileId: 'vn-profile-draft-00000001', ownerUserId: 'user-1', title: '', description: '', coverImageSrc: null, tags: [], publishStatus: 'draft', publishReady: false, playCount: 0, updatedAt: '2026-05-06T00:00:00.000Z', publishedAt: null, }, ], }); expect(items[0]?.kind).toBe('visual-novel'); expect(items[0]?.publicWorkCode).toBe('VN-12345678'); expect(items[0]?.sharePath).toContain('/works/detail?work=VN-12345678'); expect(items[1]?.status).toBe('draft'); expect(items[1]?.publicWorkCode).toBeNull(); }); test('buildCreationWorkShelfItems attaches open and delete actions through shelf adapters', () => { const onOpenPuzzleDetail = vi.fn(); const onDeletePuzzle = vi.fn(); const puzzleWork = { workId: 'puzzle:work-action', profileId: 'puzzle-profile-action', ownerUserId: 'user-1', authorDisplayName: '测试作者', levelName: '动作拼图', summary: '验证作品架动作 Adapter。', themeTags: [], coverImageSrc: null, publicationStatus: 'draft' as const, updatedAt: '2026-05-08T00:00:00.000Z', publishedAt: null, playCount: 0, remixCount: 0, likeCount: 0, publishReady: false, }; const [item] = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [puzzleWork], onOpenPuzzleDetail, onDeletePuzzle, }); item?.actions.open(); item?.actions.delete?.(); expect(onOpenPuzzleDetail).toHaveBeenCalledWith(puzzleWork); expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork); }); test('buildCreationWorkShelfItems restores persisted generation state for puzzle and match3d drafts', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [ { workId: 'puzzle:generating', profileId: 'puzzle-profile-generating', ownerUserId: 'user-1', sourceSessionId: 'puzzle-session-generating', authorDisplayName: '测试作者', levelName: '生成中拼图', summary: '退出产品后仍应显示生成中。', themeTags: [], coverImageSrc: null, publicationStatus: 'draft', updatedAt: '2026-05-08T00:00:00.000Z', publishedAt: null, publishReady: false, generationStatus: 'generating', }, ], match3dItems: [ { workId: 'match3d:generating', profileId: 'match3d-profile-generating', ownerUserId: 'user-1', sourceSessionId: 'match3d-session-generating', gameName: '生成中抓鹅', themeText: '糖果厨房', summary: '退出产品后仍应显示生成中。', tags: [], coverImageSrc: null, clearCount: 18, difficulty: 1, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-05-07T00:00:00.000Z', publishReady: false, generationStatus: 'generating', }, ], }); expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe( true, ); expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe( true, ); }); test('buildCreationWorkShelfItems maps baby object match local drafts', () => { const onOpenBabyObjectMatchDetail = vi.fn(); const onDeleteBabyObjectMatch = vi.fn(); const baseDraft: BabyObjectMatchDraft = { draftId: 'baby-object-draft-1', profileId: 'baby-object-profile-12345678', templateId: 'baby-object-match', templateName: '宝贝识物', workTitle: '宝贝识物', workDescription: '苹果和香蕉识物分类', itemNames: ['苹果', '香蕉'], itemAssets: [ { itemId: 'baby-object-item-1', itemName: '苹果', imageSrc: '/apple.png', assetObjectId: null, generationProvider: 'placeholder', prompt: '苹果', }, { itemId: 'baby-object-item-2', itemName: '香蕉', imageSrc: '/banana.png', assetObjectId: null, generationProvider: 'placeholder', prompt: '香蕉', }, ], visualPackage: null, themeTags: ['寓教于乐'], publicationStatus: 'draft', createdAt: '2026-05-11T00:00:00.000Z', updatedAt: '2026-05-11T00:00:00.000Z', publishedAt: null, }; const items = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [], babyObjectMatchItems: [ baseDraft, { ...baseDraft, draftId: 'baby-object-draft-2', profileId: 'baby-object-profile-87654321', publicationStatus: 'published', publishedAt: '2026-05-11T01:00:00.000Z', updatedAt: '2026-05-11T01:00:00.000Z', }, ], canDeleteBabyObjectMatch: true, onOpenBabyObjectMatchDetail, onDeleteBabyObjectMatch, }); items[1]?.actions.open(); items[1]?.actions.delete?.(); expect(items[0]?.kind).toBe('baby-object-match'); expect(items[0]?.status).toBe('published'); expect(items[0]?.publicWorkCode).toBe('BO-87654321'); expect(items[0]?.sharePath).toContain('/works/detail?work=BO-87654321'); expect(items[1]?.status).toBe('draft'); expect(items[1]?.publicWorkCode).toBeNull(); expect(items[1]?.canDelete).toBe(true); expect(onOpenBabyObjectMatchDetail).toHaveBeenCalledWith(baseDraft); expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(baseDraft); }); test('buildCreationWorkShelfItems sorts works by latest updatedAt across timestamp formats', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [ { workId: 'puzzle:older', profileId: 'puzzle-profile-older', ownerUserId: 'user-1', authorDisplayName: '测试作者', levelName: '旧草稿', summary: '较早修改。', themeTags: [], coverImageSrc: null, publicationStatus: 'draft', updatedAt: '2026-05-07T00:00:00.000Z', publishedAt: null, publishReady: false, }, { workId: 'puzzle:newer', profileId: 'puzzle-profile-newer', ownerUserId: 'user-1', authorDisplayName: '测试作者', levelName: '新草稿', summary: '较晚修改。', themeTags: [], coverImageSrc: null, publicationStatus: 'draft', updatedAt: '1778457601.234567Z', publishedAt: null, publishReady: false, }, ], }); expect(items.map((item) => item.id)).toEqual([ 'puzzle:newer', 'puzzle:older', ]); }); test('buildCreationWorkShelfItems falls back to available gameplay images as covers', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [ { workId: 'puzzle:level-cover', profileId: 'puzzle-profile-level-cover', ownerUserId: 'user-1', authorDisplayName: '测试作者', levelName: '关卡封面拼图', summary: '作品自身封面为空时使用关卡正式图。', themeTags: [], coverImageSrc: null, publicationStatus: 'draft', updatedAt: '2026-05-08T00:00:00.000Z', publishedAt: null, publishReady: false, levels: [ { levelId: 'level-1', levelName: '第一关', pictureDescription: '港口雨夜。', candidates: [ { candidateId: 'candidate-1', imageSrc: '/puzzle-candidate.png', assetId: 'asset-1', prompt: '港口雨夜', sourceType: 'generated', selected: true, }, ], selectedCandidateId: 'candidate-1', coverImageSrc: null, coverAssetId: null, generationStatus: 'ready', }, ], }, ], match3dItems: [ { workId: 'match3d:asset-cover', profileId: 'match3d-profile-asset-cover', ownerUserId: 'user-1', gameName: '素材封面抓鹅', themeText: '糖果厨房', summary: '作品自身封面为空时使用素材图。', tags: [], coverImageSrc: null, clearCount: 18, difficulty: 1, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-05-07T00:00:00.000Z', publishReady: false, generatedItemAssets: [ { itemId: 'item-1', itemName: '糖果', imageSrc: '/match3d-item.png', status: 'image_ready', }, ], }, ], squareHoleItems: [ { workId: 'square-hole:background-cover', profileId: 'square-hole-profile-background-cover', ownerUserId: 'user-1', gameName: '背景封面方洞', themeText: '星空玩具箱', twistRule: '旋转洞口', summary: '作品自身封面为空时使用背景图。', tags: [], coverImageSrc: null, backgroundPrompt: '星空玩具箱', backgroundImageSrc: '/square-hole-background.png', shapeOptions: [], holeOptions: [], shapeCount: 3, difficulty: 1, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-05-06T00:00:00.000Z', publishReady: false, }, ], }); expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe( '/puzzle-candidate.png', ); expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe( '/match3d-item.png', ); expect(items.find((item) => item.kind === 'square-hole')?.coverImageSrc).toBe( '/square-hole-background.png', ); }); test('buildCreationWorkShelfItems uses generated object keys as cover sources', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [ { workId: 'puzzle:level-object-key', profileId: 'puzzle-profile-level-object-key', ownerUserId: 'user-1', authorDisplayName: '测试作者', levelName: '关卡对象拼图', summary: '作品摘要带关卡图对象路径时用关卡图做卡片背景。', themeTags: [], coverImageSrc: null, publicationStatus: 'draft', updatedAt: '2026-05-08T00:00:00.000Z', publishedAt: null, publishReady: false, levels: [ { levelId: 'level-1', levelName: '第一关', pictureDescription: '港口雨夜。', candidates: [ { candidateId: 'candidate-1', imageSrc: '', assetId: 'asset-1', prompt: '港口雨夜', sourceType: 'generated', selected: true, }, ], selectedCandidateId: 'candidate-1', coverImageSrc: 'generated-puzzle-assets/session/profile/level-cover.png', coverAssetId: null, generationStatus: 'ready', }, ], }, ], match3dItems: [ { workId: 'match3d:object-key-cover', profileId: 'match3d-profile-object-key-cover', ownerUserId: 'user-1', gameName: '对象路径抓鹅', themeText: '糖果厨房', summary: '背景图或物品图只有 object key 时也应展示。', tags: [], coverImageSrc: null, clearCount: 18, difficulty: 1, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-05-07T00:00:00.000Z', publishReady: false, generatedBackgroundAsset: { prompt: '糖果厨房背景', imageObjectKey: 'generated-match3d-assets/session/profile/background/image.png', containerImageObjectKey: 'generated-match3d-assets/session/profile/background/container.png', status: 'ready', }, generatedItemAssets: [ { itemId: 'item-1', itemName: '糖果', imageObjectKey: 'generated-match3d-assets/session/profile/items/item-1/image.png', imageViews: [ { viewId: 'view-1', viewIndex: 1, imageObjectKey: 'generated-match3d-assets/session/profile/items/item-1/views/view-1.png', }, ], status: 'image_ready', }, ], }, ], }); expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe( 'generated-puzzle-assets/session/profile/level-cover.png', ); expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe( 'generated-match3d-assets/session/profile/background/container.png', ); }); test('buildCreationWorkShelfItems falls back to match3d item object key without background', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [], match3dItems: [ { workId: 'match3d:item-object-key-cover', profileId: 'match3d-profile-item-object-key-cover', ownerUserId: 'user-1', gameName: '物品对象路径抓鹅', themeText: '糖果厨房', summary: '背景图缺失时用物品视角图对象路径。', tags: [], coverImageSrc: null, clearCount: 18, difficulty: 1, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-05-07T00:00:00.000Z', publishReady: false, generatedItemAssets: [ { itemId: 'item-1', itemName: '糖果', imageObjectKey: 'generated-match3d-assets/session/profile/items/item-1/image.png', imageViews: [ { viewId: 'view-1', viewIndex: 1, imageObjectKey: 'generated-match3d-assets/session/profile/items/item-1/views/view-1.png', }, ], status: 'image_ready', }, ], }, ], }); expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe( 'generated-match3d-assets/session/profile/items/item-1/views/view-1.png', ); }); test('buildCreationWorkShelfItems ignores puzzle theme reference cover and uses first level image', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [ { workId: 'puzzle:theme-reference-cover', profileId: 'puzzle-profile-theme-reference-cover', ownerUserId: 'user-1', authorDisplayName: '测试作者', levelName: '主题兜底拼图', summary: '摘要里的封面是玩法参考图时,用第一关画面兜底。', themeTags: [], coverImageSrc: '/creation-type-references/puzzle.webp', publicationStatus: 'draft', updatedAt: '2026-05-08T00:00:00.000Z', publishedAt: null, publishReady: false, levels: [ { levelId: 'level-1', levelName: '第一关', pictureDescription: '第一关画面。', candidates: [ { candidateId: 'candidate-1', imageSrc: '/puzzle-first-level-candidate.png', assetId: 'asset-1', prompt: '第一关画面', sourceType: 'generated', selected: true, }, ], selectedCandidateId: 'candidate-1', coverImageSrc: '/puzzle-first-level-cover.png', coverAssetId: 'asset-1', generationStatus: 'ready', }, ], }, ], }); expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe( '/puzzle-first-level-cover.png', ); }); test('buildCreationWorkShelfItems ignores match3d theme reference cover and uses container image', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [], match3dItems: [ { workId: 'match3d:theme-reference-cover', profileId: 'match3d-profile-theme-reference-cover', ownerUserId: 'user-1', gameName: '主题兜底抓鹅', themeText: '糖果厨房', summary: '摘要里的封面是玩法参考图时,用UI背景图兜底。', tags: [], coverImageSrc: '/creation-type-references/match3d.webp', clearCount: 18, difficulty: 1, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-05-07T00:00:00.000Z', publishReady: false, generatedBackgroundAsset: { prompt: '糖果厨房竖屏UI背景', imageSrc: '/match3d-ui-background.png', containerImageSrc: '/match3d-container.png', status: 'image_ready', }, generatedItemAssets: [ { itemId: 'item-1', itemName: '糖果', imageSrc: '/match3d-item.png', status: 'image_ready', }, ], }, ], }); expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe( '/match3d-container.png', ); }); test('buildCreationWorkShelfItems uses match3d container asset before background and item image', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [], match3dItems: [ { workId: 'match3d:item-background-asset-cover', profileId: 'match3d-profile-item-background-asset-cover', ownerUserId: 'user-1', gameName: '背景资产抓鹅', themeText: '糖果厨房', summary: '顶层背景缺失时,从素材携带的UI背景兜底。', tags: [], coverImageSrc: '/creation-type-references/match3d.webp', clearCount: 18, difficulty: 1, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-05-07T00:00:00.000Z', publishReady: false, generatedItemAssets: [ { itemId: 'item-1', itemName: '糖果', imageSrc: '/match3d-item.png', backgroundAsset: { prompt: '糖果厨房竖屏UI背景', imageObjectKey: 'generated-match3d-assets/session/profile/background/image.png', containerImageObjectKey: 'generated-match3d-assets/session/profile/ui-container/container.png', status: 'image_ready', }, status: 'image_ready', }, ], }, ], }); expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe( 'generated-match3d-assets/session/profile/ui-container/container.png', ); }); test('buildCreationWorkShelfItems uses match3d transparent container reference as last fallback', () => { const items = buildCreationWorkShelfItems({ rpgItems: [], bigFishItems: [], puzzleItems: [], match3dItems: [ { workId: 'match3d:container-reference-fallback', profileId: 'match3d-profile-container-reference-fallback', ownerUserId: 'user-1', sourceSessionId: 'session-1', gameName: '水果抓大鹅', themeText: '水果', summary: '', tags: [], coverImageSrc: null, referenceImageSrc: null, backgroundPrompt: '', backgroundImageSrc: null, backgroundImageObjectKey: null, generatedBackgroundAsset: null, generatedItemAssets: [], clearCount: 3, difficulty: 2, publicationStatus: 'draft', publishReady: false, playCount: 0, updatedAt: '2026-05-01T00:00:00.000Z', publishedAt: null, }, ], }); expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe( '/match3d-background-references/pot-fused-reference.png', ); }); test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => { expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe( 1778457601234.567, ); expect(getCreationWorkShelfItemTime('2026-05-07T00:00:00.000Z')).toBe( new Date('2026-05-07T00:00:00.000Z').getTime(), ); });