Puzzle: support history images & partial generation

Allow history-generated image paths to be submitted where Data URLs were previously required and avoid treating partial/result-page generations as blocking the whole draft. Backend: resolve history /generated-* references via resolve_puzzle_reference_image_as_data_url and convert to PuzzleDownloadedImage; add PuzzleDownloadedImage::from_resolved_reference_image; extend draft handling to apply generated level metadata (auto-naming) and normalize generation_status to treat levels with images as ready. API: add shouldAutoNameLevel to action contracts and use it to request/refine generated level names. Spacetime/module and mappers: normalize completed level statuses when saving/reading so result-page background or per-level generation doesn't mask completed drafts. Frontend: expose resolver helpers, only mark a work as generating when no usable cover or ready level exists, keep level controls enabled during UI-background regeneration, and add tests covering history-image submission, auto-naming, and UI-background/partial-generation behaviors.
This commit is contained in:
2026-05-19 10:02:13 +08:00
parent 5e03b3d2f2
commit 7b37271f17
16 changed files with 653 additions and 73 deletions

View File

@@ -317,6 +317,7 @@ describe('PuzzleResultView', () => {
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
shouldAutoNameLevel: false,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
@@ -466,6 +467,7 @@ describe('PuzzleResultView', () => {
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
shouldAutoNameLevel: true,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
@@ -485,6 +487,42 @@ describe('PuzzleResultView', () => {
]);
});
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();
@@ -567,6 +605,90 @@ describe('PuzzleResultView', () => {
).toHaveProperty('disabled', true);
});
test('keeps level controls enabled while regenerating the UI background', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
isBusy={false}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '雨夜猫街竖屏拼图UI背景' },
});
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_ui_background',
promptText: '雨夜猫街竖屏拼图UI背景',
}),
);
expect(
screen.getByRole('button', { name: /生成中/u }),
).toHaveProperty('disabled', true);
openPuzzleLevelsTab();
const addLevelButton = screen.getByRole('button', { name: /新增关卡/u });
expect(addLevelButton).toHaveProperty('disabled', false);
fireEvent.click(addLevelButton);
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
});
test('restores UI background generate button when background generation fails', () => {
const onExecuteAction = vi.fn();
const { rerender } = render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
isBusy={false}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '雨夜猫街竖屏拼图UI背景' },
});
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
'button',
{ name: '确定' },
),
);
expect(screen.getByRole('button', { name: /生成中/u })).toHaveProperty(
'disabled',
true,
);
rerender(
<PuzzleResultView
session={createSession()}
error="UI背景生成失败"
onBack={() => {}}
onExecuteAction={onExecuteAction}
isBusy={false}
/>,
);
const generateButton = screen.getByRole('button', { name: /生成UI背景/u });
expect(generateButton).toHaveProperty('disabled', false);
expect(screen.queryByRole('button', { name: /生成中/u })).toBeNull();
});
test('keeps the current level dialog open when another level generation completes', () => {
const base = createSession();
const firstLevel = base.draft!.levels![0]!;
@@ -1143,6 +1265,7 @@ describe('PuzzleResultView', () => {
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
shouldAutoNameLevel: false,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',