From c9db36b177bb5bfb619b0c45523820c47a30638d Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 3 Aug 2026 10:00:40 +0000 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=AE=8C=E7=BE=8E=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E7=AC=AC=E4=B8=89=E5=A4=84=E9=9D=99=E9=BB=98=E5=88=86?= =?UTF-8?q?=E6=94=AF=E5=B9=B6=E5=8C=BA=E5=88=86=E4=B8=A4=E6=9D=A1=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 链路复查发现两处。 遗留:成功路径拿到 result.project 后,本地占位已被删除时直接 return。不套用是 对的(删除意图胜出),但什么都不说。同一形状此前已修两处,这是第三处;既有用例 只断言「不套用」,没断言「要说话」,所以没挡住。 新引入:上次让「对账已收口 + 本地已删」沿用了「只在素材库」的文案。两种情况事实 不同——快照里没有 dialog 时服务端画布确实没有结果;dialog 已收口时服务端画布上 有结果,只是本地未同步,重新加载即可见。混用会让用户以为画布上没有而重做。 两条文案抽成常量按事实分派。 第一次回归验证只有 1 条变红,暴露出「对账已收口 + 本地已删」这条分支根本没有 用例;补上后再破坏,2 条同时变红。 Co-Authored-By: Claude Opus 5 --- .../shared-memory/decision-log.md | 11 +++ .../useImageCanvasGenerationWorkflow.test.tsx | 81 +++++++++++++++++++ .../useImageCanvasGenerationWorkflow.ts | 26 ++++-- 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 479c10cbc..1ccfc7398 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 12f1218c6..dbf91e2e7 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -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( + , + ); + + 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', () => { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 6e6d3be5a..882a142d1 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -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, {