补齐完美像素第三处静默分支并区分两条结果文案

链路复查发现两处。

遗留:成功路径拿到 result.project 后,本地占位已被删除时直接 return。不套用是
对的(删除意图胜出),但什么都不说。同一形状此前已修两处,这是第三处;既有用例
只断言「不套用」,没断言「要说话」,所以没挡住。

新引入:上次让「对账已收口 + 本地已删」沿用了「只在素材库」的文案。两种情况事实
不同——快照里没有 dialog 时服务端画布确实没有结果;dialog 已收口时服务端画布上
有结果,只是本地未同步,重新加载即可见。混用会让用户以为画布上没有而重做。

两条文案抽成常量按事实分派。

第一次回归验证只有 1 条变红,暴露出「对账已收口 + 本地已删」这条分支根本没有
用例;补上后再破坏,2 条同时变红。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 10:00:40 +00:00
parent 925238e221
commit c9db36b177
3 changed files with 111 additions and 7 deletions
@@ -6096,3 +6096,14 @@
- 两条共性:都是用代理信号代替事实——用「占位在不在」代替「操作完成没有」,用「有没有 HTTP 响应」代替「应用层有没有表态」。两处都是没有去读被代理的那个事实的真实形状。
- 验证:新增四条用例(网关 504 触发对账、网关 429 不触发、应用层无标记 502 不触发、快照无 dialog 时不套用快照)。同时破坏两处修复后,四条精确变红。后两条是对照用例,专门守住「放宽判据不得退回反向谎报」。`vitest src/components/image-editor` 907 通过 / 72 文件,typecheck、eslint、check:encoding 通过。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-03 完美像素链路复查:补齐第三处静默分支并区分两条结果文案
- 背景:对账判据与网关分类修完之后做的整链复查,目标是找遗留与新引入的问题,不是重复已知项。
- 遗留(第三处静默):成功路径拿到 `result.project` 后,若本地占位已被用户删除则直接 `return`。不套用快照是对的(删除意图胜出),但什么都不说。此前已修过同一形状的两处(`!result.project` 分支、对账早返回),这是第三处,复查才发现。既有用例 `does not apply a completed project after the perfect-pixel placeholder was deleted` 只断言「不套用」,没断言「要说话」,所以也没挡住。
- 新引入(文案混用):上一次修复让「对账发现已收口 + 本地占位已删」这一支沿用了「结果已保存到素材库,画布占位已不存在」。**两种情况的事实不同**——快照里没有 dialog 时服务端 completion 返回 `Ok(None)`,画布上确实没有结果图层;而 dialog 已收口时服务端画布上**有**结果图层,只是本地按删除意图没套用,重新加载即可见。用前一条文案会让用户以为画布上没有,再做一遍,正是本链路要消除的重复创建。
- 决策:两条文案抽成常量并按事实分派——`PERFECT_PIXEL_ASSET_ONLY_NOTICE` 用于「服务端画布也没有」,`PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE` 用于「服务端画布已有、本地未同步」。四个使用点各归其位。
- 测试补位过程值得记:第一次回归验证只有 1 条变红——说明「对账已收口 + 本地已删」这条分支根本没有用例,文案改动是无覆盖的。补上该用例后再破坏,2 条同时变红。**如果止步于第一次验证,就会把一处无覆盖的改动当成已验证。**
- 复查中核过、确认不是问题的两点:其一,网关放宽的作用域正确——`/api/*``is_generic_api_proxy_path`,读超时默认 `3600` 秒,远高于服务端 90 秒预算与客户端 120 秒,次序是 `90 < 120 < 3600`,网关不会在服务端合法工作期间截断,它合成 502/504 只可能是连接失败或进程不可用,确属未知结果;唯一变数是有人把 `GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API_READ_TIMEOUT_SECONDS` 调到 90 秒以下。其二,`projectHasUnresolvedGenerationDialog``some(...)` 改为「取首个匹配再判」存在极低风险的语义收窄,同 id 多 dialog 时行为不同,但 id 唯一,实际不可达。
- 验证:`vitest src/components/image-editor` 908 通过 / 72 文件,typecheck、eslint、check:encoding 通过。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -2610,6 +2610,76 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('dialog').textContent).toBe('-');
});
it('tells the user the reconciled result already landed remotely after they deleted the placeholder', async () => {
// 中文注释:对账发现服务端已收口(dialog 保留且写了 generatedLayerId),说明结果**已经在
// 服务端画布上**;而本地占位被用户删掉,按删除意图不套用快照。此时不能用「只在素材库」
// 那条文案——它会让用户以为画布上没有,再做一遍造出第二份。必须说清重新加载后可见。
const applyProjectSnapshot = vi.fn();
const reconciledProject = {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:generation-dialog-1',
resourceId: 'generation-dialog:generation-dialog-1',
dialog: {
id: 'generation-dialog-1',
mode: 'quick-edit',
prompt: '完美像素',
status: 'idle',
composerOpen: false,
generatedLayerId: 'layer-perfect-pixel-1',
},
},
],
resources: [],
updatedAt: '2026-08-01T00:00:00.000Z',
};
let rejectPerfectPixel: ((error: unknown) => void) | undefined;
snapImageToPerfectPixelsMock.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectPerfectPixel = reject;
}),
);
loadEditorProjectMock.mockResolvedValueOnce(reconciledProject);
render(
<GenerationWorkflowHarness
projectId="project-1"
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toBe(
'quick-edit:generating:closed:-:placeholder',
);
});
fireEvent.click(screen.getByRole('button', { name: '删除处理占位' }));
await waitFor(() => {
expect(screen.getByTestId('generation-dialogs').textContent).toBe('-');
});
rejectPerfectPixel?.(new Error('网络中断'));
await waitFor(() => {
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'完美像素结果已生成并保存到素材库。画布占位已被删除,结果未落入当前画布,重新加载后可见。',
);
});
expect(applyProjectSnapshot).not.toHaveBeenCalled();
});
it('treats a gateway-synthesized timeout as an unknown outcome and reconciles', async () => {
// 中文注释:网关自己合成的 504 只有 code / message,没有 details,所以既不是 transport
// 异常也拿不到 resultPersistenceStarted。按「有响应即结果已知」会跳过对账,而此时
@@ -2779,6 +2849,11 @@ describe('useImageCanvasGenerationWorkflow', () => {
'完美像素结果已保存到素材库,画布占位已不存在。',
);
});
// 中文注释:本例服务端 completion 返回 Ok(None),画布上确实没有结果图层,所以只能用
// 「只在素材库」这条;用「重新加载后可见」会是谎报。两条文案对应两种不同的事实。
expect(screen.getByTestId('reference-pick-warning').textContent).not.toContain(
'重新加载后可见',
);
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(screen.getByTestId('generation-dialogs').textContent).toBe('-');
});
@@ -3099,6 +3174,12 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).toBe('-');
// 中文注释:不套用是对的,但不能静默——服务端已经完成并把结果落到画布上,只是本地按
// 删除意图没同步。文案必须说清「重新加载后可见」,不能沿用「只在素材库」那条:后者会让
// 用户以为画布上没有,再做一遍。
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'完美像素结果已生成并保存到素材库。画布占位已被删除,结果未落入当前画布,重新加载后可见。',
);
});
it('opens UI design extraction as a mark selection state before submitting', () => {
@@ -1752,6 +1752,18 @@ export function useImageCanvasGenerationWorkflow({
],
);
// 中文注释:两条文案对应两种**不同的事实**,不能共用。
// 「未落入画布」:服务端 completion 返回 Ok(None)——占位在处理期间被删掉,服务端画布上
// 没有结果图层,结果只在素材库。
// 「未落入当前画布」:服务端已经完成回填,画布上**有**结果图层,只是本地占位已被用户删除、
// 按删除意图不套用快照,所以本地看不到;重新加载即可见。
// 早先这两种情况共用前一条文案,会让「结果其实已在画布上」的用户以为只进了素材库,
// 进而重做一遍——正是本链路要消除的重复创建。
const PERFECT_PIXEL_ASSET_ONLY_NOTICE =
'完美像素结果已保存到素材库,画布占位已不存在。';
const PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE =
'完美像素结果已生成并保存到素材库。画布占位已被删除,结果未落入当前画布,重新加载后可见。';
const snapSelectedLayerToPerfectPixels = useCallback(
async (sourceLayer: CanvasLayer) => {
const normalizedProjectId = projectId?.trim();
@@ -1852,10 +1864,14 @@ export function useImageCanvasGenerationWorkflow({
if (hasCanvasGenerationDialogById(perfectPixelDialogId)) {
updateCanvasGenerationDialogById(perfectPixelDialogId, () => null);
}
showGenerationWarning('完美像素结果已保存到素材库,画布占位已不存在。');
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
return;
}
if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
// 中文注释:不套用是对的(删除意图胜出),但不能静默。服务端已经完成并把结果落到了
// 画布上,只是本地按用户的删除意图没有同步——必须说清楚,否则用户既看不到结果、
// 也不知道它已经存在,会再做一遍。这是本链路同一形状的第三处,前两处已修。
showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE);
return;
}
applyProjectSnapshot(result.project, {
@@ -1923,18 +1939,14 @@ export function useImageCanvasGenerationWorkflow({
() => null,
);
}
showGenerationWarning(
'完美像素结果已保存到素材库,画布占位已不存在。',
);
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
return;
}
if (!isUnresolvedCanvasGenerationDialogRecord(reconciledDialog)) {
// 中文注释:服务端已经完成并回填,只是响应没回来。这是真成功。
// 但本地占位若已被用户删除,删除意图仍然胜出:不套用快照、不写历史。
if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
showGenerationWarning(
'完美像素结果已保存到素材库,画布占位已不存在。',
);
showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE);
return;
}
applyProjectSnapshot(reconciled, {