1ad25e30f8
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
730 lines
22 KiB
TypeScript
730 lines
22 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { useState } from 'react';
|
|
import { expect, test, vi } from 'vitest';
|
|
|
|
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
|
|
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
|
|
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
|
|
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();
|
|
const generateOpeningCg = vi.fn();
|
|
|
|
return {
|
|
rpgCreationAssetClient: {
|
|
generatePlayableNpc,
|
|
generateStoryNpc,
|
|
generateLandmark,
|
|
generateSceneImage,
|
|
generateSceneNpc,
|
|
generateOpeningCg,
|
|
},
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ResultViewRehydratingHarness() {
|
|
const [profile, setProfile] = useState(baseProfile);
|
|
const [rehydrated, setRehydrated] = useState(false);
|
|
|
|
return (
|
|
<div>
|
|
<div data-testid="rehydrated">{rehydrated ? 'yes' : 'no'}</div>
|
|
<RpgCreationResultView
|
|
profile={profile}
|
|
previewCharacters={[]}
|
|
isGenerating={false}
|
|
progress={0}
|
|
progressLabel=""
|
|
error={null}
|
|
onBack={() => {}}
|
|
onProfileChange={(nextProfile) => {
|
|
setProfile(nextProfile);
|
|
if (!nextProfile.openingCg) {
|
|
return;
|
|
}
|
|
|
|
window.setTimeout(() => {
|
|
const normalized = normalizeCustomWorldProfileRecord(nextProfile);
|
|
if (normalized) {
|
|
setProfile(normalized);
|
|
}
|
|
setRehydrated(true);
|
|
}, 0);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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('world tab generates opening cg only after manual click and writes it back to profile', async () => {
|
|
const user = userEvent.setup();
|
|
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
|
|
id: 'opening-cg-1',
|
|
status: 'ready',
|
|
storyboardImageSrc:
|
|
'/generated-custom-world-scenes/world/opening/storyboard.png',
|
|
storyboardAssetId: 'storyboard-1',
|
|
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
|
|
videoAssetId: 'video-1',
|
|
imageModel: 'gpt-image-2',
|
|
videoModel: 'doubao-seedance-2-0-fast-260128',
|
|
aspectRatio: '16:9',
|
|
imageSize: '2k',
|
|
videoResolution: '480p',
|
|
durationSeconds: 15,
|
|
pointCost: 80,
|
|
estimatedWaitMinutes: 10,
|
|
updatedAt: '2026-05-03T00:00:00Z',
|
|
});
|
|
|
|
render(<ResultViewHarness />);
|
|
|
|
expect(mockedRpgCreationAssetClient.generateOpeningCg).not.toHaveBeenCalled();
|
|
|
|
await user.click(screen.getByRole('button', { name: '生成' }));
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
mockedRpgCreationAssetClient.generateOpeningCg,
|
|
).toHaveBeenCalledTimes(1);
|
|
});
|
|
await waitFor(() => {
|
|
expect(
|
|
document.querySelector(
|
|
'video[src="/generated-custom-world-scenes/world/opening/opening.mp4"]',
|
|
),
|
|
).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test('world tab keeps opening cg visible after parent rehydrates normalized profile', async () => {
|
|
const user = userEvent.setup();
|
|
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
|
|
id: 'opening-cg-1',
|
|
status: 'ready',
|
|
storyboardImageSrc:
|
|
'/generated-custom-world-scenes/world/opening/storyboard.png',
|
|
storyboardAssetId: 'storyboard-1',
|
|
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
|
|
videoAssetId: 'video-1',
|
|
imageModel: 'gpt-image-2',
|
|
videoModel: 'doubao-seedance-2-0-fast-260128',
|
|
aspectRatio: '16:9',
|
|
imageSize: '2k',
|
|
videoResolution: '480p',
|
|
durationSeconds: 15,
|
|
pointCost: 80,
|
|
estimatedWaitMinutes: 10,
|
|
updatedAt: '2026-05-03T00:00:00Z',
|
|
});
|
|
|
|
render(<ResultViewRehydratingHarness />);
|
|
|
|
await user.click(screen.getByRole('button', { name: '生成' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('rehydrated').textContent).toBe('yes');
|
|
});
|
|
expect(
|
|
document.querySelector(
|
|
'video[src="/generated-custom-world-scenes/world/opening/opening.mp4"]',
|
|
),
|
|
).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-2.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('发布检查').className).toContain('tracking-[0.18em]');
|
|
expect(screen.getByText('封面设置').className).toContain('tracking-[0.18em]');
|
|
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);
|
|
});
|
|
|
|
test('result view confirms full regeneration with unified dialog', async () => {
|
|
const user = userEvent.setup();
|
|
const handleRegenerate = vi.fn();
|
|
|
|
render(
|
|
<RpgCreationResultView
|
|
profile={baseProfile}
|
|
previewCharacters={[]}
|
|
isGenerating={false}
|
|
progress={0}
|
|
progressLabel=""
|
|
error={null}
|
|
onBack={() => {}}
|
|
onProfileChange={() => {}}
|
|
onRegenerate={handleRegenerate}
|
|
/>,
|
|
);
|
|
|
|
await user.click(screen.getByRole('button', { name: '重新生成' }));
|
|
|
|
const dialog = screen.getByRole('dialog', { name: '重新生成' });
|
|
expect(screen.getByText(/确认重新生成“潮雾群岛”吗/u)).toBeTruthy();
|
|
|
|
await user.click(within(dialog).getByRole('button', { name: '取消' }));
|
|
expect(handleRegenerate).not.toHaveBeenCalled();
|
|
|
|
await user.click(screen.getByRole('button', { name: '重新生成' }));
|
|
await user.click(screen.getByRole('button', { name: '确认重新生成' }));
|
|
|
|
expect(handleRegenerate).toHaveBeenCalledTimes(1);
|
|
});
|