/* @vitest-environment jsdom */ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { type ComponentProps, useState } from 'react'; import { beforeAll, expect, test, vi } from 'vitest'; import { AnimationState, type Character, type GoalPulseEvent, type GoalStackState, type InventoryItem, type QuestLogEntry, type StoryMoment, type StoryOption, WorldType, } from '../../types'; import { RpgAdventurePanel } from './RpgAdventurePanel'; 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 createPendingQuest(): QuestLogEntry { return { 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: '完成后可获得报酬。', }; } function createCompletedQuest(): QuestLogEntry { return { ...createPendingQuest(), id: 'quest-liu-completed', progress: 1, status: 'completed', completionNotified: false, }; } function createRewardItem(): InventoryItem { return { id: 'battle-herb', name: '青玉药丸', category: '消耗品', quantity: 2, rarity: 'rare', tags: ['治疗'], iconSrc: '/Icons/28_bag.png', description: '战斗后取得的测试奖励。', }; } function createQuestWithRewardItem(): QuestLogEntry { const quest = createPendingQuest(); return { ...quest, id: 'quest-liu-reward-item', reward: { ...quest.reward, items: [createRewardItem()], }, }; } function createPendingQuestStory(quest: QuestLogEntry): StoryMoment { 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', }; return { text: '柳无声把真正的委托说了出来。', displayMode: 'dialogue', dialogue: [ { speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。', }, ], options: [viewOption, replaceOption, abandonOption], npcChatState: { npcId: 'npc-liu', npcName: '柳无声', turnCount: 2, customInputPlaceholder: '输入你想对 TA 说的话', pendingQuestOffer: { quest, }, }, }; } function createAcceptedQuestStory(): StoryMoment { return { text: '柳无声把接下来的线索正式交给了你。', displayMode: 'dialogue', dialogue: [ { speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。', }, { speaker: 'player', text: '这件事我愿意接下,你把关键要点交给我。' }, { speaker: 'npc', speakerName: '柳无声', text: '先去竹林查清密信来源。' }, ], options: [ createOption('npc_chat', '这件事里你最担心哪一步'), createOption('npc_chat', '我回来时你最想先知道什么'), ], npcChatState: { npcId: 'npc-liu', npcName: '柳无声', turnCount: 2, customInputPlaceholder: '输入你想对 TA 说的话', pendingQuestOffer: null, }, }; } function createQuestGoalStack(quest: QuestLogEntry): GoalStackState { return { northStarGoal: null, activeGoal: { id: `goal-${quest.id}`, sourceKind: 'quest', sourceId: quest.id, layer: 'active_contract', track: 'side', title: quest.title, promiseText: quest.summary, whyNow: quest.description, nextStepText: '去竹林查清密信来源。', sceneHint: '竹林古道', npcHint: quest.issuerNpcName, progressLabel: '0/1', status: 'active', urgency: 'medium', relatedThreadIds: [], }, immediateStepGoal: null, supportGoals: [], }; } const QUEST_GOAL_PULSE: GoalPulseEvent = { id: 'pulse-quest-liu-1', goalId: 'goal-quest-liu-1', pulseType: 'progress', title: '任务更新', detail: '任务情报刚刚更新。', track: 'side', }; type QuestOfferHarnessProps = { battleRewardUi?: ComponentProps['battleRewardUi']; initialQuests?: QuestLogEntry[]; isLoading?: boolean; }; function QuestOfferHarness({ battleRewardUi, initialQuests, isLoading = false, }: QuestOfferHarnessProps = {}) { const pendingQuest = createPendingQuest(); const [currentStory, setCurrentStory] = useState( createPendingQuestStory(pendingQuest), ); const [quests, setQuests] = useState(initialQuests ?? []); const acceptPendingOffer = vi.fn(() => { queueMicrotask(() => { setQuests([pendingQuest]); setCurrentStory(createAcceptedQuestStory()); }); return pendingQuest.id; }); return ( undefined} onChoice={() => undefined} onSubmitNpcChatInput={() => true} onExitNpcChat={() => true} onOpenCharacter={() => undefined} onOpenInventory={() => undefined} playerCharacter={createCharacter()} worldType={WorldType.WUXIA} quests={quests} questUi={{ acknowledgeQuestCompletion: () => undefined, claimQuestReward: () => null, }} npcChatQuestOfferUi={{ replacePendingOffer: () => false, abandonPendingOffer: () => false, acceptPendingOffer, }} goalStack={{ ...createQuestGoalStack(pendingQuest), }} goalPulse={QUEST_GOAL_PULSE} onDismissGoalPulse={() => undefined} battleRewardUi={ 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} /> ); } beforeAll(() => { if (!HTMLElement.prototype.scrollTo) { HTMLElement.prototype.scrollTo = () => undefined; } }); function findNearestClassName(element: HTMLElement, className: string) { let current: HTMLElement | null = element; while (current) { if (current.className.includes(className)) { return current.className; } current = current.parentElement; } return ''; } test('quest offer accept button reuses the shared accepted-quest follow-up chain', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: /查看任务/u })); const affinityRewardClassName = findNearestClassName( await screen.findByText('好感度'), 'bg-rose-500/8', ); const currencyRewardClassName = findNearestClassName( screen.getByText('货币'), 'bg-amber-500/8', ); const experienceRewardClassName = findNearestClassName( screen.getByText('经验'), 'bg-sky-500/8', ); expect(affinityRewardClassName).toContain('border-rose-300/18'); expect(currencyRewardClassName).toContain('border-amber-300/18'); expect(experienceRewardClassName).toContain('border-sky-400/18'); const acceptButton = await screen.findByRole('button', { name: '领取任务' }); expect(acceptButton.className).toContain( 'platform-action-button--editor-dark', ); await user.click(acceptButton); expect((await screen.findAllByText('任务进度:0/1')).length).toBeGreaterThan( 0, ); const pulsePanel = screen.getByText('任务情报刚刚更新。').closest('div'); expect(pulsePanel?.className).toContain('bg-black/25'); const hintPanel = screen .getByText('地点:竹林古道 · 相关人物:柳无声') .closest('div'); expect(hintPanel?.className).toContain('bg-black/25'); const rewardEmptyState = screen.getByText('该任务没有物品奖励。'); const rewardCacheClassName = findNearestClassName( screen.getByText('奖励缓存'), 'bg-black/25', ); expect(rewardCacheClassName).toContain('border-amber-300/15'); expect(rewardEmptyState.className).toContain('platform-empty-state'); expect(rewardEmptyState.className).toContain('bg-black/20'); await user.click(screen.getByRole('button', { name: '查看任务' })); const questLogPanel = await screen.findByText('任务日志'); const questLogDialog = questLogPanel.closest('.pixel-modal-shell'); expect(questLogDialog).toBeTruthy(); if (!(questLogDialog instanceof HTMLElement)) { throw new Error('未找到任务日志弹窗'); } const questArticle = within(questLogDialog) .getByText('任务描述:') .closest('article'); const questStatusBadge = within(questLogDialog).getByText('进行中'); expect(questLogPanel).toBeTruthy(); expect(questArticle?.className).toContain('bg-black/25'); expect(questStatusBadge.className).toContain('rounded-full'); expect(questStatusBadge.className).toContain('font-black'); expect(questStatusBadge.className).toContain('bg-sky-500/10'); expect(screen.getAllByText('竹林密信').length).toBeGreaterThan(0); expect(screen.queryByText('待领取')).toBeNull(); expect(screen.getByText('这件事里你最担心哪一步')).toBeTruthy(); }); test('quest log empty state reuses dark PlatformEmptyState chrome', async () => { const user = userEvent.setup(); render(); const taskDockButton = screen.getByText('竹林密信').closest('button'); expect(taskDockButton).toBeTruthy(); if (!(taskDockButton instanceof HTMLElement)) { throw new Error('未找到任务入口'); } await user.click(taskDockButton); const emptyState = await screen.findByText('暂无活跃任务。'); expect(emptyState.className).toContain('platform-empty-state'); expect(emptyState.className).toContain('bg-black/20'); }); test('quest reward strip reuses dark PlatformSubpanel and quantity badge chrome', async () => { const user = userEvent.setup(); render(); const taskDockButton = screen.getByText('竹林密信').closest('button'); expect(taskDockButton).toBeTruthy(); if (!(taskDockButton instanceof HTMLElement)) { throw new Error('未找到任务入口'); } await user.click(taskDockButton); const questLogPanel = await screen.findByText('任务日志'); const questLogDialog = questLogPanel.closest('.pixel-modal-shell'); expect(questLogDialog).toBeTruthy(); if (!(questLogDialog instanceof HTMLElement)) { throw new Error('未找到任务日志弹窗'); } const rewardStripClassName = findNearestClassName( within(questLogDialog).getByText('任务奖励'), 'bg-black/25', ); const rewardItemButton = within(questLogDialog).getByRole('button', { name: /查看任务奖励 青玉药丸/u, }); const quantityBadge = within(rewardItemButton).getByText('2'); expect(rewardStripClassName).toContain('border-amber-300/10'); expect(quantityBadge.className).toContain('bg-black/70'); await user.click(rewardItemButton); expect(await screen.findByLabelText('关闭奖励物品')).toBeTruthy(); }); test('adventure statistics panel reuses dark PlatformSubpanel chrome', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '打开设置' })); const statsEntryButton = await screen.findByRole('button', { name: /运行统计/u, }); expect(statsEntryButton.className).toContain('bg-black/25'); expect(statsEntryButton.className).toContain('border-white/10'); await user.click(statsEntryButton); const statsTitle = await screen.findByText('冒险统计'); const statsDialog = statsTitle.closest('.pixel-modal-shell'); expect(statsDialog).toBeTruthy(); if (!(statsDialog instanceof HTMLElement)) { throw new Error('未找到冒险统计弹窗'); } const overviewClassName = findNearestClassName( within(statsDialog).getByText('冒险总览'), 'bg-black/25', ); const playTimeClassName = findNearestClassName( within(statsDialog).getByText('游戏时长'), 'bg-black/25', ); expect(overviewClassName).toContain('border-amber-300/15'); expect(playTimeClassName).toContain('border-white/10'); }); test('settings save-disabled hint reuses dark PlatformEmptyState chrome', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: '打开设置' })); const hint = await screen.findByText( '故事内容仍在加载或流式传输时,保存功能暂时禁用。', ); expect(hint.className).toContain('platform-empty-state'); expect(hint.className).toContain('border-dashed'); expect(hint.className).toContain('bg-black/20'); expect(hint.className).toContain('rounded-xl'); }); test('quest completion notice reuses dark PlatformSubpanel chrome', async () => { render(); const noticeText = await screen.findByText('可前往任务日志领取奖励。'); const noticeClassName = findNearestClassName(noticeText, 'bg-black/25'); const openQuestLogButton = screen.getByRole('button', { name: '打开任务日志' }); expect(screen.getByText('奖励已准备')).toBeTruthy(); expect(noticeClassName).toContain('border-emerald-400/15'); expect(openQuestLogButton.className).toContain( 'platform-action-button--editor-dark', ); }); test('battle reward modal reuses dark PlatformSubpanel chrome', async () => { const user = userEvent.setup(); render( undefined, }} />, ); const rewardTitle = await screen.findByText('战斗奖励'); const rewardDialog = rewardTitle.closest('.pixel-modal-shell'); expect(rewardDialog).toBeTruthy(); if (!(rewardDialog instanceof HTMLElement)) { throw new Error('未找到战斗奖励弹窗'); } const battleEndClassName = findNearestClassName( within(rewardDialog).getByText('战斗结束'), 'bg-black/25', ); const lootClassName = findNearestClassName( within(rewardDialog).getByText('战利品'), 'bg-black/25', ); const defeatedBadge = within(rewardDialog).getByText('影狼'); expect(battleEndClassName).toContain('border-emerald-300/15'); expect(lootClassName).toContain('border-amber-300/15'); expect(defeatedBadge.className).toContain('rounded-full'); expect(defeatedBadge.className).toContain('font-black'); expect(defeatedBadge.className).toContain('bg-emerald-500/10'); await user.click( within(rewardDialog).getByRole('button', { name: /查看奖励物品 青玉药丸/u, }), ); const itemCloseButton = await screen.findByLabelText('关闭奖励物品'); const itemDialog = itemCloseButton.closest('.pixel-modal-shell'); expect(itemDialog).toBeTruthy(); if (!(itemDialog instanceof HTMLElement)) { throw new Error('未找到奖励物品详情弹窗'); } const itemDescriptionClassName = findNearestClassName( within(itemDialog).getByText('战斗后取得的测试奖励。'), 'bg-black/25', ); const itemTagsClassName = findNearestClassName( within(itemDialog).getByText(/标签:/u), 'bg-black/25', ); expect(itemDescriptionClassName).toContain('border-white/10'); expect(itemTagsClassName).toContain('border-white/10'); });