Files
Genarrative/src/components/puzzle-result/PuzzleResultView.test.tsx
2026-04-29 20:56:59 +08:00

630 lines
18 KiB
TypeScript

// @vitest-environment jsdom
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import * as puzzleWorksService from '../../services/puzzle-works';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import { PuzzleResultView } from './PuzzleResultView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
puzzleAssetClient: {
listHistoryAssets: vi.fn(),
},
}));
vi.mock('../../services/puzzle-works', () => ({
updatePuzzleWork: vi.fn(),
}));
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
function createSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const baseSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-1',
currentTurn: 2,
progressPercent: 88,
stage: 'ready_to_publish',
anchorPack: {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雨夜猫咪',
status: 'confirmed',
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
draft: {
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雨夜猫咪',
status: 'confirmed',
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'ready',
metadata: null,
},
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-26T10:00:00.000Z',
};
const session = {
...baseSession,
resultPreview: {
draft: baseSession.draft!,
publishReady: true,
blockers: [],
qualityFindings: [],
},
...overrides,
} satisfies PuzzleAgentSessionSnapshot;
return session;
}
describe('PuzzleResultView', () => {
test('auto saves renamed title to the puzzle work profile', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.change(screen.getByDisplayValue('雨夜猫街'), {
target: { value: '暖灯猫街' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levelName: '暖灯猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜'],
}),
);
});
test('uses one ordered list without tabs or persistent publish validation', () => {
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '基本信息' })).toBeNull();
expect(screen.queryByRole('button', { name: '拼图图片' })).toBeNull();
const html = document.body.textContent ?? '';
expect(html.indexOf('关卡名称')).toBeLessThan(html.indexOf('画面预览'));
expect(html.indexOf('画面预览')).toBeLessThan(html.indexOf('画面描述'));
expect(html.indexOf('画面描述')).toBeLessThan(html.indexOf('重新生成画面'));
expect(html.indexOf('重新生成画面')).toBeLessThan(html.indexOf('题材标签'));
expect(screen.queryByText('作者预览')).toBeNull();
expect(screen.queryByText('发布校验')).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});
test('edits theme tags with chips instead of a persistent tag input', () => {
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(screen.queryByLabelText('新题材标签')).toBeNull();
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
expect(screen.queryByText('猫咪')).toBeNull();
expect(screen.getByText('雨夜')).toBeTruthy();
fireEvent.click(screen.getByLabelText('新增题材标签'));
fireEvent.change(screen.getByLabelText('新题材标签'), {
target: { value: '暖灯' },
});
fireEvent.click(screen.getByRole('button', { name: '添加' }));
expect(screen.getByText('暖灯')).toBeTruthy();
expect(screen.queryByLabelText('新题材标签')).toBeNull();
});
test('shows blockers only after clicking publish and blocks publish action', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession({
resultPreview: {
draft: createSession().draft!,
publishReady: false,
blockers: [
{
id: 'missing-cover',
code: 'missing-cover',
message: '请先选择正式图',
},
],
qualityFindings: [],
},
})}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
expect(screen.queryByText('请先选择正式图')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(within(dialog).getByText('请先选择正式图')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '发布到广场' }));
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('starts work test from the current editable draft', () => {
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.change(screen.getByDisplayValue('雨夜猫街'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByLabelText('新增题材标签'));
fireEvent.change(screen.getByLabelText('新题材标签'), {
target: { value: '暖灯' },
});
fireEvent.click(screen.getByRole('button', { name: '添加' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '暖灯猫街',
summary: '一只猫在雨夜灯牌下回头。',
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
);
});
test('auto saves edited picture description to the puzzle work profile', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
summary: '一只猫在雨夜灯牌下回头。',
}),
);
});
test('requires at least three theme tags before publish can pass', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(
within(dialog).getByText('正式标签数量必须在 3 到 6 之间。'),
).toBeTruthy();
expect(
(
within(dialog).getByRole('button', {
name: '发布到广场',
}) as HTMLButtonElement
).disabled,
).toBe(true);
});
test('publishes with the edited picture description', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession({
draft: {
...createSession().draft!,
themeTags: ['猫咪', '雨夜', '暖灯'],
},
resultPreview: {
draft: {
...createSession().draft!,
themeTags: ['猫咪', '雨夜', '暖灯'],
},
publishReady: true,
blockers: [],
qualityFindings: [],
},
})}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
'button',
{ name: '发布到广场' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'publish_puzzle_work',
levelName: '雨夜猫街',
summary: '一只猫在雨夜灯牌下回头。',
themeTags: ['猫咪', '雨夜', '暖灯'],
});
});
test('auto saves added and removed theme tags', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByLabelText('新增题材标签'));
fireEvent.change(screen.getByLabelText('新题材标签'), {
target: { value: '暖灯' },
});
fireEvent.click(screen.getByRole('button', { name: '添加' }));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
);
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
themeTags: ['雨夜', '暖灯'],
}),
);
});
test('generates one image from the picture description and replaces current image', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
expect(screen.getByText('画面描述')).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.queryByText(//u)).toBeNull();
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
candidateCount: 1,
});
});
test('selects a history puzzle asset as reference image for the next generation', async () => {
const onExecuteAction = vi.fn();
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
{
assetObjectId: 'asset-history-1',
assetKind: 'puzzle_cover_image',
imageSrc: '/generated-puzzle-assets/history/image.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: 'puzzle-session-1',
createdAt: '2026-04-27T10:00:00.000Z',
updatedAt: '2026-04-27T10:00:00.000Z',
},
]);
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByLabelText('从历史拼图素材库选择'));
const dialog = await screen.findByRole('dialog', {
name: '选择历史拼图素材',
});
fireEvent.click(
await within(dialog).findByRole('button', { name: / user-1/u }),
);
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: '选择历史拼图素材' }),
).toBeNull();
});
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onExecuteAction).toHaveBeenLastCalledWith({
action: 'generate_puzzle_images',
promptText: '屋檐下的猫与暖灯街角。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
candidateCount: 1,
});
});
test('refreshes the current formal image when session cover image changes', async () => {
const { rerender } = render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(
screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'),
).toBe('/puzzle/candidate-1.png');
rerender(
<PuzzleResultView
session={createSession({
draft: {
...createSession().draft!,
candidates: [
{
candidateId: 'candidate-2',
imageSrc: '/puzzle/candidate-2.png',
assetId: 'asset-2',
prompt: '新图',
actualPrompt: '新图',
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-2',
coverImageSrc: '/puzzle/candidate-2.png',
coverAssetId: 'asset-2',
},
updatedAt: '2026-04-27T11:11:11.000Z',
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
await waitFor(() => {
expect(
screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'),
).toBe('/puzzle/candidate-2.png');
});
});
test('prefers the selected latest candidate image when coverImageSrc lags behind', async () => {
render(
<PuzzleResultView
session={createSession({
draft: {
...createSession().draft!,
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '旧图',
actualPrompt: '旧图',
sourceType: 'generated',
selected: false,
},
{
candidateId: 'candidate-2',
imageSrc: '/puzzle/candidate-2.png',
assetId: 'asset-2',
prompt: '新图',
actualPrompt: '新图',
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-2',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
await waitFor(() => {
expect(
screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src'),
).toBe('/puzzle/candidate-2.png');
});
});
});