Files
Genarrative/src/components/CustomWorldResultView.test.tsx
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

585 lines
18 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, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView';
vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
const generatePlayableNpc = vi.fn();
const generateStoryNpc = vi.fn();
const generateLandmark = vi.fn();
const generateSceneImage = vi.fn();
const generateSceneNpc = vi.fn();
return {
rpgCreationAssetClient: {
generatePlayableNpc,
generateStoryNpc,
generateLandmark,
generateSceneImage,
generateSceneNpc,
},
generateCustomWorldPlayableNpc: generatePlayableNpc,
generateCustomWorldStoryNpc: generateStoryNpc,
generateCustomWorldLandmark: generateLandmark,
generateCustomWorldSceneImage: generateSceneImage,
generateCustomWorldSceneNpc: generateSceneNpc,
};
});
const mockedRpgCreationAssetClient = vi.mocked(
rpgCreationAssetClient.rpgCreationAssetClient,
);
vi.mock('./CharacterAnimator', () => ({
CharacterAnimator: () => <div></div>,
}));
vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
<div>{npc.name}</div>
),
}));
vi.mock('../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (source: string | null | undefined) => ({
resolvedUrl: source?.trim() ?? '',
isResolving: false,
shouldResolve: false,
}),
}));
vi.mock('./rpg-creation-editor/RpgCreationEntityEditorModal', () => ({
RpgCreationEntityEditorModal: () => null,
default: () => null,
}));
function createBackstoryReveal() {
return {
publicSummary: '公开背景',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: 6,
teaser: '表层来意',
content: '表层来意内容',
contextSnippet: '表层来意摘要',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: 12,
teaser: '旧事裂痕',
content: '旧事裂痕内容',
contextSnippet: '旧事裂痕摘要',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: 18,
teaser: '隐藏执念',
content: '隐藏执念内容',
contextSnippet: '隐藏执念摘要',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: 24,
teaser: '最终底牌',
content: '最终底牌内容',
contextSnippet: '最终底牌摘要',
},
],
};
}
function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc {
return {
id,
name,
title: '同行者',
role: '协作战力',
description: `${name}的定位描述`,
backstory: `${name}的背景`,
personality: `${name}的性格`,
motivation: `${name}的动机`,
combatStyle: `${name}的战斗风格`,
initialAffinity: 18,
relationshipHooks: ['关系钩子'],
relations: [],
tags: ['测试'],
backstoryReveal: createBackstoryReveal(),
skills: [
{
id: `${id}-skill-1`,
name: '技能一',
summary: '技能说明一',
style: '起手压制',
},
{
id: `${id}-skill-2`,
name: '技能二',
summary: '技能说明二',
style: '机动周旋',
},
{
id: `${id}-skill-3`,
name: '技能三',
summary: '技能说明三',
style: '爆发终结',
},
],
initialItems: [
{
id: `${id}-item-1`,
name: '物品一',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '物品说明一',
tags: ['测试'],
},
{
id: `${id}-item-2`,
name: '物品二',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '物品说明二',
tags: ['测试'],
},
{
id: `${id}-item-3`,
name: '物品三',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '物品说明三',
tags: ['测试'],
},
],
};
}
const baseProfile = {
id: 'world-1',
settingText: '潮雾群岛上的禁制与旧航道正在一起失衡。',
name: '潮雾群岛',
subtitle: '旧航道与沉钟回响',
summary: '一座正在被旧誓与新利益共同撕扯的群岛世界。',
tone: '压抑、潮湿、带着未解旧伤。',
playerGoal: '找到能让群岛重新稳定的关键节点。',
templateWorldType: 'WUXIA',
majorFactions: ['守潮盟', '沉钟会'],
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
attributeSchema: {},
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
storyNpcs: [
{
...createPlayableRole('story-1', '顾潮音'),
initialAffinity: 6,
},
],
items: [],
camp: {
name: '潮灯居',
description: '玩家最初落脚的旧灯塔内院。',
},
anchorContent: {
worldPromise:
'被海雾反复改写航路的群岛世界,旧灯塔与禁航令共同决定谁能活着穿过去,体验压抑、悬疑、潮湿。',
playerFantasy:
'玩家是被迫返乡的守灯人继承者,追查沉钟异动与失控航路的真相,风险是失去家族留下的最后航路坐标。',
themeBoundary: '压抑、悬疑;潮湿群岛、冷雾港口;避免热血少年漫。',
playerEntryPoint:
'玩家以返乡守灯人继承者身份切入,首夜就撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
coreConflict:
'守潮盟与沉钟会争夺航路解释权,有人借假航灯持续清洗整片群岛的旧证据,玩家回港当夜就被卷进禁航区封锁。',
keyRelationships:
'玩家与沈砺旧友互疑,沈砺掌握沉船夜的关键视角。',
hiddenLines:
'沉钟异动和旧案灭口是同一条线,表面看像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
iconicElements:
'假航灯、沉钟回响、旧灯塔、禁航碑;错误航灯会把船引进必死水域。',
},
landmarks: [
{
id: 'landmark-1',
name: '沉钟栈桥',
description: '旧钟与潮声常年相撞的码头栈桥。',
sceneNpcIds: ['story-1'],
connections: [],
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '沉钟栈桥章节',
summary: '围绕沉钟栈桥推进的三幕结构。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-1',
sceneId: 'landmark-1',
title: '潮声逼近',
summary: '第一幕先把潮声与旧钟压上来。',
stageCoverage: ['opening'],
backgroundImageSrc: '/generated-custom-world-scenes/scene-act-1.png',
backgroundAssetId: 'scene-asset-1',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '接住首幕压力',
transitionHook: '继续逼近钟楼深处。',
},
{
id: 'scene-act-2',
sceneId: 'landmark-1',
title: '钟楼回响',
summary: '第二幕把旧钟与暗线证据推到台前。',
stageCoverage: ['investigation'],
backgroundImageSrc: '/generated-custom-world-scenes/scene-act-2.png',
backgroundAssetId: 'scene-asset-2',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: [],
advanceRule: 'after_clue_found',
actGoal: '找到旧钟证据',
transitionHook: '钟楼深处传来第二次回响。',
},
],
},
],
creatorIntent: null,
anchorPack: null,
lockState: null,
ownedSettingLayers: null,
generationMode: 'full',
generationStatus: 'complete',
} as unknown as CustomWorldProfile;
function ResultViewHarness() {
const [profile, setProfile] = useState(baseProfile);
return (
<RpgCreationResultView
profile={profile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={setProfile}
/>
);
}
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
const user = userEvent.setup();
let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null = null;
mockedRpgCreationAssetClient.generatePlayableNpc.mockImplementation(
() =>
new Promise<CustomWorldPlayableNpc>((resolve) => {
resolveGeneration = resolve;
}),
);
render(<ResultViewHarness />);
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '新增可扮演角色' }));
expect(screen.getByText('新可扮演角色')).toBeTruthy();
expect(screen.getByText('正在整理世界上下文')).toBeTruthy();
const createButton = screen.getByRole('button', { name: '新增可扮演角色' });
expect((createButton as HTMLButtonElement).disabled).toBe(true);
const finishGeneration = resolveGeneration;
if (!finishGeneration) {
throw new Error('expected pending playable generation resolver');
}
(finishGeneration as (value: CustomWorldPlayableNpc) => void)(
createPlayableRole('playable-2', '云止'),
);
await waitFor(() => {
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});
await waitFor(() => {
expect(screen.queryByText('新可扮演角色')).toBeNull();
});
expect(screen.getAllByText('新').length).toBeGreaterThan(0);
});
test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => {
render(<ResultViewHarness />);
expect(screen.getByText('世界承诺')).toBeTruthy();
expect(screen.getByText('玩家幻想')).toBeTruthy();
expect(screen.getByText('主题边界')).toBeTruthy();
expect(screen.getByText('玩家切入口')).toBeTruthy();
expect(screen.getByText('核心冲突')).toBeTruthy();
expect(screen.getByText('关键关系')).toBeTruthy();
expect(screen.getByText('暗线与揭示')).toBeTruthy();
expect(screen.getByText('标志元素')).toBeTruthy();
expect(screen.queryByText('解析字段')).toBeNull();
expect(screen.queryByText('锚点原文')).toBeNull();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getByText(/线/u)).toBeTruthy();
});
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
const user = userEvent.setup();
const profile = {
...baseProfile,
playableNpcs: [
{
...createPlayableRole('playable-portrait', '云止'),
imageSrc: '/generated-characters/playable-portrait/master.png',
generatedVisualAssetId: 'visual-playable-portrait',
},
],
} as CustomWorldProfile;
render(
<RpgCreationResultView
profile={profile}
previewCharacters={[
{
id: 'playable-portrait',
name: '云止',
title: '同行者',
description: '预览角色',
backstory: '预览背景',
personality: '预览性格',
portrait: '/template/portrait.png',
avatar: '/template/avatar.png',
assetFolder: 'test',
assetVariant: 'Hero',
combatTags: [],
skills: [],
adventureOpenings: {},
} as never,
]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: //u }));
const portrait = screen.getByRole('img', { name: '云止' });
expect((portrait as HTMLImageElement).getAttribute('src')).toBe(
'/generated-characters/playable-portrait/master.png',
);
expect(screen.getByText('已生成主图')).toBeTruthy();
});
test('landmark tab previews every generated act image while keeping chapter details out of list', async () => {
const user = userEvent.setup();
render(<ResultViewHarness />);
await user.click(screen.getByRole('button', { name: /\s*2/u }));
expect(screen.queryByText('沉钟栈桥章节')).toBeNull();
expect(screen.queryByText('潮声逼近')).toBeNull();
const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' });
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
'/generated-custom-world-scenes/scene-act-1.png',
);
expect(
(screen.getByRole('img', {
name: '沉钟栈桥-潮声逼近',
}) as HTMLImageElement).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-1.png');
expect(
(screen.getByRole('img', {
name: '沉钟栈桥-钟楼回响',
}) as HTMLImageElement).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-1.png');
});
test('readOnly result view hides edit and create actions for agent preview mode', async () => {
const user = userEvent.setup();
render(
<RpgCreationResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
readOnly
compactAgentResultMode
/>,
);
expect(screen.queryByRole('button', { name: /^$/u })).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
expect(screen.queryByRole('button', { name: '新增可扮演角色' })).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
expect(screen.queryByRole('button', { name: //u })).toBeNull();
});
test('agent result view shows error when entity generation returns no new profile', async () => {
const user = userEvent.setup();
render(
<RpgCreationResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
compactAgentResultMode
onGenerateEntity={async () => {}}
/>,
);
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '新增场景角色' }));
expect(
await screen.findByText(//u),
).toBeTruthy();
});
test('agent result view keeps publish-enter action clickable and hides sticky publish hints', () => {
render(
<RpgCreationResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
compactAgentResultMode
publishReady={false}
publishBlockers={[
'仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
'营地还缺少正式场景图资产,发布前需要先确认营地图。',
]}
qualityFindings={[
{
id: 'role-assets-pending',
severity: 'warning',
code: 'role_assets_pending',
message: '仍有角色资产未完全补齐。',
},
]}
previewSourceLabel="服务端预览"
enterWorldActionLabel="发布并进入世界"
onEnterWorld={() => {}}
/>,
);
const actionButton = screen.getByRole('button', {
name: '发布并进入世界',
});
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByText(//u)).toBeNull();
expect(screen.queryByText(/ 2 /u)).toBeNull();
});
test('agent result view opens publish blocker dialog only when user clicks publish action', async () => {
const user = userEvent.setup();
render(
<RpgCreationResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
compactAgentResultMode
publishReady={false}
publishBlockers={[
'仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
'营地还缺少正式场景图资产,发布前需要先确认营地图。',
]}
previewSourceLabel="服务端预览"
enterWorldActionLabel="发布并进入世界"
onEnterWorld={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '发布并进入世界' }));
expect(screen.getByRole('dialog', { name: '发布作品' })).toBeTruthy();
expect(screen.getByText('发布检查')).toBeTruthy();
expect(screen.getByText('封面设置')).toBeTruthy();
expect(
screen.getByText(//u),
).toBeTruthy();
});
test('agent result view keeps publish-enter action enabled when publish gate is clear', () => {
render(
<RpgCreationResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
compactAgentResultMode
publishReady
publishBlockers={[]}
qualityFindings={[
{
id: 'scene-assets-pending',
severity: 'warning',
code: 'scene_assets_pending',
message: '仍有场景分幕图未补齐。',
},
]}
previewSourceLabel="服务端预览"
enterWorldActionLabel="发布并进入世界"
onEnterWorld={() => {}}
/>,
);
expect(screen.getByText(/ 1 warning /u)).toBeTruthy();
const actionButton = screen.getByRole('button', {
name: '发布并进入世界',
});
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
});