refactor(api-server): narrow puzzle state surface

This commit is contained in:
kdletters
2026-05-21 18:55:25 +08:00
parent cc23b6020d
commit 5834a99107
31 changed files with 1087 additions and 169 deletions

View File

@@ -29,6 +29,7 @@ vi.mock('../ResolvedAssetImage', () => ({
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
puzzleAssetClient: {
listHistoryAssets: vi.fn(),
uploadReferenceImage: vi.fn(),
},
}));
@@ -90,6 +91,19 @@ beforeEach(() => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockImplementation(
async ({ file }) => ({
assetObjectId: `asset-reference-${file.name}`,
assetKind: 'puzzle_cover_image',
objectKey: `generated-puzzle-assets/reference/${file.name}`,
imageSrc: `/generated-puzzle-assets/reference/${file.name}`,
ownerUserId: 'user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
}),
);
});
afterEach(() => {
@@ -190,6 +204,8 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -325,8 +341,10 @@ test('puzzle workspace selects a history image from the upload card', async () =
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留历史图里的主体,改成晴天花园。',
pictureDescription: '保留历史图里的主体,改成晴天花园。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-history-1',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -384,6 +402,8 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
promptText: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
@@ -484,6 +504,8 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -528,8 +550,10 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: 'first-level.png',
pictureDescription: 'first-level.png',
referenceImageSrc: uploadedDataUrl,
referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-first-level.png',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: false,
});
@@ -584,6 +608,8 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
pictureDescription: '历史素材 · image.png',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-history-1',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: false,
});
@@ -593,6 +619,17 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
const onCreateFromForm = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({
assetObjectId: 'asset-reference-main-1',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/main-1.png',
imageSrc: '/generated-puzzle-assets/reference/main-1.png',
ownerUserId: 'user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
});
render(
<PuzzleAgentWorkspace
@@ -612,6 +649,9 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
await waitFor(() => {
expect(screen.getByAltText('拼图图片')).toBeTruthy();
});
expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({
file: expect.any(File),
});
fireEvent.change(screen.getByLabelText('画面AI重绘要求提示词'), {
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
});
@@ -621,8 +661,101 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
referenceImageSrc: uploadedDataUrl,
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: 'asset-reference-main-1',
referenceImageAssetObjectIds: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle workspace uploads prompt references as asset object ids', async () => {
const onCreateFromForm = vi.fn();
const uploadedSources = [
'data:image/png;base64,reference-1',
'data:image/png;base64,reference-2',
];
let readIndex = 0;
stubReferenceImageUpload(uploadedSources[0] ?? 'data:image/png;base64,reference-1');
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result = uploadedSources[readIndex] ?? uploadedSources[0] ?? '';
readIndex += 1;
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
vi.mocked(puzzleAssetClient.uploadReferenceImage)
.mockResolvedValueOnce({
assetObjectId: 'asset-reference-prompt-1',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/prompt-1.png',
imageSrc: '/generated-puzzle-assets/reference/prompt-1.png',
ownerUserId: 'user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
})
.mockResolvedValueOnce({
assetObjectId: 'asset-reference-prompt-2',
assetKind: 'puzzle_cover_image',
objectKey: 'generated-puzzle-assets/reference/prompt-2.png',
imageSrc: '/generated-puzzle-assets/reference/prompt-2.png',
ownerUserId: 'user-1',
profileId: null,
entityId: null,
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
});
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
target: {
files: uploadedSources.map(
(_source, index) =>
new File(['x'], `reference-${index + 1}.png`, {
type: 'image/png',
}),
),
},
});
await waitFor(() => {
expect(screen.getAllByRole('button', { name: //u })).toHaveLength(
2,
);
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-prompt-1',
'asset-reference-prompt-2',
],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -705,7 +838,15 @@ test('puzzle workspace uploads prompt reference images from the description box'
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: uploadedSources.slice(0, 5),
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [
'asset-reference-reference-1.png',
'asset-reference-reference-2.png',
'asset-reference-reference-3.png',
'asset-reference-reference-4.png',
'asset-reference-reference-5.png',
],
imageModel: 'gpt-image-2',
aiRedraw: true,
});