import { renderToStaticMarkup } from 'react-dom/server'; import { expect, test } from 'vitest'; import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../types'; import { AdventurePanel } from './AdventurePanel'; function createCharacter(): Character { return { id: 'hero', name: '沈行', title: '试剑客', description: '测试主角', backstory: '测试背景', avatar: '/hero.png', portrait: '/hero.png', assetFolder: 'hero', assetVariant: 'default', attributes: { strength: 10, agility: 10, intelligence: 8, spirit: 9, }, personality: 'calm', skills: [], adventureOpenings: {}, } as Character; } function createOption(functionId: string, actionText: string): StoryOption { return { functionId, actionText, text: actionText, visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, }; } function renderPanel( currentStory: StoryMoment, displayedOptions: StoryOption[], overrides: { canRefreshOptions?: boolean; hideOptions?: boolean; isLoading?: boolean; onSubmitNpcChatInput?: (input: string) => boolean; onExitNpcChat?: () => boolean; } = {}, ) { return renderToStaticMarkup( undefined} onChoice={() => undefined} onSubmitNpcChatInput={overrides.onSubmitNpcChatInput} onExitNpcChat={overrides.onExitNpcChat} onOpenCharacter={() => undefined} onOpenInventory={() => undefined} playerCharacter={createCharacter()} worldType={WorldType.WUXIA} quests={[]} questUi={{ acknowledgeQuestCompletion: () => undefined, claimQuestReward: () => null, }} npcChatQuestOfferUi={{ replacePendingOffer: async () => false, abandonPendingOffer: () => false, acceptPendingOffer: () => null, }} goalStack={{ northStarGoal: null, activeGoal: null, immediateStepGoal: null, supportGoals: [], }} goalPulse={null} onDismissGoalPulse={() => undefined} battleRewardUi={{ reward: null, dismiss: () => undefined, }} playerHp={100} playerMaxHp={100} playerMana={20} playerMaxMana={20} playerSkillCooldowns={{}} inBattle={false} currentNpcBattleMode={null} statistics={{ playTimeMs: 0, hostileNpcsDefeated: 0, questsAccepted: 0, questsCompleted: 0, questsTurnedIn: 0, itemsUsed: 0, scenesTraveled: 0, currentSceneName: '竹林古道', playerCurrency: 0, inventoryItemCount: 0, inventoryStackCount: 0, activeCompanionCount: 0, rosterCompanionCount: 0, }} musicVolume={0.6} onMusicVolumeChange={() => undefined} onSaveAndExit={() => undefined} />, ); } test('adventure panel recognizes story_continue_adventure by function id instead of action text', () => { const continueOption = createOption('story_continue_adventure', '查看后续'); const currentStory: StoryMoment = { text: '你们交换完这一轮判断。', options: [continueOption], deferredOptions: [createOption('idle_explore_forward', '继续向前探索')], }; const html = renderPanel(currentStory, [continueOption]); expect(html).toContain('剧情推理完成,继续后显示新的冒险选项'); }); test('adventure panel does not show deferred hint for non-continue options with the same text', () => { const misleadingOption = createOption('npc_chat', '查看后续'); const currentStory: StoryMoment = { text: '你们交换完这一轮判断。', options: [misleadingOption], deferredOptions: [createOption('idle_explore_forward', '继续向前探索')], }; const html = renderPanel(currentStory, [misleadingOption]); expect(html).not.toContain('剧情推理完成,继续后显示新的冒险选项'); }); test('adventure panel shows npc chat custom input and exit button in chat mode', () => { const optionA = createOption('npc_chat', '先听对方把话说完'); const optionB = createOption('npc_chat', '顺着这个问题继续追问'); const optionC = createOption('npc_chat', '换个更轻松的语气回应'); const currentStory: StoryMoment = { text: '你们的对话正在继续。', displayMode: 'dialogue', dialogue: [ { speaker: 'player', text: '你刚才那句话是什么意思?' }, { speaker: 'npc', speakerName: '柳无声', text: '意思是这件事还没结束。' }, ], options: [optionA, optionB, optionC], npcAffinityEffect: { eventId: 'effect-liu-1', npcId: 'npc-liu', delta: 3, }, npcChatState: { npcId: 'npc-liu', npcName: '柳无声', turnCount: 2, customInputPlaceholder: '输入你想对 TA 说的话', }, }; const html = renderPanel(currentStory, [optionA, optionB, optionC], { canRefreshOptions: true, onSubmitNpcChatInput: () => true, onExitNpcChat: () => true, }); expect(html).toContain('退出聊天'); expect(html).toContain('输入你想对 TA 说的话'); expect(html).toContain('发送'); expect(html).not.toContain('换一换'); expect(html).not.toContain('关系升温'); }); test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => { const viewOption = createOption('npc_chat_quest_offer_view', '查看任务'); viewOption.runtimePayload = { npcChatQuestOfferAction: 'view', }; const replaceOption = createOption('npc_chat_quest_offer_replace', '更换任务'); replaceOption.runtimePayload = { npcChatQuestOfferAction: 'replace', }; const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务'); abandonOption.runtimePayload = { npcChatQuestOfferAction: 'abandon', }; const currentStory: StoryMoment = { text: '柳无声把真正的委托说了出来。', displayMode: 'dialogue', dialogue: [ { speaker: 'player', text: '你像是还有别的话想说。' }, { speaker: 'npc', speakerName: '柳无声', text: '确实有一件事想正式托付给你。' }, ], options: [viewOption, replaceOption, abandonOption], npcChatState: { npcId: 'npc-liu', npcName: '柳无声', turnCount: 2, customInputPlaceholder: '输入你想对 TA 说的话', pendingQuestOffer: { quest: { id: 'quest-liu-1', issuerNpcId: 'npc-liu', issuerNpcName: '柳无声', sceneId: 'scene-bamboo', title: '竹林密信', description: '替柳无声查清竹林中的密信来源。', summary: '去竹林查清密信来源。', objective: { kind: 'inspect_treasure', requiredCount: 1, }, progress: 0, status: 'active', reward: { affinityBonus: 5, currency: 10, items: [], }, rewardText: '完成后可获得报酬。', }, }, }, }; const html = renderPanel(currentStory, [viewOption, replaceOption, abandonOption], { onSubmitNpcChatInput: () => true, onExitNpcChat: () => true, }); expect(html).toContain('查看任务'); expect(html).toContain('更换任务'); expect(html).toContain('放弃任务'); expect(html).not.toContain('发送'); expect(html).not.toContain('输入你想对 TA 说的话'); }); test('adventure panel renders narrative story text without italics and hides option detail text', () => { const option = createOption('idle_observe_signs', '观察风里残下的痕迹'); option.detailText = '这段说明不应该继续出现在 UI 里。'; const currentStory: StoryMoment = { text: '风从桥洞里灌过来,你把注意力重新放回脚下与前路。', options: [option], }; const html = renderPanel(currentStory, [option]); expect(html).toContain('font-serif'); expect(html).not.toContain('italic'); expect(html).toContain('text-[15px]'); expect(html).not.toContain('这段说明不应该继续出现在 UI 里。'); });