/* @vitest-environment jsdom */ import { act, renderHook } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AnimationState, type GameState, type StoryMoment, type StoryOption, WorldType, } from '../../types'; import { useRpgSceneTransitionModel } from './useRpgSceneTransitionModel'; function createGameState(actId: string): GameState { return { worldType: WorldType.WUXIA, customWorldProfile: null, playerCharacter: { id: 'hero', name: '测试主角', title: '游侠', description: '测试角色', backstory: '测试背景', avatar: '', portrait: '', assetFolder: '', assetVariant: '', attributes: { strength: 10, agility: 10, intelligence: 10, spirit: 10, }, personality: '沉稳', skills: [], adventureOpenings: {}, }, runtimeStats: { playTimeMs: 0, lastPlayTickAt: null, hostileNpcsDefeated: 0, questsAccepted: 0, itemsUsed: 0, scenesTraveled: 0, }, currentScene: 'Story', storyHistory: [{ text: '旧幕', options: [] }], storyEngineMemory: { discoveredFactIds: [], activeThreadIds: [], resolvedScarIds: [], recentCarrierIds: [], currentSceneActState: { sceneId: 'scene-1', chapterId: 'chapter-1', currentActId: actId, currentActIndex: actId === 'act-1' ? 0 : 1, completedActIds: actId === 'act-1' ? [] : ['act-1'], visitedActIds: [actId], }, }, characterChats: {}, animationState: AnimationState.IDLE, currentEncounter: null, npcInteractionActive: false, currentScenePreset: { id: 'scene-1', name: '断桥旧哨', description: '测试场景', imageSrc: '/scene.png', treasureHints: [], npcs: [], }, sceneHostileNpcs: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: false, playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, playerSkillCooldowns: {}, activeCombatEffects: [], playerCurrency: 0, playerInventory: [], playerEquipment: { weapon: null, armor: null, relic: null, }, npcStates: {}, quests: [], roster: [], companions: [], currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }; } function createStory( text: string, options: StoryOption[] = [], deferredAutoChoice?: StoryOption, ): StoryMoment { return { text, options, deferredAutoChoice, }; } describe('useRpgSceneTransitionModel', () => { afterEach(() => { vi.useRealTimers(); }); it('fires deferred auto choice only after entry and through the latest callback', () => { vi.useFakeTimers(); const autoChoice: StoryOption = { functionId: 'npc_preview_talk', actionText: '与新角色交谈', text: '与新角色交谈', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }; const firstCallback = vi.fn(); const latestCallback = vi.fn(); const initialState = createGameState('act-1'); const initialStory = createStory('旧幕收束', [ { functionId: 'story_continue_adventure', actionText: '继续冒险', text: '继续冒险', visuals: autoChoice.visuals, }, ]); const nextStory = createStory('新幕入口', [autoChoice], autoChoice); const { result, rerender } = renderHook( (props: { gameState: GameState; currentStory: StoryMoment; onDeferredAutoChoice: (option: StoryOption) => void; }) => useRpgSceneTransitionModel({ gameState: props.gameState, currentStory: props.currentStory, openingCampSceneId: null, onDeferredAutoChoice: props.onDeferredAutoChoice, }), { initialProps: { gameState: initialState, currentStory: initialStory, onDeferredAutoChoice: firstCallback, }, }, ); act(() => { result.current.setSceneTransitionDurations({ exitMs: 20, entryMs: 30 }); }); act(() => { result.current.beginSceneTransition('content-change'); }); expect(result.current.sceneTransitionPhase).toBe('exiting'); rerender({ gameState: createGameState('act-2'), currentStory: nextStory, onDeferredAutoChoice: latestCallback, }); act(() => { vi.advanceTimersByTime(20); }); expect(result.current.sceneTransitionPhase).toBe('entering'); expect(latestCallback).not.toHaveBeenCalled(); act(() => { vi.advanceTimersByTime(30); }); expect(result.current.sceneTransitionPhase).toBe('idle'); expect(firstCallback).not.toHaveBeenCalled(); expect(latestCallback).toHaveBeenCalledWith(autoChoice); }); });