收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
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,
|
||||
@@ -78,16 +81,58 @@ function createPendingQuest(): QuestLogEntry {
|
||||
};
|
||||
}
|
||||
|
||||
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', '更换任务');
|
||||
const replaceOption = createOption(
|
||||
'npc_chat_quest_offer_replace',
|
||||
'更换任务',
|
||||
);
|
||||
replaceOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'replace',
|
||||
};
|
||||
const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务');
|
||||
const abandonOption = createOption(
|
||||
'npc_chat_quest_offer_abandon',
|
||||
'放弃任务',
|
||||
);
|
||||
abandonOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'abandon',
|
||||
};
|
||||
@@ -96,7 +141,11 @@ function createPendingQuestStory(quest: QuestLogEntry): StoryMoment {
|
||||
text: '柳无声把真正的委托说了出来。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。' },
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '柳无声',
|
||||
text: '这件事我只想正式托付给你。',
|
||||
},
|
||||
],
|
||||
options: [viewOption, replaceOption, abandonOption],
|
||||
npcChatState: {
|
||||
@@ -111,12 +160,16 @@ function createPendingQuestStory(quest: QuestLogEntry): StoryMoment {
|
||||
};
|
||||
}
|
||||
|
||||
function createAcceptedQuestStory(quest: QuestLogEntry): StoryMoment {
|
||||
function createAcceptedQuestStory(): StoryMoment {
|
||||
return {
|
||||
text: '柳无声把接下来的线索正式交给了你。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '这件事我只想正式托付给你。' },
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '柳无声',
|
||||
text: '这件事我只想正式托付给你。',
|
||||
},
|
||||
{ speaker: 'player', text: '这件事我愿意接下,你把关键要点交给我。' },
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '先去竹林查清密信来源。' },
|
||||
],
|
||||
@@ -134,16 +187,60 @@ function createAcceptedQuestStory(quest: QuestLogEntry): StoryMoment {
|
||||
};
|
||||
}
|
||||
|
||||
function QuestOfferHarness() {
|
||||
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[]>([]);
|
||||
const [quests, setQuests] = useState<QuestLogEntry[]>(initialQuests ?? []);
|
||||
const acceptPendingOffer = vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
setQuests([pendingQuest]);
|
||||
setCurrentStory(createAcceptedQuestStory(pendingQuest));
|
||||
setCurrentStory(createAcceptedQuestStory());
|
||||
});
|
||||
return pendingQuest.id;
|
||||
});
|
||||
@@ -152,7 +249,7 @@ function QuestOfferHarness() {
|
||||
<RpgAdventurePanel
|
||||
aiError={null}
|
||||
currentStory={currentStory}
|
||||
isLoading={false}
|
||||
isLoading={isLoading}
|
||||
displayedOptions={currentStory.options}
|
||||
hideOptions={false}
|
||||
canRefreshOptions={false}
|
||||
@@ -175,17 +272,16 @@ function QuestOfferHarness() {
|
||||
acceptPendingOffer,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
immediateStepGoal: null,
|
||||
supportGoals: [],
|
||||
...createQuestGoalStack(pendingQuest),
|
||||
}}
|
||||
goalPulse={null}
|
||||
goalPulse={QUEST_GOAL_PULSE}
|
||||
onDismissGoalPulse={() => undefined}
|
||||
battleRewardUi={{
|
||||
reward: null,
|
||||
dismiss: () => undefined,
|
||||
}}
|
||||
battleRewardUi={
|
||||
battleRewardUi ?? {
|
||||
reward: null,
|
||||
dismiss: () => undefined,
|
||||
}
|
||||
}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
playerMana={20}
|
||||
@@ -221,16 +317,251 @@ beforeAll(() => {
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
await user.click(await screen.findByRole('button', { name: '领取任务' }));
|
||||
|
||||
expect(await screen.findByText('任务进度:0/1')).toBeTruthy();
|
||||
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: '打开设置' }));
|
||||
await user.click(await screen.findByRole('button', { name: /运行统计/u }));
|
||||
|
||||
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');
|
||||
|
||||
expect(screen.getByText('奖励已准备')).toBeTruthy();
|
||||
expect(noticeClassName).toContain('border-emerald-400/15');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user