This commit is contained in:
2026-04-30 17:49:07 +08:00
parent 805d6f8cae
commit 9d684cb7b3
615 changed files with 15368 additions and 6172 deletions

View File

@@ -45,96 +45,79 @@ afterEach(() => {
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: '屋檐下的猫与暖灯街角。',
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: {
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',
},
},
anchorPack,
draft: {
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜'],
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levelName: level.levelName,
summary: level.pictureDescription,
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',
anchorPack,
candidates: level.candidates,
selectedCandidateId: level.selectedCandidateId,
coverImageSrc: level.coverImageSrc,
coverAssetId: level.coverAssetId,
generationStatus: 'ready',
levels: [level],
metadata: null,
},
messages: [],
@@ -160,40 +143,7 @@ function createSession(
}
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', () => {
test('renders level list and work info tabs', () => {
render(
<PuzzleResultView
session={createSession()}
@@ -203,117 +153,22 @@ describe('PuzzleResultView', () => {
/>,
);
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();
});
expect(screen.getByRole('button', { name: '拼图关卡' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByText('雨夜猫街')).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={() => {}}
/>,
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
expect(screen.getByLabelText('作品名称')).toHaveProperty(
'value',
'暖灯猫街作品',
);
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: ['猫咪', '雨夜', '暖灯'],
}),
expect(screen.getByLabelText('作品描述')).toHaveProperty(
'value',
'一套雨夜猫街主题拼图。',
);
});
test('auto saves edited picture description to the puzzle work profile', async () => {
test('auto saves work info and levels through one payload', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
@@ -328,8 +183,9 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街合集' },
});
await act(async () => {
@@ -339,83 +195,68 @@ describe('PuzzleResultView', () => {
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
summary: '一只猫在雨夜灯牌下回头。',
workTitle: '暖灯猫街合集',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levels: expect.arrayContaining([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
]),
}),
);
});
test('requires at least three theme tags before publish can pass', () => {
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}
/>,
);
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('画面描述'), {
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
'button',
{ name: '发布到广场' },
),
);
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'publish_puzzle_work',
levelName: '雨夜猫街',
summary: '一只猫在雨夜灯牌下回头。',
themeTags: ['猫咪', '雨夜', '暖灯'],
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
candidateCount: 1,
});
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '暖灯猫街',
summary: '一只猫在雨夜灯牌下回头。',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '暖灯猫街',
}),
],
}),
);
});
test('auto saves added and removed theme tags', async () => {
test('adds and deletes levels from the list', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
@@ -430,11 +271,10 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByLabelText('新增题材标签'));
fireEvent.change(screen.getByLabelText('新题材标签'), {
target: { value: '暖灯' },
});
fireEvent.click(screen.getByRole('button', { name: '添加' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
fireEvent.click(screen.getByLabelText('关闭'));
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
await act(async () => {
await vi.runAllTimersAsync();
@@ -443,11 +283,16 @@ describe('PuzzleResultView', () => {
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
themeTags: ['猫咪', '雨夜', '暖灯'],
levels: expect.arrayContaining([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({ levelName: '第2关' }),
]),
}),
);
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
fireEvent.click(screen.getByLabelText('删除关卡 第2关'));
expect(screen.queryByText('第2关')).toBeNull();
await act(async () => {
await vi.runAllTimersAsync();
});
@@ -455,12 +300,16 @@ describe('PuzzleResultView', () => {
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenLastCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
themeTags: ['雨夜', '暖灯'],
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
}),
],
}),
);
});
test('generates one image from the picture description and replaces current image', () => {
test('publishes with work info and serialized levels', () => {
const onExecuteAction = vi.fn();
render(
@@ -471,24 +320,34 @@ describe('PuzzleResultView', () => {
/>,
);
expect(screen.getByText('画面描述')).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.queryByText(//u)).toBeNull();
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(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
candidateCount: 1,
});
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('selects a history puzzle asset as reference image for the next generation', async () => {
test('selects a history puzzle asset as reference image for the selected level', async () => {
const onExecuteAction = vi.fn();
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
{
@@ -512,13 +371,14 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByLabelText('从历史拼图素材库选择'));
const dialog = await screen.findByRole('dialog', {
const picker = await screen.findByRole('dialog', {
name: '选择历史拼图素材',
});
fireEvent.click(
await within(dialog).findByRole('button', { name: / user-1/u }),
await within(picker).findByRole('button', { name: / user-1/u }),
);
await waitFor(() => {
@@ -528,102 +388,12 @@ describe('PuzzleResultView', () => {
});
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onExecuteAction).toHaveBeenLastCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
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');
});
});
});

File diff suppressed because it is too large Load Diff