Files
Genarrative/src/components/puzzle-result/PuzzleResultView.test.tsx
kdletters 0a4ccdf45c 继续收口平台分段与泥点确认
新增泥点确认状态机共享 hook 并接入拼图与抓大鹅工作台

将首页发现页与个人中心剩余切换条收口到 PlatformSegmentedTabs

统一平台弹窗 header 关闭入口并补齐相关测试

更新前端组件收口文档与团队决策记录
2026-06-11 01:30:13 +08:00

1804 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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,
refreshKey,
'data-testid': dataTestId,
}: {
src?: string | null;
alt?: string;
className?: string;
refreshKey?: string | number | null;
'data-testid'?: string;
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
data-refresh-key={refreshKey ?? undefined}
data-testid={dataTestId}
/>
) : null,
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
puzzleAssetClient: {
listHistoryAssets: vi.fn(),
},
}));
vi.mock('../../services/puzzle-works', () => ({
updatePuzzleWork: vi.fn(),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src
? `https://signed.example.com/${src.replace(/^\/+/u, '')}`
: '',
isResolving: false,
shouldResolve: Boolean(src?.trim().startsWith('/generated-')),
}),
}));
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function stubReferenceImageUpload(dataUrl: string) {
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result = dataUrl;
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
}
test('renders missing draft notice with shared PlatformSubpanel chrome', () => {
render(
<PuzzleResultView
session={{ ...createSession(), draft: null }}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
const noticePanel = screen
.getByText('还没有可编辑的拼图草稿')
.closest('.platform-subpanel');
expect(noticePanel?.className).toContain('rounded-[1rem]');
expect(noticePanel?.className).toContain('sm:p-5');
expect(noticePanel?.className).toContain('text-[var(--platform-text-base)]');
});
function createSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const anchorPack = {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雨夜猫咪',
status: 'confirmed' as const,
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '屋檐下的猫',
status: 'confirmed' as const,
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '温暖',
status: 'confirmed' as const,
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '雨滴与灯牌',
status: 'confirmed' as const,
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '猫咪、雨夜',
status: 'confirmed' as const,
},
};
const level = {
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
pictureDescription: '屋檐下的猫与暖灯街角。',
pictureReference: null,
uiBackgroundPrompt: null,
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
levelSceneImageSrc: null,
levelSceneImageObjectKey: null,
uiSpritesheetImageSrc: null,
uiSpritesheetImageObjectKey: null,
levelBackgroundImageSrc: null,
levelBackgroundImageObjectKey: null,
backgroundMusic: null,
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated' as const,
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'ready' as const,
};
const baseSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-1',
currentTurn: 2,
progressPercent: 88,
stage: 'ready_to_publish',
anchorPack,
draft: {
workTitle: overrides.draft?.workTitle ?? '暖灯猫街作品',
workDescription:
overrides.draft?.workDescription ?? '一套雨夜猫街主题拼图。',
levelName: level.levelName,
summary: level.pictureDescription,
themeTags: overrides.draft?.themeTags ?? ['猫咪', '雨夜', '暖灯'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: level.candidates,
selectedCandidateId: level.selectedCandidateId,
coverImageSrc: level.coverImageSrc,
coverAssetId: level.coverAssetId,
generationStatus: 'ready',
levels: [level],
metadata: null,
...overrides.draft,
},
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;
}
function openPuzzleLevelsTab() {
fireEvent.click(screen.getByRole('button', { name: '拼图关卡' }));
}
describe('PuzzleResultView', () => {
test('renders level list and work info tabs without asset config tab', () => {
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
const workInfoTab = screen.getByRole('button', { name: '作品信息' });
const levelsTab = screen.getByRole('button', { name: '拼图关卡' });
expect(workInfoTab).toBeTruthy();
expect(levelsTab).toBeTruthy();
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
expect(
workInfoTab.compareDocumentPosition(levelsTab) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
expect(screen.getByLabelText('作品名称')).toHaveProperty(
'value',
'暖灯猫街作品',
);
expect(screen.getByLabelText('作品描述')).toHaveProperty(
'value',
'一套雨夜猫街主题拼图。',
);
openPuzzleLevelsTab();
const levelImage = screen.getByRole('img', { name: '雨夜猫街' });
const mediaFrame = levelImage.closest('div.relative');
expect(mediaFrame?.className).toContain('aspect-[4/3]');
expect(mediaFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
expect(mediaFrame?.className).toContain('rounded-none');
expect(screen.getByText('雨夜猫街')).toBeTruthy();
const levelTitleButton = within(
screen.getByLabelText('拼图关卡列表'),
).getByRole('button', { name: '雨夜猫街' });
const levelCard = levelTitleButton.closest('.platform-subpanel');
expect(levelCard?.className).toContain('rounded-[1.35rem]');
expect(levelCard?.className).toContain('p-0');
expect(levelCard?.className).toContain('overflow-hidden');
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
fireEvent.click(screen.getByText('雨夜猫街'));
const levelDialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(levelDialog).getByText('关卡名称').className).toContain(
'tracking-[0.18em]',
);
});
test('result action bar restores draft trial entry', () => {
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
workTitle: '暖灯猫街作品',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
],
}),
);
});
test('level detail trial keeps the complete draft and only selects the target level', () => {
const onStartTestRun = vi.fn();
const base = createSession();
const firstLevel = base.draft!.levels![0]!;
const secondLevel = {
...firstLevel,
levelId: 'puzzle-level-2',
levelName: '钟楼猫街',
pictureDescription: '发光钟楼下的猫咪。',
candidates: [
{
...firstLevel.candidates[0]!,
candidateId: 'candidate-2',
imageSrc: '/puzzle/candidate-2.png',
assetId: 'asset-2',
},
],
selectedCandidateId: 'candidate-2',
coverImageSrc: '/puzzle/candidate-2.png',
coverAssetId: 'asset-2',
};
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [firstLevel, secondLevel],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('钟楼猫街'));
fireEvent.click(
within(screen.getByRole('dialog', { name: '关卡详情' })).getByRole(
'button',
{ name: '关卡测试' },
),
);
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levels: [
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({ levelId: 'puzzle-level-2' }),
],
}),
{ levelId: 'puzzle-level-2' },
);
});
test('auto saves work info and levels through one payload', 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.getByRole('button', { name: '作品信息' }));
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街合集' },
});
expect(screen.getByText('保存中').className).toContain(
'border-[var(--platform-warm-border)]',
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(screen.getByText('已自动保存').className).toContain(
'border-emerald-200',
);
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
workTitle: '暖灯猫街合集',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levels: expect.arrayContaining([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
]),
}),
);
});
test('auto save failure badge uses PlatformPillBadge danger chrome', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockRejectedValue(
new Error('保存失败'),
);
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街异常版' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
const failureBadge = screen
.getAllByText('保存失败')
.find((element) => element.tagName.toLowerCase() === 'span');
expect(failureBadge?.className).toContain(
'border-[var(--platform-button-danger-border)]',
);
});
test('opens an independent level detail dialog for generation and test play', () => {
const onExecuteAction = vi.fn();
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
onStartTestRun={onStartTestRun}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(dialog.className).toContain('max-w-[56rem]');
expect(dialog.querySelector('.puzzle-level-detail-list')).toBeTruthy();
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
fireEvent.click(
within(confirmDialog).getByRole('button', { name: '确定' }),
);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
aiRedraw: true,
referenceImageSrcs: [],
candidateCount: 1,
shouldAutoNameLevel: false,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
expect(
screen.getByRole('progressbar', { name: '画面生成进度' }),
).toBeTruthy();
const generatePayload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '暖灯猫街',
pictureDescription: '一只猫在雨夜灯牌下回头。',
generationStatus: 'generating',
}),
]);
expect(within(dialog).getByText('预计剩余 270 秒')).toBeTruthy();
expect(
within(dialog).queryByPlaceholderText('参考图链接或资产ID'),
).toBeNull();
const levelList = screen.getByLabelText('拼图关卡列表');
expect(within(levelList).getAllByText('生成中').length).toBeGreaterThan(0);
const generationBadges = within(levelList)
.getAllByText('生成中')
.map((element) => element.closest('span'));
expect(
generationBadges.some((badge) =>
badge?.className.includes('bg-white/94'),
),
).toBe(true);
expect(
generationBadges.some((badge) =>
badge?.className.includes('bg-amber-100'),
),
).toBe(true);
const levelNameInput = within(dialog).getByLabelText('关卡名称');
const formalImageTitle = within(dialog).getByText('画面图');
const formalImageCard = formalImageTitle
.closest('.creative-image-input-panel__image-field')
?.querySelector('.puzzle-image-upload-card');
fireEvent.click(
within(dialog).getByRole('button', { name: '查看关卡图片' }),
);
const imagePreviewDialog = screen.getByRole('dialog', {
name: '查看关卡图片',
});
expect(within(imagePreviewDialog).getByAltText('暖灯猫街')).toBeTruthy();
fireEvent.click(
within(imagePreviewDialog).getByRole('button', {
name: '关闭关卡图片预览',
}),
);
expect(
within(dialog).getByRole('button', { name: '更换参考图' }),
).toBeTruthy();
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
expect(pictureDescriptionInput.closest('.platform-subpanel')).toBeNull();
expect(formalImageCard).toBeTruthy();
expect(formalImageCard?.className).toContain('min-h-[');
expect(
levelNameInput.compareDocumentPosition(formalImageTitle) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
formalImageTitle.compareDocumentPosition(pictureDescriptionInput) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: /关卡测试/u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '暖灯猫街',
summary: '一套雨夜猫街主题拼图。',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '暖灯猫街',
}),
],
}),
{ levelId: 'puzzle-level-1' },
);
});
test('adds and deletes levels from the list', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(
within(dialog).getByRole('button', { name: /生成画面/u }),
).toBeTruthy();
expect(within(dialog).getByText('消耗2泥点')).toBeTruthy();
expect(within(dialog).getByText('画面图')).toBeTruthy();
expect(
within(dialog).queryByRole('button', { name: /关卡测试/u }),
).toBeNull();
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levels: expect.arrayContaining([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({ levelName: '' }),
]),
}),
);
fireEvent.click(screen.getByLabelText('删除关卡 第2关'));
expect(screen.queryByText('第2关')).toBeNull();
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
}),
],
}),
);
});
test('generates image for a newly added level with the current levels snapshot', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '新关卡里有一座发光钟楼。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: /生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1775000000000-2',
promptText: '新关卡里有一座发光钟楼。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
aiRedraw: true,
referenceImageSrcs: [],
candidateCount: 1,
shouldAutoNameLevel: true,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
const payload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({
levelId: 'puzzle-level-1775000000000-2',
levelName: '',
pictureDescription: '新关卡里有一座发光钟楼。',
generationStatus: 'generating',
}),
]);
});
test('requests automatic level naming when generating an unnamed level image', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '新关卡里有一座发光钟楼。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: /生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1775000000000-2',
promptText: '新关卡里有一座发光钟楼。',
shouldAutoNameLevel: true,
}),
);
});
test('keeps generation progress visible after closing and reopening level dialog', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
expect(
within(screen.getByLabelText('拼图关卡列表')).getAllByText('生成中')
.length,
).toBeGreaterThan(0);
fireEvent.click(screen.getByText('雨夜猫街'));
const reopenedDialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(
within(reopenedDialog).getByRole('progressbar', { name: '画面生成进度' }),
).toBeTruthy();
expect(within(reopenedDialog).getByText('预计剩余 270 秒')).toBeTruthy();
});
test('allows parallel draft editing while a level image is generating but blocks publish', () => {
const onExecuteAction = vi.fn();
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
onStartTestRun={onStartTestRun}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
fireEvent.change(screen.getByLabelText('关卡名称'), {
target: { value: '继续编辑的猫街' },
});
fireEvent.click(screen.getByRole('button', { name: /关卡测试/u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '继续编辑的猫街',
}),
{ levelId: 'puzzle-level-1' },
);
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
openPuzzleLevelsTab();
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
fireEvent.click(screen.getByLabelText('关闭关卡详情'));
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(
within(publishDialog).getByText('还有关卡画面正在生成。'),
).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: /发布到广场/u }),
).toHaveProperty('disabled', true);
});
test('asset config tab is removed and cannot trigger the legacy UI background action', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
isBusy={false}
/>,
);
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
expect(screen.queryByLabelText('拼图UI背景提示词')).toBeNull();
expect(screen.queryByRole('button', { name: /生成UI背景/u })).toBeNull();
openPuzzleLevelsTab();
const addLevelButton = screen.getByRole('button', { name: /新增关卡/u });
expect(addLevelButton).toHaveProperty('disabled', false);
fireEvent.click(addLevelButton);
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
expect(onExecuteAction).not.toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_ui_background',
}),
);
});
test('keeps the current level dialog open when another level generation completes', () => {
const base = createSession();
const firstLevel = base.draft!.levels![0]!;
const generatingSecondLevel = {
...firstLevel,
levelId: 'puzzle-level-2',
levelName: '第二关',
pictureDescription: '第二关画面正在生成。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating' as const,
};
const localThirdLevel = {
...firstLevel,
levelId: 'puzzle-level-3',
levelName: '第三关',
pictureDescription: '第三关初稿。',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle' as const,
};
const completedSecondLevel = {
...generatingSecondLevel,
candidates: [
{
candidateId: 'candidate-level-2',
imageSrc: '/puzzle/level-2.png',
assetId: 'asset-level-2',
prompt: '第二关画面',
actualPrompt: null,
sourceType: 'generated' as const,
selected: true,
},
],
selectedCandidateId: 'candidate-level-2',
coverImageSrc: '/puzzle/level-2.png',
coverAssetId: 'asset-level-2',
generationStatus: 'ready' as const,
};
const { rerender } = render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [firstLevel, generatingSecondLevel, localThirdLevel],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('第三关'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '正在编辑第三关的信息。' },
});
rerender(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [firstLevel, completedSecondLevel],
},
updatedAt: '2026-05-14T10:00:00.000Z',
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
const currentDialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(currentDialog).getByLabelText('关卡名称')).toHaveProperty(
'value',
'第三关',
);
expect(within(currentDialog).getByLabelText('画面描述')).toHaveProperty(
'value',
'正在编辑第三关的信息。',
);
expect(within(currentDialog).queryByDisplayValue('第二关')).toBeNull();
});
test('publishes with work info and serialized levels', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(within(publishDialog).getByText('发布检查').className).toContain(
'tracking-[0.18em]',
);
expect(within(publishDialog).getByText('封面关卡').className).toContain(
'tracking-[0.18em]',
);
const coverImage = within(publishDialog).getByRole('img', {
name: '雨夜猫街',
});
expect(coverImage.closest('div.relative')?.className).toContain(
'aspect-square',
);
expect(coverImage.closest('div.relative')?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(coverImage.closest('div.relative')?.className).toContain(
'bg-white/68',
);
expect(coverImage.getAttribute('data-refresh-key')).toBe(
'2026-04-26T10:00:00.000Z:/puzzle/candidate-1.png:1',
);
fireEvent.click(
within(publishDialog).getByRole('button', { name: /发布到广场/u }),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'publish_puzzle_work',
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
);
const payload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(payload.levelsJson)).toEqual([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
]);
});
test('keeps publish dialog open and shows backend publish error', () => {
const onExecuteAction = vi.fn();
const { rerender } = render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
fireEvent.click(
within(dialog).getByRole('button', { name: /发布到广场/u }),
);
rerender(
<PuzzleResultView
session={createSession()}
error="泥点余额不足"
isBusy={false}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
const publishDialog = screen.getByRole('dialog', {
name: '发布拼图作品',
});
expect(publishDialog).toBeTruthy();
expect(within(publishDialog).getByText('泥点余额不足')).toBeTruthy();
});
test('generates six tags after work title and description are filled', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession({
draft: {
...createSession().draft!,
workTitle: '雨夜猫街',
workDescription: '',
themeTags: [],
},
resultPreview: {
draft: createSession().draft!,
publishReady: false,
blockers: [
{
id: 'invalid-tag-count',
code: 'INVALID_TAG_COUNT',
message: '正式标签数量必须在 3 到 6 之间',
},
],
qualityFindings: [],
},
})}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
expect(screen.getByText('请先填写作品名称和作品描述。')).toBeTruthy();
expect(onExecuteAction).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('作品描述'), {
target: { value: '一套雨夜猫街主题拼图。' },
});
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_tags',
workTitle: '雨夜猫街',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '一套雨夜猫街主题拼图。',
themeTags: [],
levelsJson: expect.any(String),
});
});
test('preserves generated level asset bundle in test run draft', () => {
const onStartTestRun = vi.fn();
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene.png',
levelSceneImageObjectKey:
'generated-puzzle-assets/session/level-scene.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet.png',
uiSpritesheetImageObjectKey:
'generated-puzzle-assets/session/ui-spritesheet.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background.png',
levelBackgroundImageObjectKey:
'generated-puzzle-assets/session/level-background.png',
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levels: [
expect.objectContaining({
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene.png',
levelSceneImageObjectKey:
'generated-puzzle-assets/session/level-scene.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet.png',
uiSpritesheetImageObjectKey:
'generated-puzzle-assets/session/ui-spritesheet.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background.png',
levelBackgroundImageObjectKey:
'generated-puzzle-assets/session/level-background.png',
}),
],
}),
);
});
test('does not expose music or standalone UI asset controls', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio/music.mp3',
prompt: '',
title: '雨夜轻响',
updatedAt: '2026-05-12T10:00:00.000Z',
},
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
expect(screen.queryByRole('button', { name: /重新生成音乐/u })).toBeNull();
expect(screen.queryByLabelText('拼图背景音乐')).toBeNull();
expect(screen.queryByLabelText('拼图UI背景提示词')).toBeNull();
});
test('生成完成回包合并历史音乐和关卡资产后试玩使用最新资源', () => {
const onStartTestRun = vi.fn();
const base = createSession();
const localLevel = {
...base.draft!.levels![0]!,
generationStatus: 'generating' as const,
levelSceneImageSrc: null,
uiSpritesheetImageSrc: null,
levelBackgroundImageSrc: null,
backgroundMusic: null,
};
const incomingLevel = {
...localLevel,
generationStatus: 'ready' as const,
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene-fruit.png',
levelSceneImageObjectKey:
'generated-puzzle-assets/session/level-scene-fruit.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet-fruit.png',
uiSpritesheetImageObjectKey:
'generated-puzzle-assets/session/ui-spritesheet-fruit.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background-fruit.png',
levelBackgroundImageObjectKey:
'generated-puzzle-assets/session/level-background-fruit.png',
backgroundMusic: {
taskId: 'music-task-fruit',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-fruit',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
prompt: '',
title: '水果乐园',
updatedAt: '2026-05-14T10:00:00.000Z',
},
};
const { rerender } = render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [localLevel],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
rerender(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
coverImageSrc: incomingLevel.coverImageSrc,
coverAssetId: incomingLevel.coverAssetId,
generationStatus: 'ready',
levels: [incomingLevel],
},
updatedAt: '2026-05-14T10:00:00.000Z',
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levels: [
expect.objectContaining({
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene-fruit.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet-fruit.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background-fruit.png',
backgroundMusic: expect.objectContaining({
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
}),
}),
],
}),
);
});
test('auto saves generated level asset bundle through levels', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background.png',
},
],
},
})}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.change(screen.getByLabelText('关卡名称'), {
target: { value: '雨夜猫街新版' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街新版',
levelSceneImageSrc:
'/generated-puzzle-assets/session/level-scene.png',
uiSpritesheetImageSrc:
'/generated-puzzle-assets/session/ui-spritesheet.png',
levelBackgroundImageSrc:
'/generated-puzzle-assets/session/level-background.png',
}),
],
}),
);
});
test('selects a history puzzle asset as reference image for the selected level', 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: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
},
]);
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const uploadInput = within(dialog).getAllByLabelText('上传参考图', {
selector: 'input',
})[0]!;
expect(uploadInput.closest('.platform-subpanel')).toBeNull();
expect(uploadInput.closest('.puzzle-level-detail-list')).toBeTruthy();
const historyButton = within(dialog).getByRole('button', {
name: '选择历史图片',
});
expect(within(historyButton).getByText('历史')).toBeTruthy();
fireEvent.click(historyButton);
const picker = await screen.findByRole('dialog', {
name: '选择历史图片',
});
expect(await within(picker).findByText(/2024\/04\/21/u)).toBeTruthy();
expect(within(picker).queryByText('image.png')).toBeNull();
expect(within(picker).queryByText('账号 user-1')).toBeNull();
fireEvent.click(
await within(picker).findByRole('button', {
name: /选择2024\/04\/21.*的历史图片/u,
}),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
});
expect(within(dialog).getByAltText('拼图参考图').getAttribute('src')).toBe(
'/generated-puzzle-assets/history/image.png',
);
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenLastCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '屋檐下的猫与暖灯街角。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
imageModel: 'gpt-image-2',
aiRedraw: true,
referenceImageSrcs: [],
candidateCount: 1,
shouldAutoNameLevel: false,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
});
test('uses the saved level picture reference when regenerating a level image', () => {
const onExecuteAction = vi.fn();
const session = createSession({
draft: {
...createSession().draft!,
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference:
'/generated-puzzle-assets/history/saved-reference.png',
},
],
},
});
render(
<PuzzleResultView
session={session}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const referenceImage = within(dialog).getByAltText('拼图参考图');
const referenceRow = referenceImage.closest('div')?.parentElement;
expect(referenceRow?.className).toContain('flex items-center gap-3');
expect(referenceRow?.className).toContain('bg-white/72');
expect(within(dialog).getByText('已选择参考图').className).toContain(
'truncate',
);
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
referenceImageSrc:
'/generated-puzzle-assets/history/saved-reference.png',
aiRedraw: true,
}),
);
expect(screen.queryByPlaceholderText('参考图链接或资产ID')).toBeNull();
});
test('passes the selected image mode without exposing model names', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.click(
within(dialog).getByRole('button', { name: '图片生成模式' }),
);
expect(within(dialog).queryByText(/gpt|nanobanana|gemini/u)).toBeNull();
fireEvent.click(
within(dialog).getByRole('menuitemradio', { name: '标准模式' }),
);
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
imageModel: 'gpt-image-2',
}),
);
});
test('level image editor exposes entrance image editing controls without sharing UI background state', () => {
const onExecuteAction = vi.fn();
const session = createSession({
draft: {
...createSession().draft!,
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference:
'/generated-puzzle-assets/history/saved-reference.png',
},
],
},
});
render(
<PuzzleResultView
session={session}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).getByText('画面图')).toBeTruthy();
expect(within(dialog).getByLabelText('上传参考图')).toBeTruthy();
expect(
within(dialog).getByRole('switch', { name: 'AI重绘' }),
).toHaveProperty('checked', true);
expect(
within(dialog).getByRole('button', { name: '选择历史图片' }),
).toBeTruthy();
fireEvent.change(
within(dialog).getByLabelText('画面AI重绘要求提示词'),
{
target: { value: '只重绘第一关猫街画面' },
},
);
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '只重绘第一关猫街画面',
aiRedraw: true,
}),
);
expect(onExecuteAction).not.toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_ui_background',
}),
);
});
test('level image editor keeps AI redraw switch scoped to the level image action', () => {
const onExecuteAction = vi.fn();
const session = createSession({
draft: {
...createSession().draft!,
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference:
'/generated-puzzle-assets/history/saved-reference.png',
},
],
},
});
render(
<PuzzleResultView
session={session}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
aiRedraw: false,
}),
);
expect(onExecuteAction).not.toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_ui_background',
aiRedraw: false,
}),
);
});
test('level image editor submits uploaded image directly when AI redraw is off', async () => {
const onExecuteAction = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,level-upload';
stubReferenceImageUpload(uploadedDataUrl);
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const uploadInput = within(dialog).getAllByLabelText('上传参考图', {
selector: 'input',
})[0]!;
fireEvent.change(uploadInput, {
target: {
files: [new File(['x'], 'level-upload.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(
within(dialog).getByRole('switch', { name: 'AI重绘' }),
).toBeTruthy();
});
expect(within(dialog).getByAltText('拼图参考图')).toHaveProperty(
'src',
uploadedDataUrl,
);
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
referenceImageSrc: uploadedDataUrl,
referenceImageSrcs: [],
aiRedraw: false,
}),
);
});
test('level image editor uploads prompt reference images from the description box', async () => {
const onExecuteAction = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,level-reference';
stubReferenceImageUpload(uploadedDataUrl);
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const referenceInput = within(dialog).getByLabelText('上传描述参考图', {
selector: 'input',
});
fireEvent.change(referenceInput, {
target: {
files: [new File(['x'], 'prompt-reference.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(
within(dialog).getByRole('button', {
name: /预览参考图 prompt-reference\.png/u,
}),
).toBeTruthy();
});
fireEvent.click(
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
referenceImageSrc: undefined,
referenceImageSrcs: [uploadedDataUrl],
aiRedraw: true,
}),
);
});
test('level image editor hides AI redraw controls when only the formal image is shown', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(within(dialog).getByLabelText('画面描述')).toBeTruthy();
});
test('standalone UI background generator stays removed from the result page', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
expect(screen.queryByText('UI背景预览')).toBeNull();
expect(screen.queryByLabelText('UI背景提示词')).toBeNull();
expect(screen.queryByRole('button', { name: /生成UI背景/u })).toBeNull();
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('shows creative agent draft edit bar and submits the current draft', () => {
const onSubmit = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
creativeDraftEdit={{
isBusy: false,
error: null,
onSubmit,
}}
/>,
);
const creativeDraftInput = screen.getByLabelText('智能修订拼图草稿');
const creativeDraftPanel = creativeDraftInput.closest('.platform-subpanel');
const creativeDraftIconBadge = creativeDraftPanel?.querySelector(
'span[aria-hidden="true"]',
);
expect(creativeDraftPanel?.className).toContain('rounded-[1.35rem]');
expect(creativeDraftPanel?.className).toContain('sm:p-4');
expect(creativeDraftIconBadge?.className).toContain('h-9');
expect(creativeDraftIconBadge?.className).toContain('rounded-full');
expect(creativeDraftIconBadge?.className).toContain('bg-white/72');
fireEvent.change(creativeDraftInput, {
target: { value: '把标题改得轻松一点' },
});
fireEvent.click(screen.getByRole('button', { name: '修改' }));
expect(onSubmit).toHaveBeenCalledWith({
instruction: '把标题改得轻松一点',
currentDraft: expect.objectContaining({
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
pictureReference: null,
}),
],
}),
});
});
});