This commit is contained in:
2026-04-18 13:05:29 +08:00
parent 09d4c0c31b
commit 5032701c38
77 changed files with 8538 additions and 2413 deletions

View File

@@ -1,483 +1,161 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
vi.mock('../../services/aiService', () => ({
getCustomWorldAgentCardDetail: vi.fn(),
}));
vi.mock('../CustomWorldRoleAssetStudioModal', () => ({
CustomWorldRoleAssetStudioModal: ({
role,
onPublishSuccess,
}: {
role: { name: string };
onPublishSuccess?: (
payload: {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
},
options?: { closeAfterSync?: boolean },
) => void;
}) => (
<div>
<div>{role.name}</div>
<button
type="button"
onClick={() =>
onPublishSuccess?.(
{
roleId: 'character-1',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
},
{
closeAfterSync: true,
},
)
}
>
</button>
</div>
),
}));
const detailById: Record<string, CustomWorldDraftCardDetail> = {
'world-foundation': {
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
sections: [
{
id: 'title',
label: '标题',
value: '潮雾列岛',
},
{
id: 'summary',
label: '摘要',
value: '这是第一版世界底稿。',
},
],
linkedIds: ['thread-1', 'character-1'],
locked: false,
editable: true,
editableSectionIds: ['title', 'summary'],
warningMessages: [],
},
'character-1': {
id: 'character-1',
kind: 'character',
title: '沈砺',
sections: [
{
id: 'name',
label: '角色名',
value: '沈砺',
},
{
id: 'summary',
label: '角色摘要',
value: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
},
'character-2': {
id: 'character-2',
kind: 'character',
title: '顾潮音',
sections: [
{
id: 'name',
label: '角色名',
value: '顾潮音',
},
{
id: 'summary',
label: '角色摘要',
value: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
},
};
const baseSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1',
stage: 'object_refining',
focusCardId: 'world-foundation',
currentTurn: 4,
anchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有通路都要向未知代价借路。',
desiredExperience: '压迫、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的旧航路继承人。',
corePursuit: '查清沉船夜背后的真相。',
fearOfLoss: '一旦失败,就会再次失去唯一还活着的旧友。',
},
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
progressPercent: 58,
lastAssistantReply: '世界和玩家视角已经有了,下一步我想把最明面的冲突钉住。',
stage: 'collecting_intent',
focusCardId: null,
creatorIntent: {},
creatorIntentReadiness: {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
isReady: false,
completedKeys: ['world_hook', 'player_premise'],
missingKeys: ['theme_and_tone', 'core_conflict', 'relationship_seed', 'iconic_element'],
},
anchorPack: {},
lockState: {},
draftProfile: {
name: '潮雾列岛',
storyNpcs: [
{
id: 'character-1',
name: '沈砺',
title: '守灯会旧友',
role: '航道向导',
publicMask: '守灯会里最熟悉旧航道的人。',
hiddenHook: '暗地里正在为沉船商盟引路。',
relationToPlayer: '旧友兼宿敌',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
},
draftProfile: null,
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'summary',
text: '当前底稿已经可以继续精修。',
createdAt: new Date().toISOString(),
kind: 'chat',
text: '先告诉我你想做一个怎样的世界。',
createdAt: '2026-04-17T12:00:00.000Z',
relatedOperationId: null,
},
],
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与航道争夺',
summary: '世界总卡已经生成。',
status: 'warning',
linkedIds: ['thread-1', 'character-1'],
warningCount: 1,
},
{
id: 'character-1',
kind: 'character',
title: '沈砺',
subtitle: '守灯会旧友',
summary: '他最了解旧航道,也最可能先背叛。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
draftCards: [],
pendingClarifications: [],
suggestedActions: [
{
id: 'request-summary',
type: 'request_summary',
label: '总结当前世界底稿',
targetId: null,
},
],
recommendedReplies: [
'现在开始生成草稿',
'先总结一下当前设定',
'我还想再补充一点',
],
suggestedActions: [],
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [
{
roleId: 'character-1',
roleName: '沈砺',
roleKind: 'story',
priorityTier: 'featured',
portraitPath: null,
generatedVisualAssetId: null,
generatedAnimationSetId: null,
status: 'missing',
missingAnimations: ['idle', 'run', 'attack', 'hurt', 'die'],
nextPointCost: 20,
},
],
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
updatedAt: '2026-04-14T10:00:00.000Z',
updatedAt: '2026-04-17T12:00:00.000Z',
};
beforeEach(() => {
vi.mocked(getCustomWorldAgentCardDetail).mockImplementation(
async (_sessionId, cardId): Promise<CustomWorldDraftCardDetail> =>
detailById[cardId] ?? detailById['world-foundation']!,
);
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
});
test('workspace loads detail, saves edits, opens generate actions, and reflects updated drawer cards', async () => {
test('workspace sends summary request from progress area', async () => {
const user = userEvent.setup();
const onExecuteAction = vi.fn();
const onSubmitMessage = vi.fn();
const { rerender } = render(
render(
<CustomWorldAgentWorkspace
session={baseSession}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
onSubmitMessage={onSubmitMessage}
onExecuteAction={() => {}}
/>,
);
await waitFor(() => {
expect(getCustomWorldAgentCardDetail).toHaveBeenCalledWith(
baseSession.sessionId,
'world-foundation',
);
});
await user.click(screen.getByRole('button', { name: '总结当前设定' }));
expect(screen.getByText('卡片详情')).toBeTruthy();
expect(screen.queryByPlaceholderText('输入消息')).toBeNull();
expect(screen.queryByText('当前底稿已经可以继续精修。')).toBeNull();
expect(onSubmitMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: '请总结一下当前已经成形的世界设定。',
}),
);
});
await user.click(screen.getByRole('button', { name: '编辑设定' }));
const summaryInput = screen.getByLabelText('摘要');
await user.clear(summaryInput);
await user.type(summaryInput, '这是更新后的世界摘要。');
await user.click(screen.getByRole('button', { name: '保存' }));
test('workspace enables quick fill after at least two turns and submits quick fill request', async () => {
const user = userEvent.setup();
const onSubmitMessage = vi.fn();
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'update_draft_card',
cardId: 'world-foundation',
sections: [
{
sectionId: 'title',
value: '潮雾列岛',
},
{
sectionId: 'summary',
value: '这是更新后的世界摘要。',
},
],
});
render(
<CustomWorldAgentWorkspace
session={baseSession}
activeOperation={null}
onBack={() => {}}
onSubmitMessage={onSubmitMessage}
onExecuteAction={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(getCustomWorldAgentCardDetail).toHaveBeenLastCalledWith(
baseSession.sessionId,
'character-1',
);
});
await user.click(screen.getByRole('button', { name: '补全剩余设定' }));
const [generateCharacterButton] = screen.getAllByRole('button', {
name: '新增角色',
});
await user.click(generateCharacterButton!);
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成角色' }));
expect(onSubmitMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: '请补全剩余设定。',
quickFillRequested: true,
}),
);
});
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_characters',
count: 2,
promptText: null,
anchorCardIds: ['character-1'],
});
const [generateLandmarkButton] = screen.getAllByRole('button', {
name: '新增场景',
});
await user.click(generateLandmarkButton!);
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成场景' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_landmarks',
count: 2,
promptText: null,
anchorCardIds: ['character-1'],
});
const [openRoleAssetsButton] = screen.getAllByRole('button', {
name: '角色资产',
});
await user.click(openRoleAssetsButton!);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_role_assets',
roleIds: ['character-1'],
});
rerender(
test('workspace hides quick fill before two turns', () => {
render(
<CustomWorldAgentWorkspace
session={{
...baseSession,
stage: 'visual_refining',
draftCards: [
...baseSession.draftCards,
{
id: 'character-2',
kind: 'character',
title: '顾潮音',
subtitle: '回潮记录员',
summary: '她会把每一次海雾异常都记到连自己都不愿复看的本子里。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
updatedAt: '2026-04-14T10:05:00.000Z',
}}
activeOperation={{
operationId: 'operation-role-assets',
type: 'generate_role_assets',
status: 'completed',
phaseLabel: '角色资产工坊已就绪',
phaseDetail: '可以开始生成角色主图与动作。',
progress: 100,
error: null,
currentTurn: 1,
}}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
onExecuteAction={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByText('顾潮音')).toBeTruthy();
});
expect(screen.getByText('角色资产工坊:沈砺')).toBeTruthy();
expect(screen.queryByRole('button', { name: '补全剩余设定' })).toBeNull();
});
await user.click(screen.getByRole('button', { name: '模拟同步角色资产' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'sync_role_assets',
roleId: 'character-1',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
});
test('workspace exposes draft action when progress reaches 100', async () => {
const user = userEvent.setup();
const onExecuteAction = vi.fn();
rerender(
render(
<CustomWorldAgentWorkspace
session={{
...baseSession,
stage: 'visual_refining',
draftCards: [
{
...baseSession.draftCards[0]!,
},
{
...baseSession.draftCards[1]!,
subtitle: '守灯会旧友 / 动作已就绪',
assetStatus: 'complete',
assetStatusLabel: '动作已就绪',
},
],
assetCoverage: {
roleAssets: [
{
roleId: 'character-1',
roleName: '沈砺',
roleKind: 'story',
priorityTier: 'featured',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
status: 'complete',
missingAnimations: [],
nextPointCost: 0,
},
],
sceneAssets: [],
allRoleAssetsReady: true,
allSceneAssetsReady: false,
},
draftProfile: {
...baseSession.draftProfile,
storyNpcs: [
{
id: 'character-1',
name: '沈砺',
title: '守灯会旧友',
role: '航道向导',
publicMask: '守灯会里最熟悉旧航道的人。',
hiddenHook: '暗地里正在为沉船商盟引路。',
relationToPlayer: '旧友兼宿敌',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
imageSrc: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
},
],
},
}}
activeOperation={{
operationId: 'operation-sync-role-assets',
type: 'sync_role_assets',
status: 'completed',
phaseLabel: '角色资产已同步',
phaseDetail: '角色资产已经写回草稿。',
progress: 100,
error: null,
progressPercent: 100,
stage: 'foundation_review',
}}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
await waitFor(() => {
expect(screen.getAllByText('动作已就绪').length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: '生成游戏设定草稿' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'draft_foundation',
});
});