Files
Genarrative/src/components/rpg-runtime-panels/RpgAdventurePanel.questOffer.test.tsx
kdletters ab5a0efe50 继续收口运行态浮层暗色动作与行卡
统一运行态浮层里的标准暗色动作按钮到共享 PlatformActionButton
统一设置面板里的运行统计入口到共享 PlatformSubpanel 按钮壳
补充 PlatformUiKit 收口计划、共享决策记录与 quest offer 组件测试护栏
2026-06-11 02:33:31 +08:00

581 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @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<typeof RpgAdventurePanel>['battleRewardUi'];
initialQuests?: QuestLogEntry[];
isLoading?: boolean;
};
function QuestOfferHarness({
battleRewardUi,
initialQuests,
isLoading = false,
}: QuestOfferHarnessProps = {}) {
const pendingQuest = createPendingQuest();
const [currentStory, setCurrentStory] = useState<StoryMoment>(
createPendingQuestStory(pendingQuest),
);
const [quests, setQuests] = useState<QuestLogEntry[]>(initialQuests ?? []);
const acceptPendingOffer = vi.fn(() => {
queueMicrotask(() => {
setQuests([pendingQuest]);
setCurrentStory(createAcceptedQuestStory());
});
return pendingQuest.id;
});
return (
<RpgAdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={isLoading}
displayedOptions={currentStory.options}
hideOptions={false}
canRefreshOptions={false}
onRefreshOptions={() => 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(<QuestOfferHarness />);
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(<QuestOfferHarness />);
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(<QuestOfferHarness initialQuests={[createQuestWithRewardItem()]} />);
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(<QuestOfferHarness />);
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(<QuestOfferHarness isLoading />);
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(<QuestOfferHarness initialQuests={[createCompletedQuest()]} />);
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(
<QuestOfferHarness
battleRewardUi={{
reward: {
id: 'battle-reward-1',
defeatedHostileNpcs: [{ id: 'shadow-wolf', name: '影狼' }],
items: [createRewardItem()],
},
dismiss: () => 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');
});