完美像素未知结果先对账再定性
Project CI / Repository checks (pull_request) Failing after 47s
Project CI / Frontend tests (pull_request) Successful in 2m53s
Project CI / Backend tests (pull_request) Successful in 3m32s
Project CI / Native shell tests (pull_request) Failing after 7m31s

catch 此前对所有错误一视同仁:标 failed、finally 解锁、按钮恢复可点。transport
异常、abort 和 120 秒客户端超时因此被谎报成明确失败,而服务端此时很可能已经完成
OSS PUT、asset object、project resource、账号素材和画布回填,只是响应没回来。用户
按提示重试就再造一整份。这违反本功能自己立下的契约:结果未知时先 GET 权威快照,
由用户显式决定是否再次执行。

判别依据是 ApiClientError 只在拿到 Response 时由 buildApiClientError 构造,
transport 异常、AbortError 和 TimeoutError 在重试判定后原样抛出。已知结果不发
对账 GET,避免每个 400 都多打一次权威读取。

未知结果先 loadEditorProject,再按占位是否存活分流:占位已被 completion 消费掉
说明这次其实成功,按快照收口并写入正常的 perfect-pixel 历史;占位仍在说明画布没
收到结果,同步快照但不写历史,文案要求先核对素材库再决定是否重试——持久化是非
事务的,对象和素材可能已落库而画布回填未完成。对账 GET 本身失败时给独立文案,
不退回谎报。

既有用例 keeps a failed perfect-pixel placeholder 原本用裸 Error 表达「服务端识别
不到网格」,语义不准且会误入对账路径,改为 ApiClientError。harness 新增
dialog-error 输出,否则对账文案不可观测。

刷新后停在 generating 的占位仍无自动收口,需要先给 dialog 增加「无 durable job 的
inline 链路」标记才能在 hydration 后对账,改动面大于本次,单独立项。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 11:44:57 +00:00
parent 483201b625
commit cc88564d4a
4 changed files with 220 additions and 4 deletions
@@ -10,6 +10,7 @@ import {
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasGenerationDialogState,
CanvasLayer,
@@ -34,6 +35,7 @@ const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
const removeImageBackgroundMock = vi.hoisted(() => vi.fn());
const snapImageToPerfectPixelsMock = vi.hoisted(() => vi.fn());
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorImageReference', async () => {
@@ -62,6 +64,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
generateEditorSoundEffect: generateEditorSoundEffectMock,
loadEditorProject: loadEditorProjectMock,
splitEditorIconSpritesheet: splitEditorIconSpritesheetMock,
};
});
@@ -231,6 +234,9 @@ function GenerationWorkflowHarness({
: '-'}
</span>
<span data-testid="dialog-prompt">{activeDialog?.prompt || '-'}</span>
<span data-testid="dialog-error">
{activeDialog?.errorMessage || '-'}
</span>
<span data-testid="dialog-image-options">
{activeDialog
? `${activeDialog.mode}:${activeDialog.imageModel ?? '-'}:${activeDialog.aspectRatio ?? '-'}:${activeDialog.imageSize ?? '-'}`
@@ -925,6 +931,9 @@ function GenerationWorkflowHarness({
describe('useImageCanvasGenerationWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks();
// 中文注释:未知结果的对账 GET 默认返回 null,等价于"权威快照读不到"。需要具体
// 对账结果的用例各自 mockResolvedValueOnce 覆盖。
loadEditorProjectMock.mockResolvedValue(null);
resolveEditorImageReferenceDataUrlMock.mockImplementation(
async (src: string) => src,
);
@@ -2256,6 +2265,153 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
});
it('reconciles an unknown perfect-pixel outcome that actually succeeded', async () => {
// 中文注释:120 秒超时或 transport abort 时服务端可能已经写完。此路由禁用自动
// 重放,直接标 failed 会谎报结果、诱使用户重试再造一份对象与素材。契约要求先
// GET 权威快照对账;占位已被 completion 消费掉即视为成功。
const applyProjectSnapshot = vi.fn();
const reconciledProject = {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-01T00:00:00.000Z',
};
const timeoutError = new Error('The operation timed out.');
timeoutError.name = 'TimeoutError';
snapImageToPerfectPixelsMock.mockRejectedValueOnce(timeoutError);
loadEditorProjectMock.mockResolvedValueOnce(reconciledProject);
render(
<GenerationWorkflowHarness
projectId="project-1"
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={vi.fn(async () => {})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1');
});
expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject, {
type: 'perfect-pixel',
count: 1,
});
await waitFor(() => {
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
expect(screen.getByTestId('dialog').textContent).not.toContain('failed');
expect(screen.getByTestId('dialog-error').textContent).toBe('-');
});
it('marks an unknown perfect-pixel outcome as unresolved when the placeholder survives', async () => {
// 中文注释:占位仍在权威快照里说明画布没收到结果。持久化是非事务的,OSS 对象与
// 账号素材仍可能已落库,因此只同步快照、不写历史,文案必须要求用户先核对。
const applyProjectSnapshot = vi.fn();
const reconciledProject = {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
dialog: {
id: 'generation-dialog-1',
mode: 'quick-edit',
status: 'generating',
prompt: '完美像素',
},
},
],
resources: [],
updatedAt: '2026-08-01T00:00:00.000Z',
};
const abortError = new Error('The operation was aborted.');
abortError.name = 'AbortError';
snapImageToPerfectPixelsMock.mockRejectedValueOnce(abortError);
loadEditorProjectMock.mockResolvedValueOnce(reconciledProject);
render(
<GenerationWorkflowHarness
projectId="project-1"
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={vi.fn(async () => {})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith('project-1');
});
// 中文注释:只同步快照,不带历史动作——这次没有成功。
expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject);
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain('failed');
});
expect(screen.getByTestId('dialog-error').textContent).toContain(
'结果未知',
);
expect(screen.getByTestId('dialog-error').textContent).toContain(
'请先确认再决定是否重试',
);
});
it('keeps a rejected perfect-pixel request out of the reconciliation path', async () => {
// 中文注释:服务端明确响应过(ApiClientError)就是已知结果,不需要也不应该
// 再发对账 GET,否则每个 400 都要多打一次权威读取。
const applyProjectSnapshot = vi.fn();
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
new ApiClientError({
message: 'assetKind 与来源素材权威类型不一致。',
status: 400,
code: 'HTTP_400',
}),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={vi.fn(async () => {})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain('failed');
});
expect(loadEditorProjectMock).not.toHaveBeenCalled();
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog-error').textContent).toBe(
'assetKind 与来源素材权威类型不一致。',
);
});
it('uploads an inline perfect-pixel source before flushing and posting', async () => {
const order: string[] = [];
uploadEditorMediaAssetFileMock.mockImplementationOnce(async () => {
@@ -2318,8 +2474,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
it('keeps a failed perfect-pixel placeholder and releases busy state', async () => {
const applyProjectSnapshot = vi.fn();
// 中文注释:服务端明确拒绝(识别不到像素网格)是已知结果,用 ApiClientError 表达。
// 裸 Error 在生产里对应的是 transport 异常,属于未知结果,会走对账路径。
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
new Error('像素网格无法识别'),
new ApiClientError({
message: '像素网格无法识别',
status: 422,
code: 'HTTP_422',
}),
);
render(
<GenerationWorkflowHarness