630 lines
18 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|