收口前端平台组件库能力

新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-10 10:24:18 +08:00
parent a4ee6ff698
commit 1ad25e30f8
226 changed files with 23364 additions and 7825 deletions

View File

@@ -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');
});