import { beforeEach, describe, expect, it, vi } from 'vitest'; const { requestJsonMock } = vi.hoisted(() => ({ requestJsonMock: vi.fn(), })); vi.mock('./apiClient', async () => { const actual = await vi.importActual('./apiClient'); return { ...actual, requestJson: requestJsonMock, }; }); import { AnimationState } from '../types'; import { buildStoryMomentFromRuntimeOptions, getRuntimeClientVersion, getRuntimeSessionId, isServerRuntimeFunctionId, isTask5RuntimeFunctionId, resolveRuntimeStoryAction, resolveRuntimeStoryMoment, shouldUseServerRuntimeOptions, } from './runtimeStoryService'; describe('runtimeStoryService', () => { beforeEach(() => { requestJsonMock.mockReset(); }); it('builds runtime action requests against the dedicated story endpoint', async () => { requestJsonMock.mockResolvedValue({ sessionId: 'runtime-main', serverVersion: 2, viewModel: {}, presentation: { actionText: '继续交谈', resultText: '后端已结算', storyText: '后端已结算', options: [], }, patches: [], snapshot: { version: 2, savedAt: '2026-04-08T00:00:00.000Z', bottomTab: 'adventure', gameState: {}, currentStory: null, }, }); await resolveRuntimeStoryAction({ sessionId: 'runtime-custom', clientVersion: 9, option: { functionId: 'npc_chat', actionText: '继续交谈', }, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/runtime/story/actions/resolve', expect.objectContaining({ method: 'POST', body: JSON.stringify({ sessionId: 'runtime-custom', clientVersion: 9, action: { type: 'story_choice', functionId: 'npc_chat', targetId: undefined, payload: { optionText: '继续交谈', }, }, }), }), '执行运行时动作失败', expect.any(Object), ); }); it('merges custom runtime payload fields into the action request body', async () => { requestJsonMock.mockResolvedValue({ sessionId: 'runtime-main', serverVersion: 3, viewModel: {}, presentation: { actionText: '使用凝神灵液', resultText: '后端已结算物品使用', storyText: '后端已结算物品使用', options: [], }, patches: [], snapshot: { version: 3, savedAt: '2026-04-08T00:00:00.000Z', bottomTab: 'adventure', gameState: {}, currentStory: null, }, }); await resolveRuntimeStoryAction({ option: { functionId: 'inventory_use', actionText: '使用凝神灵液', }, payload: { itemId: 'focus-tonic', }, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/runtime/story/actions/resolve', expect.objectContaining({ method: 'POST', body: JSON.stringify({ sessionId: 'runtime-main', clientVersion: undefined, action: { type: 'story_choice', functionId: 'inventory_use', targetId: undefined, payload: { optionText: '使用凝神灵液', itemId: 'focus-tonic', }, }, }), }), '执行运行时动作失败', expect.any(Object), ); }); it('keeps disabled runtime options when rebuilding a story moment', () => { const story = buildStoryMomentFromRuntimeOptions({ storyText: '服务端返回的新故事', options: [ { functionId: 'npc_chat', actionText: '继续交谈', scope: 'npc', }, { functionId: 'npc_recruit', actionText: '邀请加入队伍', scope: 'npc', disabled: true, reason: '队伍已满', }, ], }); expect(story.text).toBe('服务端返回的新故事'); expect(story.options).toHaveLength(2); expect(story.options[0]?.functionId).toBe('npc_chat'); expect(story.options[1]?.functionId).toBe('npc_recruit'); expect(story.options[1]?.disabled).toBe(true); expect(story.options[1]?.disabledReason).toBe('队伍已满'); }); it('recognizes server-runtime option pools for server-side legality checks', () => { expect(isTask5RuntimeFunctionId('npc_chat')).toBe(true); expect(isTask5RuntimeFunctionId('battle_attack_basic')).toBe(true); expect(isTask5RuntimeFunctionId('npc_trade')).toBe(false); expect(isServerRuntimeFunctionId('npc_trade')).toBe(true); expect(isServerRuntimeFunctionId('unknown_action')).toBe(false); expect( shouldUseServerRuntimeOptions([ { functionId: 'npc_chat', actionText: '继续交谈', text: '继续交谈', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }, ]), ).toBe(true); expect( shouldUseServerRuntimeOptions([ { functionId: 'npc_trade', actionText: '交易', text: '交易', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }, ]), ).toBe(true); expect( shouldUseServerRuntimeOptions([ { functionId: 'unknown_action', actionText: '未知动作', text: '未知动作', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }, ]), ).toBe(false); expect(getRuntimeSessionId({ runtimeSessionId: '' })).toBe('runtime-main'); expect(getRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3); }); it('preserves runtime option interaction metadata from the server response', () => { const story = buildStoryMomentFromRuntimeOptions({ storyText: '服务端返回的新故事', options: [ { functionId: 'npc_trade', actionText: '交易', scope: 'npc', interaction: { kind: 'npc', npcId: 'npc-merchant', action: 'trade', }, }, ], }); expect(story.options[0]?.interaction).toEqual({ kind: 'npc', npcId: 'npc-merchant', action: 'trade', }); }); it('prefers the richer snapshot story when the server persisted dialogue mode', () => { const story = resolveRuntimeStoryMoment({ response: { sessionId: 'runtime-main', serverVersion: 4, viewModel: { player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 }, encounter: null, companions: [], availableOptions: [], status: { inBattle: false, npcInteractionActive: true, currentNpcBattleMode: null, currentNpcBattleOutcome: null, }, }, presentation: { actionText: '继续交谈', resultText: '后端已结算', storyText: '普通文本', options: [], battle: null, toast: null, }, patches: [], snapshot: { version: 2, savedAt: '2026-04-08T00:00:00.000Z', bottomTab: 'adventure', gameState: {} as never, currentStory: { text: '你:先把话说开。\n梁伯:那我就直说了。', options: [], displayMode: 'dialogue', dialogue: [ { speaker: 'player', text: '先把话说开。' }, { speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' }, ], deferredOptions: [ { functionId: 'npc_chat', actionText: '继续交谈', text: '继续交谈', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }, ], }, } as never, }, hydratedSnapshot: { version: 2, savedAt: '2026-04-08T00:00:00.000Z', bottomTab: 'adventure', gameState: {} as never, currentStory: { text: '你:先把话说开。\n梁伯:那我就直说了。', options: [], displayMode: 'dialogue', dialogue: [ { speaker: 'player', text: '先把话说开。' }, { speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' }, ], deferredOptions: [ { functionId: 'npc_chat', actionText: '继续交谈', text: '继续交谈', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }, ], }, } as never, fallbackStoryText: '普通文本', }); expect(story.displayMode).toBe('dialogue'); expect(story.deferredOptions).toHaveLength(1); expect(story.text).toContain('梁伯'); }); });