import { describe, expect, test } from 'vitest'; import type { JumpHopWorkSummaryResponse, } from '../../../packages/shared/src/contracts/jumpHop'; import type { PuzzleAnchorPack, PuzzleResultDraft, } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import type { SquareHoleResultDraft, SquareHoleSessionSnapshot, } from '../../../packages/shared/src/contracts/squareHoleAgent'; import type { VisualNovelResultDraft, VisualNovelWorkDetail, } from '../../../packages/shared/src/contracts/visualNovel'; import type { WoodenFishAudioAsset, WoodenFishImageAsset, WoodenFishSessionSnapshotResponse, WoodenFishWorkProfileResponse, WoodenFishWorkspaceCreateRequest, WoodenFishWorkSummaryResponse, } from '../../../packages/shared/src/contracts/woodenFish'; import { buildJumpHopPendingSession, buildPuzzleRuntimeWorkFromSession, buildSquareHoleProfileFromSession, buildVisualNovelSessionFromWorkDetail, buildWoodenFishGeneratingWorkSummary, buildWoodenFishPendingSession, buildWoodenFishSessionFromWorkDetail, } from './platformMiniGameSessionMappingModel'; 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 buildPuzzleDraft( overrides: Partial = {}, ): PuzzleResultDraft { const anchorPack = buildAnchorPack(); return { workTitle: '星桥拼图', workDescription: '修复星桥机关。', levelName: '星桥机关', summary: '把星桥碎片拼回原位。', themeTags: ['星桥'], forbiddenDirectives: [], creatorIntent: null, anchorPack, candidates: [], selectedCandidateId: null, coverImageSrc: '/puzzle-cover.png', coverAssetId: 'asset-cover', generationStatus: 'ready', levels: [ { levelId: 'level-1', levelName: '星桥机关', pictureDescription: '星桥', candidates: [], selectedCandidateId: null, coverImageSrc: '/puzzle-level-cover.png', coverAssetId: 'asset-level-cover', generationStatus: 'ready', }, ], ...overrides, }; } function buildPuzzleSession( overrides: Partial = {}, ): PuzzleAgentSessionSnapshot { const draft = buildPuzzleDraft(); return { sessionId: 'puzzle-session-12345678', seedText: '星桥', currentTurn: 1, progressPercent: 100, stage: 'ready_to_publish', anchorPack: draft.anchorPack, draft, messages: [], lastAssistantReply: null, publishedProfileId: null, suggestedActions: [], resultPreview: { draft, blockers: [], qualityFindings: [], publishReady: true, }, updatedAt: '2026-06-01T10:00:00.000Z', ...overrides, }; } function buildJumpHopSummary( overrides: Partial = {}, ): JumpHopWorkSummaryResponse { return { runtimeKind: 'jump-hop', workId: 'jump-hop-work-1', profileId: 'jump-hop-profile-1', ownerUserId: 'user-1', sourceSessionId: ' jump-hop-session-1 ', themeText: '云阶机关', workTitle: '云阶跳跃', workDescription: '越过云阶。', themeTags: ['云阶'], difficulty: 'standard', stylePreset: 'paper-toy', coverImageSrc: '/jump-hop-cover.png', publicationStatus: 'draft', playCount: 0, updatedAt: '2026-06-01T11:00:00.000Z', publishedAt: null, publishReady: false, generationStatus: 'generating', ...overrides, }; } function buildSquareHoleDraft( overrides: Partial = {}, ): SquareHoleResultDraft { return { profileId: 'square-hole-profile-1', gameName: '星桥方洞', themeText: '星桥机关', twistRule: '只允许相同颜色形状入洞', summary: '把星桥机关里的形状送入正确孔洞。', tags: ['星桥', '机关'], coverImageSrc: '/square-hole-cover.png', backgroundPrompt: '星桥机关背景', backgroundImageSrc: '/square-hole-background.png', shapeOptions: [ { optionId: 'shape-1', shapeKind: 'star', label: '星形', targetHoleId: 'hole-1', imagePrompt: '星形积木', imageSrc: '/shape-star.png', }, ], holeOptions: [ { holeId: 'hole-1', holeKind: 'star-hole', label: '星形洞', imagePrompt: '星形洞口', imageSrc: '/hole-star.png', }, ], shapeCount: 6, difficulty: 3, publishReady: true, blockers: [], ...overrides, }; } function buildSquareHoleSession( overrides: Partial = {}, ): SquareHoleSessionSnapshot { return { sessionId: 'square-hole-session-1', currentTurn: 2, progressPercent: 100, stage: 'draft_ready', anchorPack: { theme: { key: 'theme', label: '主题', value: '星桥机关', status: 'confirmed', }, twistRule: { key: 'twistRule', label: '扭转规则', value: '只允许相同颜色形状入洞', status: 'confirmed', }, shapeCount: { key: 'shapeCount', label: '形状数量', value: '6', status: 'confirmed', }, difficulty: { key: 'difficulty', label: '难度', value: '3', status: 'confirmed', }, }, config: { themeText: '星桥机关', twistRule: '只允许相同颜色形状入洞', shapeCount: 6, difficulty: 3, shapeOptions: [], holeOptions: [], backgroundPrompt: '星桥机关背景', coverImageSrc: null, backgroundImageSrc: null, }, draft: buildSquareHoleDraft(), messages: [], lastAssistantReply: null, publishedProfileId: null, updatedAt: '2026-06-01T12:30:00.000Z', ...overrides, }; } function buildVisualNovelDraft( overrides: Partial = {}, ): VisualNovelResultDraft { return { profileId: 'visual-novel-profile-1', workTitle: '雪线电台', workDescription: '旧电台牵出雪夜列车谜案。', workTags: ['雪夜', '电台'], coverImageSrc: '/visual-novel-cover.png', sourceMode: 'idea', sourceAssetIds: ['asset-source-1'], world: { title: '北境终点线', summary: '边境小城与旧电台。', background: '十二年前的雪崩留下夜间广播。', premise: '玩家需要在日出前找出列车停摆的原因。', literaryStyle: '克制冷光感。', playerRole: '临时广播员', defaultTone: '安静紧张', }, characters: [ { characterId: 'vn-char-1', name: '林遥', gender: '女', role: 'main', appearance: '灰色长外套。', personality: '谨慎敏锐。', tone: '短句多。', background: '旧电台夜班实习生。', relationshipToPlayer: '临时搭档', imageAssets: [], defaultExpression: 'calm', isPlayerVisible: false, }, ], scenes: [ { sceneId: 'vn-scene-1', name: '风雪站台', description: '站灯忽明忽暗。', backgroundImageSrc: null, musicSrc: null, ambientSoundSrc: null, availability: 'opening', phaseIds: ['vn-phase-1'], }, ], storyPhases: [ { phaseId: 'vn-phase-1', title: '重启站台', goal: '确认列车为何停在废弃站台。', summary: '玩家抵达风雪站台。', entryCondition: '开场进入', exitCondition: '找到车长日志', sceneIds: ['vn-scene-1'], characterIds: ['vn-char-1'], suggestedChoices: ['检查广播柜'], }, ], opening: { sceneId: 'vn-scene-1', narration: '雪落得很慢。', speakerCharacterId: 'vn-char-1', firstDialogue: '你听见了吗?', initialChoices: [ { choiceId: 'vn-choice-1', text: '靠近广播柜。', actionHint: 'inspect_radio', }, ], }, runtimeConfig: { textModeEnabled: true, defaultTextMode: false, maxHistoryEntries: 80, maxAssistantStepCountPerTurn: 8, allowFreeTextAction: true, allowHistoryRegeneration: true, attributePanelMode: 'template_config', saveArchiveEnabled: true, }, publishReady: true, validationIssues: [], updatedAt: '2026-06-01T13:00:00.000Z', ...overrides, }; } function buildVisualNovelWorkDetail( overrides: Partial = {}, ): VisualNovelWorkDetail { const draft = buildVisualNovelDraft(); return { workId: 'visual-novel-work-1', summary: { runtimeKind: 'visual-novel', profileId: 'visual-novel-profile-1', ownerUserId: 'user-visual-novel-1', title: draft.workTitle, description: draft.workDescription, coverImageSrc: draft.coverImageSrc, tags: draft.workTags, publishStatus: 'draft', publishReady: draft.publishReady, playCount: 0, updatedAt: '2026-06-01T13:30:00.000Z', publishedAt: null, }, sourceSessionId: ' visual-novel-session-1 ', authorDisplayName: '视觉小说作者', sourceAssetIds: draft.sourceAssetIds, draft, createdAt: '2026-06-01T12:50:00.000Z', ...overrides, }; } const woodenFishImageAsset: WoodenFishImageAsset = { assetId: 'asset-hit', imageSrc: '/hit.png', imageObjectKey: 'hit.png', assetObjectId: 'asset-object-hit', generationProvider: 'test', prompt: '木鱼', width: 512, height: 512, }; const woodenFishAudioAsset: WoodenFishAudioAsset = { assetId: 'asset-sound', audioSrc: '/hit.mp3', audioObjectKey: 'hit.mp3', assetObjectId: 'asset-object-sound', source: 'test', }; function buildWoodenFishSummary( overrides: Partial = {}, ): WoodenFishWorkSummaryResponse { return { runtimeKind: 'wooden-fish', workId: 'wooden-fish-work-1', profileId: 'wooden-fish-profile-1', ownerUserId: 'user-1', sourceSessionId: ' wooden-fish-session-1 ', workTitle: '星灯木鱼', workDescription: '敲亮星灯。', themeTags: ['星灯'], coverImageSrc: '/wooden-fish-cover.png', publicationStatus: 'draft', playCount: 0, updatedAt: '2026-06-01T12:00:00.000Z', publishedAt: null, publishReady: false, generationStatus: 'generating', ...overrides, }; } function buildWoodenFishWorkProfile( overrides: Partial = {}, ): WoodenFishWorkProfileResponse { const summary = buildWoodenFishSummary(); const draft = { templateId: 'wooden-fish', templateName: '敲木鱼', profileId: summary.profileId, workTitle: summary.workTitle, workDescription: summary.workDescription, themeTags: summary.themeTags, hitObjectPrompt: '星灯', hitObjectReferenceImageSrc: null, hitSoundPrompt: null, floatingWords: ['功德 +1'], hitObjectAsset: woodenFishImageAsset, backgroundAsset: null, backButtonAsset: null, hitSoundAsset: woodenFishAudioAsset, coverImageSrc: summary.coverImageSrc, generationStatus: summary.generationStatus, }; return { summary, draft, hitObjectAsset: woodenFishImageAsset, backgroundAsset: null, backButtonAsset: null, hitSoundAsset: woodenFishAudioAsset, floatingWords: ['功德 +1'], ...overrides, }; } function buildWoodenFishSession( overrides: Partial = {}, ): WoodenFishSessionSnapshotResponse { const summary = buildWoodenFishSummary(); return { sessionId: 'wooden-fish-session-1', ownerUserId: 'user-1', status: 'generating', draft: buildWoodenFishWorkProfile({ summary }).draft, createdAt: '2026-06-01T11:59:00.000Z', updatedAt: '2026-06-01T12:00:00.000Z', ...overrides, }; } function buildWoodenFishCreatePayload( overrides: Partial = {}, ): WoodenFishWorkspaceCreateRequest { return { templateId: 'wooden-fish', workTitle: '表单星灯木鱼', workDescription: '表单里敲亮星灯。', themeTags: ['表单星灯'], hitObjectPrompt: '星灯', hitObjectReferenceImageSrc: null, hitSoundPrompt: null, floatingWords: ['功德 +1'], ...overrides, }; } describe('platformMiniGameSessionMappingModel', () => { test('builds a draft puzzle runtime work from a session', () => { expect( buildPuzzleRuntimeWorkFromSession(buildPuzzleSession(), { userId: 'user-1', displayName: '玩家一号', }), ).toMatchObject({ workId: 'puzzle-work-12345678', profileId: 'puzzle-profile-12345678', ownerUserId: 'user-1', sourceSessionId: 'puzzle-session-12345678', authorDisplayName: '玩家一号', workTitle: '星桥拼图', coverImageSrc: '/puzzle-cover.png', publicationStatus: 'draft', publishedAt: null, publishReady: true, }); }); test('prefers published puzzle profile id when present', () => { expect( buildPuzzleRuntimeWorkFromSession( buildPuzzleSession({ publishedProfileId: 'published-puzzle-profile', }), {}, ), ).toMatchObject({ profileId: 'published-puzzle-profile', workId: 'puzzle-work-12345678', ownerUserId: 'current-user', authorDisplayName: '玩家', }); }); test('returns null for puzzle runtime work without draft or cover', () => { expect( buildPuzzleRuntimeWorkFromSession( buildPuzzleSession({ draft: null, }), {}, ), ).toBeNull(); expect( buildPuzzleRuntimeWorkFromSession( buildPuzzleSession({ draft: buildPuzzleDraft({ coverImageSrc: ' ' }), }), {}, ), ).toBeNull(); }); test('builds jump hop pending session from work summary', () => { expect(buildJumpHopPendingSession(buildJumpHopSummary())).toEqual({ sessionId: 'jump-hop-session-1', ownerUserId: 'user-1', status: 'generating', draft: { templateId: 'jump-hop', templateName: '跳一跳', profileId: 'jump-hop-profile-1', themeText: '云阶机关', workTitle: '云阶跳跃', workDescription: '越过云阶。', themeTags: ['云阶'], difficulty: 'standard', stylePreset: 'paper-toy', characterPrompt: '', tilePrompt: '', endMoodPrompt: null, characterAsset: null, tileAtlasAsset: null, tileAssets: [], path: null, coverComposite: '/jump-hop-cover.png', generationStatus: 'generating', }, createdAt: '2026-06-01T11:00:00.000Z', updatedAt: '2026-06-01T11:00:00.000Z', }); }); test('builds square hole draft profile from session', () => { expect(buildSquareHoleProfileFromSession(buildSquareHoleSession())).toEqual({ workId: 'square-hole-profile-1', profileId: 'square-hole-profile-1', ownerUserId: 'current-user', sourceSessionId: 'square-hole-session-1', gameName: '星桥方洞', themeText: '星桥机关', twistRule: '只允许相同颜色形状入洞', summary: '把星桥机关里的形状送入正确孔洞。', tags: ['星桥', '机关'], coverImageSrc: '/square-hole-cover.png', backgroundPrompt: '星桥机关背景', backgroundImageSrc: '/square-hole-background.png', shapeOptions: buildSquareHoleDraft().shapeOptions, holeOptions: buildSquareHoleDraft().holeOptions, shapeCount: 6, difficulty: 3, publicationStatus: 'draft', playCount: 0, updatedAt: '2026-06-01T12:30:00.000Z', publishedAt: null, publishReady: true, }); }); test('returns null for square hole profile without session draft or profile id', () => { expect(buildSquareHoleProfileFromSession(null)).toBeNull(); expect( buildSquareHoleProfileFromSession( buildSquareHoleSession({ draft: null, }), ), ).toBeNull(); expect( buildSquareHoleProfileFromSession( buildSquareHoleSession({ draft: buildSquareHoleDraft({ profileId: '' }), }), ), ).toBeNull(); }); test('builds visual novel recovered session from work detail', () => { const work = buildVisualNovelWorkDetail(); expect(buildVisualNovelSessionFromWorkDetail(work)).toEqual({ sessionId: 'visual-novel-session-1', ownerUserId: 'user-visual-novel-1', sourceMode: 'idea', status: 'ready', messages: [], draft: work.draft, pendingAction: null, createdAt: '2026-06-01T12:50:00.000Z', updatedAt: '2026-06-01T13:30:00.000Z', }); }); test('falls back visual novel recovered session id to work id', () => { expect( buildVisualNovelSessionFromWorkDetail( buildVisualNovelWorkDetail({ sourceSessionId: ' ', workId: 'visual-novel-work-fallback', }), ).sessionId, ).toBe('visual-novel-work-fallback'); }); test('builds wooden fish pending session from work summary', () => { expect(buildWoodenFishPendingSession(buildWoodenFishSummary())).toEqual({ sessionId: 'wooden-fish-session-1', ownerUserId: 'user-1', status: 'generating', draft: { templateId: 'wooden-fish', templateName: '敲木鱼', profileId: 'wooden-fish-profile-1', workTitle: '星灯木鱼', workDescription: '敲亮星灯。', themeTags: ['星灯'], hitObjectPrompt: '', hitObjectReferenceImageSrc: null, hitSoundPrompt: null, floatingWords: ['功德 +1'], hitObjectAsset: null, backgroundAsset: null, backButtonAsset: null, hitSoundAsset: null, coverImageSrc: '/wooden-fish-cover.png', generationStatus: 'generating', }, createdAt: '2026-06-01T12:00:00.000Z', updatedAt: '2026-06-01T12:00:00.000Z', }); }); test('builds wooden fish generating work summary from session and payload', () => { expect( buildWoodenFishGeneratingWorkSummary( buildWoodenFishSession(), buildWoodenFishCreatePayload(), ), ).toEqual({ runtimeKind: 'wooden-fish', workId: 'wooden-fish-session-1', profileId: 'wooden-fish-session-1', ownerUserId: 'user-1', sourceSessionId: 'wooden-fish-session-1', workTitle: '表单星灯木鱼', workDescription: '表单里敲亮星灯。', themeTags: ['表单星灯'], coverImageSrc: '/wooden-fish-cover.png', publicationStatus: 'draft', playCount: 0, updatedAt: '2026-06-01T12:00:00.000Z', publishedAt: null, publishReady: false, generationStatus: 'generating', }); expect( buildWoodenFishGeneratingWorkSummary( buildWoodenFishSession({ draft: null, createdAt: '2026-06-01T11:59:00.000Z', }), null, ), ).toMatchObject({ workTitle: '敲木鱼', workDescription: '', themeTags: ['敲木鱼'], coverImageSrc: null, updatedAt: '2026-06-01T12:00:00.000Z', }); }); test('builds wooden fish recovered session with summary, fallback and profile id priority', () => { expect( buildWoodenFishSessionFromWorkDetail( buildWoodenFishWorkProfile({ summary: buildWoodenFishSummary({ sourceSessionId: null, }), }), buildWoodenFishSummary({ sourceSessionId: ' fallback-session ', }), ), ).toMatchObject({ sessionId: 'fallback-session', ownerUserId: 'user-1', status: 'generating', }); expect( buildWoodenFishSessionFromWorkDetail( buildWoodenFishWorkProfile({ summary: buildWoodenFishSummary({ sourceSessionId: null, }), }), null, ).sessionId, ).toBe('wooden-fish-profile-1'); }); });