/* @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: () =>
角色预览
,
}));
vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
{npc.name}
),
}));
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: '玩家最初落脚的旧灯塔内院。',
dangerLevel: 'medium',
},
anchorContent: {
worldPromise: {
hook: '被海雾反复改写航路的群岛世界。',
differentiator: '旧灯塔与禁航令共同决定谁能活着穿过去。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的守灯人继承者。',
corePursuit: '查清沉钟异动与失控航路的真相。',
fearOfLoss: '失去家族留下的最后航路坐标。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['热血少年漫'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '首夜就撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守潮盟与沉钟会争夺航路解释权'],
hiddenCrisis: '有人借假航灯持续清洗整片群岛的旧证据',
firstTouchedConflict: '玩家回港当夜就被卷进禁航区封锁',
},
keyRelationships: [
{
pairs: '玩家 vs 沈砺',
relationshipType: '旧友互疑',
secretOrCost: '他掌握沉船夜的关键视角',
},
],
hiddenLines: {
hiddenTruths: ['沉钟异动和旧案灭口是同一条线'],
misdirectionHints: ['表面看像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
},
landmarks: [
{
id: 'landmark-1',
name: '沉钟栈桥',
description: '旧钟与潮声常年相撞的码头栈桥。',
dangerLevel: 'medium',
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: '继续逼近钟楼深处。',
},
],
},
],
creatorIntent: null,
anchorPack: null,
lockState: null,
ownedSettingLayers: null,
generationMode: 'full',
generationStatus: 'complete',
} as unknown as CustomWorldProfile;
function ResultViewHarness() {
const [profile, setProfile] = useState(baseProfile);
return (
{}}
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((resolve) => {
resolveGeneration = resolve;
}),
);
render();
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();
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(
{}}
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 uses first act image as scene card preview and keeps chapter details out of list', async () => {
const user = userEvent.setup();
render();
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',
);
});
test('readOnly result view hides edit and create actions for agent preview mode', async () => {
const user = userEvent.setup();
render(
{}}
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 keeps publish-enter action clickable and hides sticky publish hints', () => {
render(
{}}
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(
{}}
onProfileChange={() => {}}
compactAgentResultMode
publishReady={false}
publishBlockers={[
'仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
'营地还缺少正式场景图资产,发布前需要先确认营地图。',
]}
previewSourceLabel="服务端预览"
enterWorldActionLabel="发布并进入世界"
onEnterWorld={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '发布并进入世界' }));
expect(
screen.getByRole('dialog', { name: '发布前检查' }),
).toBeTruthy();
expect(screen.getByText(/当前还有 2 个阻断项/u)).toBeTruthy();
expect(
screen.getByText(/仍有角色缺少正式主图或动作资产/u),
).toBeTruthy();
});
test('agent result view keeps publish-enter action enabled when publish gate is clear', () => {
render(
{}}
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);
});