统一运行态浮层里的标准暗色动作按钮到共享 PlatformActionButton 统一设置面板里的运行统计入口到共享 PlatformSubpanel 按钮壳 补充 PlatformUiKit 收口计划、共享决策记录与 quest offer 组件测试护栏
581 lines
17 KiB
TypeScript
581 lines
17 KiB
TypeScript
/* @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');
|
||
});
|