修复账本寿命短于标记寿命导致的成功误判与孤儿失效

阶段 3 引入的判据「布局里有 perfectPixelOperationId、本机没有账本 ⇒ 无效」
按构造就是错的:标记写进布局后寿命无限(服务端完成 completion 只做字段级
改写,从不摘标记),账本寿命却很短(收口即清、75 秒对账窗口、7 天保留期、
换设备即无)。账本消失是正常终态,不是异常。

同一个不对称造成两个缺陷:

一、每一次成功都被判成失败。settle 时先清账本、紧接着用真实 hydrate 重新
套用权威快照,成功结果被判成 failed + perfectPixelOperationInvalid;该状态
还会被下一次自动保存写回服务端,覆盖正确的 idle,且标记一旦落库就能单独
强制 failed,自我固化。历史上早已完成的占位在标记化改写后同样中招。

二、孤儿账本对账在唯一目标场景下失效。它的场景是「关掉浏览器、稍后重开」,
重开必然晚于 75 秒,而分支按 reconcileUntil 短路,等于永不生效,条目还会在
本机躺满保留期反复被跳过。

修复:凡是「缺账本 ⇒ 无效」的判据一律先排除收口态(复用既有的
isUnresolvedCanvasGenerationDialogRecord 取反)。收口态无条件忽略已落库的
无效标记与残留错误文案、强制归位 idle,让被写脏的行自愈;写边界同步收窄,
收口后不再输出标记,让标记寿命与账本对齐。

孤儿不按 reconcileUntil 短路,改走新增的 readPerfectPixelOrphanVerdict——
一次确定性的读,不轮询。dialog-missing 提示结果只进素材库;applied 静默
刷新(本次读到的就是权威状态,结果本就在眼前);未落库完全静默。三者都清
账本,读失败不清。

测试缺口的根因:工作流里所有 applied 用例的 applyProjectSnapshot 都是空桩,
跨 hook 协作从未被跑过;共享 fixture 也没还原服务端真实保留的标记。补了一条
把 verdict.project 真正喂进 splitCanvasLayoutItems 的集成用例,回退修复后它
报 expected 'failed' to be 'idle'。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 07:45:23 +00:00
parent 2fa2f245c1
commit 515682d571
5 changed files with 398 additions and 30 deletions
@@ -6387,8 +6387,22 @@
- 背景:完美像素是唯一没有 durable job 的生成路径——免费、同步、不走 `enqueue_editor_generation_job`,服务端没有任何一行记录「这次请求发出过」。为了让刷新后还能 GET-only 对账,请求账本 `perfectPixelOperation` 被写进了**用户的画布布局**,并由此派生出一条严格布局保存通道:发 POST 前必须拿到布局保存的 revision ack,否则整条链路中止。该耦合直接造成两类缺陷:一是账本寄生在用户数据上,占位一度被禁止删除(已由同日「完美像素占位恢复为可删除」作废);二是任何布局校验失败都会升级成完美像素的硬阻断,「画布图层元数据以资源行为准」那条缺陷正是因为严格保存才从静默重试变成用户可见的死锁。
- 决策:账本改由 `src/components/image-editor/perfectPixelOperationStore.ts` 存在本机 localStorage,按 owner + project 双键隔离;布局里只留 `perfectPixelOperationId` 标记,用来把这类占位与队列型占位区分开。发 POST 前先同步写本机账本,再**尽力而为**地保存布局;布局保存失败不再拦 POST,只是把结果降级为「只进素材库」。严格布局保存通道(`strictCompletion` 全套机制、`flushProjectPersistence``requireSuccess` / `requiredDialogId` / `deadlineAt` 选项、`PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS`)整体删除,只保留一个不改变失败语义的 `preferLatestGenerationDialogs`,用于取到刚创建、尚未回流到 ref 的占位。
- 为什么这样仍然安全:本机写入是同步的、不过网络、不受服务端校验影响,因此它能提供严格布局保存想提供的那个保证——「请求可被追溯」——却不引入任何阻断点。由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清账本。
- **本机账本是明确设计,缺失只降级、不得构成阻断**:换设备、换浏览器、清缓存、隐私模式、配额写满都会读不到账本。这些情况下带标记的占位一律 hydrate 成 `failed + perfectPixelOperationInvalid` 的**可删除**占位,用户删掉重来即可。任何代码路径都不得因为「读不到账本」而拒绝用户发起、重试或删除,也不得把占位停在无从收口的处理中态。跨设备不再自动收口是本决策已知且接受的代价:完美像素免费且同步,重做成本极低,用它换掉「用户数据里混着系统对账状态」的耦合是划算的。后续复查发现该行为时,应对照本条确认为设计,不得判定为回归。
- **本机账本是明确设计,缺失只降级、不得构成阻断**:换设备、换浏览器、清缓存、隐私模式、配额写满都会读不到账本。这些情况下带标记的**未收口**占位一律 hydrate 成 `failed + perfectPixelOperationInvalid` 的**可删除**占位,用户删掉重来即可(收口态占位不适用,见下一条决策)。任何代码路径都不得因为「读不到账本」而拒绝用户发起、重试或删除,也不得把占位停在无从收口的处理中态。跨设备不再自动收口是本决策已知且接受的代价:完美像素免费且同步,重做成本极低,用它换掉「用户数据里混着系统对账状态」的耦合是划算的。后续复查发现该行为时,应对照本条确认为设计,不得判定为回归。
- 兼容与生命周期:布局内联账本是 legacy 形状,hydrate 侧继续认,滚动部署期间的在途操作不会被一次性判死;写入侧不再产生新的内联账本。本机账本按 7 天保留期与 32 条上限裁剪,终态(applied / dialog-missing / 快照与项目不匹配 / 无占位可挂错误)立即清除。读取沿用与布局快照相同的 v1 白名单校验,任何字段漂移失败关闭,绝不据一份可疑账本重放 POST。
- 影响范围:新增 `perfectPixelOperationStore.ts``ImageCanvasEditorTypes.ts` 新增 `perfectPixelOperationId``ImageCanvasEditorModel.ts``serializeDialogReferences` / `hydrateCanvasGenerationDialog` / `splitCanvasLayoutItems` / `dropDeadInlineGenerationPlaceholders``useImageCanvasProjectPersistence.ts` 删除严格保存机制并在 hydrate 时读账本;`useImageCanvasGenerationWorkflow.ts` 的提交、重试与恢复 effect`useImageCanvasGenerationSurface.tsx` 的 props 类型。不修改服务端、SpacetimeDB schema 或对外契约——服务端从来不认识这个字段。
- 验证方式:账本单测覆盖往返、owner / project 隔离、跨账号整条丢弃、被篡改条目失败关闭、保留期与条数裁剪、终态清除、以及存储不可用时静默降级;模型层覆盖「布局只留标记且不含源图地址」「标记在而账本缺失时收口为可删除失败态」「账本 id 与占位不符时失败关闭」;工作流覆盖「布局保存失败仍照发 POST 并保留可重试的 operation」与「孤儿账本条目照常对账并在终态清账本」;持久化层覆盖「布局保存 400 / 403 与缺 authority 时 flush 均不抛、下游照常执行」。运行 `npx vitest run src/components/image-editor``npm run typecheck``npm run lint:eslint``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-05 账本寿命短于标记寿命:收口态不需要账本,孤儿账本不看对账窗口
- 背景:上一条把请求账本移到本机后引入了一条判据——「布局里有 `perfectPixelOperationId` 标记、本机没有账本 ⇒ 该占位无效」。这条判据按构造就是错的,因为两者寿命根本不对称:标记写进布局后**寿命无限**(服务端完成 completion 时只做字段级改写,置 `status: "idle"``composerOpen: false`、写入 `generatedLayerId`、清 `errorMessage`,从不摘掉标记,见 `editor_project_storage.rs``plan_editor_pixel_art_canvas_layout`),而账本**寿命很短**(收口即清、75 秒对账窗口、7 天保留期、换设备即无)。账本消失是正常终态,不是异常。同一个不对称还以第二种形态出现在恢复 effect 里:孤儿账本按 `reconcileUntil` 短路。
- 缺陷一(每一次成功都被判成失败):`settleLivePerfectPixelVerdict` 在 applied 终态先清账本,紧接着 `applyProjectSnapshot` 用真实 hydrate 重新套用权威快照;此时布局里标记还在、账本已清,于是成功结果被判成 `failed + perfectPixelOperationInvalid`,用户点开刚生成的图层会看到「操作快照无效」。更糟的是这个状态会被下一次自动保存序列化回服务端,覆盖服务端正确的 `idle``perfectPixelOperationInvalid` 一旦落库,此后单凭它就能强制 `failed`,**自我固化**。历史上早已完成的完美像素占位(当时带内联账本)在标记化改写后同样中招。
- 缺陷二(兜底分支在唯一目标场景下失效):孤儿账本对账是「删掉严格布局保存仍然安全」的全部依据,目标场景是「POST 已发、布局没落盘、浏览器关闭、稍后重开」——而重开几乎必然晚于 75 秒对账窗口,`operation.reconcileUntil <= now` 的短路让这条分支基本永不生效,条目还会在本机躺满整个保留期反复被跳过。
- 决策一:凡是「缺账本 ⇒ 无效」的判据,一律先排除**收口态**。收口的定义复用既有的 `isUnresolvedCanvasGenerationDialogRecord` 取反:带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`。收口态占位不需要账本——`generatedLayerId` 本身就是服务端已回填的证据;它同时**无条件忽略**已落库的 `perfectPixelOperationInvalid` 标记与残留 `errorMessage`,并把状态强制归位到 `idle`,让被上一版写脏的行在下一次 hydrate 时自愈。写边界同步收窄:`serializeDialogReferences` 对收口态占位不再输出 `perfectPixelOperationId`,让标记的寿命与账本对齐,不再在布局里堆积。
- 决策二:孤儿账本**不按 `reconcileUntil` 短路**。`reconcileUntil` 的语义是「结果可能还在飞,值得多读几次」,而孤儿是上一个会话留下的、发出它的标签页早已不在,需要的是一次能回答「到底落没落」的确定性读,不是轮询。为此新增 `readPerfectPixelOrphanVerdict`(单次 `loadEditorProject` + `inspectPerfectPixelProjectSnapshot`),不复用 `reconcilePerfectPixelProject` 的轮询循环——后者在窗口耗尽时确实会因 `hasAttemptedRead` 初值为 false 而强制读一次,但那是实现副作用而非契约,寄生在上面迟早被重构静默破坏。收口口径:`dialog-missing` → 刷新素材库 + asset-only 提示(这正是布局尽力保存失败的典型结局);`applied` → 静默刷新素材库(本次读到的就是当前项目的权威状态,结果本就在用户眼前,弹提示只是噪音);`pending` / `conflict` → 完全静默(什么都没落库,用户无需知道)。三者都清账本;**读失败不清**——那是「不知道」而非「知道没有」,留给下次加载。
- 保留不变:未收口占位在账本缺失时仍然收口成可删除的失败态,上一条决策的「缺失只降级、不得构成阻断」原样有效。本条只是把「缺失」的适用范围限定在它本来就该管的那一半。
- 同类判据的通用要求:本仓库中任何「持久化标记 + 短寿命本地状态」的组合,判据都必须先问「这个标记所指的事情是不是已经结束了」。只要标记比它依赖的状态活得久,`marker && !state ⇒ invalid` 就一定会把正常终态误判成异常。
- 影响范围:`ImageCanvasEditorModel.ts` 新增 `isSettledPerfectPixelDialogRecord` 并改写 `serializeDialogReferences` / `hydrateCanvasGenerationDialog``useImageCanvasGenerationWorkflow.ts` 新增 `readPerfectPixelOrphanVerdict` 并改写恢复 effect 的孤儿分支。不修改服务端、SpacetimeDB schema 或对外契约。
- 测试缺口的根因与补救:缺陷一能溜过整套测试,是因为工作流用例里所有 applied 场景的 `applyProjectSnapshot` 都是空桩,「收口 → 清账本 → 真实 hydrate 重新套用」这条**跨 hook 协作**从未被跑过;模型层用例又只覆盖了 `generating` + 账本缺失,没有 `idle + generatedLayerId` 这一真实终态形状。补救不是多加两条断言,而是新增一条把 `verdict.project` 真正喂进 `splitCanvasLayoutItems` 的集成用例,并把共享 fixture `createPerfectPixelProject` 补上服务端真实会保留的 `perfectPixelOperationId`——fixture 不还原真实形状,下游所有用例都在测一个不存在的世界。
- 验证方式:模型层覆盖「收口态无账本仍有效且序列化不再输出标记」「被上一版写脏的行自愈成 idle 且清掉残留错误文案」「收口态的内联 legacy 账本被剥离且不留标记」;工作流层覆盖「applied 结果经真实 hydrate 回来仍是 idle」(该用例已实证:回退修复后报 `expected 'failed' to be 'idle'`)、「过期孤儿仍做且只做一次读并清账本」、「未落库的孤儿静默清账本、不提示、不刷新素材库」。运行 `npx vitest run src/components/image-editor src/components/platform-entry``npm run typecheck``npm run lint:eslint``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -1031,6 +1031,92 @@ describe('ImageCanvasEditorModel', () => {
);
});
it('keeps a settled perfect-pixel placeholder valid without any local ledger', () => {
// 中文注释:服务端完成 completion 后只做字段级改写,perfectPixelOperationId 会永久留在
// 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状,
// 绝不能被判成失败。
const dialogId = 'dialog-perfect-pixel-settled';
const settledSnapshot = {
id: dialogId,
mode: 'quick-edit' as const,
prompt: '完美像素',
status: 'idle' as const,
composerOpen: false,
generatedLayerId: `layer-${dialogId}`,
perfectPixelOperationId: dialogId,
};
const hydrated = hydrateCanvasGenerationDialog(settledSnapshot);
expect(hydrated).toMatchObject({
id: dialogId,
status: 'idle',
generatedLayerId: `layer-${dialogId}`,
});
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
expect(hydrated).not.toHaveProperty('perfectPixelOperationId');
expect(hydrated?.errorMessage).toBeUndefined();
// 中文注释:标记的寿命必须与账本对齐——收口后不再写回布局,否则错误形状会一直堆积。
const layout = serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
});
expect(JSON.stringify(layout)).not.toContain('"perfectPixelOperationId"');
});
it('heals a settled placeholder that a previous build wrote back as invalid', () => {
// 中文注释:上一版判据会把成功结果写成 failed + perfectPixelOperationInvalid 并落库。
// 这类已经被写脏的行必须在下一次 hydrate 时自愈,否则错误状态会自我固化。
const dialogId = 'dialog-perfect-pixel-poisoned';
const hydrated = hydrateCanvasGenerationDialog({
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'failed',
composerOpen: false,
generatedLayerId: `layer-${dialogId}`,
perfectPixelOperationId: dialogId,
perfectPixelOperationInvalid: true,
errorMessage: '完美像素操作快照无效,禁止自动重试。',
});
expect(hydrated).toMatchObject({ id: dialogId, status: 'idle' });
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
expect(hydrated?.errorMessage).toBeUndefined();
});
it('drops the inline legacy ledger of an already settled placeholder without marking it', () => {
const dialogId = 'dialog-perfect-pixel-settled-legacy';
const layout = serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [
{
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'idle',
composerOpen: false,
generatedLayerId: `layer-${dialogId}`,
perfectPixelOperation: buildPerfectPixelOperation(dialogId),
} as CanvasGenerationDialogState,
],
});
const serializedLayout = JSON.stringify(layout);
expect(serializedLayout).not.toContain('"perfectPixelOperation"');
expect(serializedLayout).not.toContain('"perfectPixelOperationId"');
const { generationDialogs } = splitCanvasLayoutItems(layout);
expect(generationDialogs[0]).toMatchObject({
id: dialogId,
status: 'idle',
});
expect(generationDialogs[0]).not.toHaveProperty(
'perfectPixelOperationInvalid',
);
});
it('settles a perfect-pixel placeholder as deletable failure when the local ledger is absent', () => {
const dialogId = 'dialog-perfect-pixel-other-device';
const operation = buildPerfectPixelOperation(dialogId);
@@ -730,13 +730,19 @@ function serializeGenerationReferences(
* 账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容,改由
* `perfectPixelOperationStore` 存在本机(见那里的长注释)。布局仍需要一个标记,否则
* 换设备打开时无法把这类占位与队列型占位区分开,只能当成普通占位一直转下去。
*
* 标记的寿命必须与账本对齐:收口后账本会被清掉,标记也就不该再留在布局里。否则每一次成功
* 的完美像素都会在布局里留下一个「有标记、没账本」的占位,被 hydrate 判成无效。
*/
function serializeDialogReferences(
dialog: CanvasGenerationDialogState,
): CanvasGenerationDialogState {
const { perfectPixelOperation, ...persistedDialog } = dialog;
const perfectPixelOperationId =
dialog.perfectPixelOperationId ?? perfectPixelOperation?.operationId;
const perfectPixelOperationId = isSettledPerfectPixelDialogRecord(
dialog as unknown as Record<string, unknown>,
)
? undefined
: (dialog.perfectPixelOperationId ?? perfectPixelOperation?.operationId);
return {
...persistedDialog,
...(perfectPixelOperationId ? { perfectPixelOperationId } : {}),
@@ -878,6 +884,21 @@ export function isUnresolvedCanvasGenerationDialogRecord(
);
}
/**
* 中文注释:完美像素占位是否已经收口,即结果图层已被服务端回填进画布。
*
* **本机账本只在占位未收口期间存在**:发 POST 前写入,拿到终态就清除。而布局里的
* `perfectPixelOperationId` 标记寿命无限——服务端完成时只做字段级改写(置 `status: "idle"`、
* 写入 `generatedLayerId`),从不摘掉这个标记。两者寿命不对称,所以任何「有标记、没账本
* ⇒ 无效」的判据都必须先排除收口态,否则每一次**成功**的完美像素都会在下一次 hydrate 时
* 被判成 `failed + perfectPixelOperationInvalid`,并把这个错误状态写回服务端。
*/
function isSettledPerfectPixelDialogRecord(
dialog: Record<string, unknown> | null,
): boolean {
return Boolean(dialog) && !isUnresolvedCanvasGenerationDialogRecord(dialog);
}
/**
* 中文注释:从占位创建起算的存活窗口。超过它还停在 `generating` 的 inline 占位,确定是孤儿。
*
@@ -1148,12 +1169,20 @@ export function hydrateCanvasGenerationDialog(
snapshot,
'perfectPixelOperationInvalid',
);
// 中文注释:标记在、账本不在,正是「换设备 / 清缓存 / 隐私模式」这条明确设计的路径。
// 收口为可删除的失败占位即可,不得阻断用户删除或从源图重做。
// 中文注释:收口态占位不需要账本——服务端已经把结果图层回填进画布,`generatedLayerId`
// 就是证据。账本在收口那一刻已被主动清除,标记却永远留在布局里(服务端不摘),所以下面
// 这些判据必须先排除收口态,否则每一次成功都会被判成失败,并把错误状态写回服务端。
// 已经被写脏的历史行也在这里一并纠正:收口态无条件忽略已落库的无效标记。
const isSettledPerfectPixelPlaceholder =
isPerfectPixelPlaceholder &&
isSettledPerfectPixelDialogRecord(snapshot as Record<string, unknown>);
// 中文注释:标记在、账本不在且尚未收口,正是「换设备 / 清缓存 / 隐私模式」这条明确设计的
// 路径。收口为可删除的失败占位即可,不得阻断用户删除或从源图重做。
const hasInvalidPerfectPixelOperation =
(isPerfectPixelPlaceholder && !perfectPixelOperation) ||
hasPersistedInvalidPerfectPixelOperationMarker ||
(snapshot.status === 'pending-confirmation' && !perfectPixelOperation);
!isSettledPerfectPixelPlaceholder &&
((isPerfectPixelPlaceholder && !perfectPixelOperation) ||
hasPersistedInvalidPerfectPixelOperationMarker ||
(snapshot.status === 'pending-confirmation' && !perfectPixelOperation));
const trustedPerfectPixelOperation = hasInvalidPerfectPixelOperation
? undefined
: perfectPixelOperation;
@@ -1172,14 +1201,20 @@ export function hydrateCanvasGenerationDialog(
prompt,
status: hasInvalidPerfectPixelOperation
? 'failed'
: isGenerationStatus(snapshot.status)
? snapshot.status
: 'idle',
: // 中文注释:带 generatedLayerId 的完美像素占位按定义已被服务端回填,状态只能是
// `idle`。这里强制归位,顺带修复被上一版判据写脏成 `failed` 的历史行。
isSettledPerfectPixelPlaceholder
? 'idle'
: isGenerationStatus(snapshot.status)
? snapshot.status
: 'idle',
// 中文注释:只认布尔 true。缺字段的历史占位一律视为未置位,按队列型处理原样恢复,
// 不会被 dropDeadInlineGenerationPlaceholders 误清。
requiresLiveSession:
snapshot.requiresLiveSession === true ? true : undefined,
...(isPerfectPixelPlaceholder ? { perfectPixelOperationId: id } : {}),
...(isPerfectPixelPlaceholder && !isSettledPerfectPixelPlaceholder
? { perfectPixelOperationId: id }
: {}),
...(trustedPerfectPixelOperation
? { perfectPixelOperation: trustedPerfectPixelOperation }
: {}),
@@ -1308,7 +1343,12 @@ export function hydrateCanvasGenerationDialog(
imageSize: stringOrUndefined(snapshot.imageSize),
errorMessage: hasInvalidPerfectPixelOperation
? INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE
: stringOrUndefined(snapshot.errorMessage),
: // 中文注释:服务端回填成功时会清掉 errorMessage,所以收口态占位身上的错误文案一定
// 是残留——上一版判据写进去的那句「快照无效」正是这样落库的。一并清掉,否则状态
// 已经纠正回 idle,面板上却还挂着一句失败提示。
isSettledPerfectPixelPlaceholder
? undefined
: stringOrUndefined(snapshot.errorMessage),
generationStartedAt: numberOrUndefined(snapshot.generationStartedAt),
generationFinishedAt: numberOrUndefined(snapshot.generationFinishedAt),
placeholder: hydrateGenerationPlaceholder(snapshot.placeholder),
@@ -15,7 +15,10 @@ import type {
EditorPixelArtSnapInput,
EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import { PERFECT_PIXEL_RECONCILIATION_WINDOW_MS } from './ImageCanvasEditorModel';
import {
PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
splitCanvasLayoutItems,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
@@ -177,6 +180,10 @@ function createPerfectPixelProject(
prompt: '完美像素',
status: state === 'pending' ? 'generating' : 'idle',
composerOpen: false,
// 中文注释:服务端完成 completion 时只做字段级改写(status/composerOpen/
// generatedLayerId/errorMessage),从不摘掉 perfectPixelOperationId。fixture 必须
// 保留这个标记,否则「收口态占位 + 账本已清」这条真实形状永远测不到。
perfectPixelOperationId: operationId,
...(state === 'applied' ? { generatedLayerId } : {}),
},
};
@@ -3415,6 +3422,64 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('generation-dialogs').textContent).toBe('-');
});
it('does not turn an applied perfect-pixel result into an invalid placeholder when rehydrated', async () => {
// 中文注释:这条盯的是两个 hook 的**协作**,不是单个函数。此前所有 applied 用例的
// applyProjectSnapshot 都是空桩,于是「收口 → 清账本 → 用真实 hydrate 重新套用权威
// 快照」这条链路从未被跑过,成功结果被判成 failed 的缺陷才得以溜过整套测试。这里把
// 桩换成真实的 splitCanvasLayoutItems + 本机账本读取。
let operationId = '';
let rehydratedDialogs: CanvasGenerationDialogState[] = [];
const applyProjectSnapshot = vi.fn((project: EditorProjectSnapshot) => {
const { generationDialogs } = splitCanvasLayoutItems(
project.layers,
new Map(),
'user-a',
readPerfectPixelOperations('user-a', 'project-1'),
);
rehydratedDialogs = generationDialogs;
});
snapImageToPerfectPixelsMock.mockImplementationOnce(
async (request: EditorPixelArtSnapInput) => {
operationId = request.canvasCompletion.dialogId;
throw new Error('网络中断');
},
);
loadEditorProjectMock.mockImplementation(async () =>
createPerfectPixelProject(operationId, 'applied'),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
currentUserId="user-a"
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(applyProjectSnapshot).toHaveBeenCalled();
});
const settledDialog = rehydratedDialogs.find(
(dialog) => dialog.id === operationId,
);
expect(settledDialog).toBeTruthy();
expect(settledDialog?.status).toBe('idle');
expect(settledDialog?.generatedLayerId).toBe(`layer-${operationId}`);
expect(settledDialog).not.toHaveProperty('perfectPixelOperationInvalid');
expect(settledDialog?.errorMessage).toBeUndefined();
// 中文注释:收口后账本确实被清,本用例因此正是「有标记、没账本」的真实形状。
expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0);
});
it('reconciles an orphan local ledger entry whose placeholder never reached the layout', async () => {
// 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进
// 布局就断电」的窗口。刷新后必须仍然对账并告知用户结果去了素材库——这正是删掉严格
@@ -3470,6 +3535,111 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0);
});
it('still reconciles an orphan ledger entry whose reconcile window already expired', async () => {
// 中文注释:孤儿的目标场景就是「关掉浏览器、稍后重开」,重开必然晚于 75 秒对账窗口。
// 按 reconcileUntil 短路会让这条兜底分支在它唯一的用武之地上完全失效。
const operationId = 'perfect-pixel-orphan-expired';
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
const submittedAt = Date.now() - 6 * 60 * 60 * 1_000;
savePerfectPixelOperation('user-a', 'project-1', {
version: 1,
kind: 'perfect-pixel',
operationId,
taskId: `pixel-art-snap-${operationId}`,
request: {
sourceImageSrc: 'generated-images/editor/source.png',
projectId: 'project-1',
canvasCompletion: {
dialogId: operationId,
title: '源图 · 完美像素',
placeholder: {
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
},
submittedAt,
reconcileUntil: submittedAt + 75_000,
});
loadEditorProjectMock.mockImplementation(async () =>
createPerfectPixelProject(operationId, 'dialog-missing'),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
currentUserId="user-a"
applyProjectSnapshotWithoutHistory={vi.fn(() => true)}
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
refreshAssetLibrary={refreshAssetLibrary}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'完美像素结果已保存到素材库,画布占位已不存在。',
);
});
// 中文注释:孤儿只做一次确定性的读,不轮询。
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0);
});
it('silently clears an orphan ledger entry whose result never landed', async () => {
const operationId = 'perfect-pixel-orphan-nothing-landed';
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
const submittedAt = Date.now() - 6 * 60 * 60 * 1_000;
savePerfectPixelOperation('user-a', 'project-1', {
version: 1,
kind: 'perfect-pixel',
operationId,
taskId: `pixel-art-snap-${operationId}`,
request: {
sourceImageSrc: 'generated-images/editor/source.png',
projectId: 'project-1',
canvasCompletion: {
dialogId: operationId,
title: '源图 · 完美像素',
placeholder: {
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
},
submittedAt,
reconcileUntil: submittedAt + 75_000,
});
loadEditorProjectMock.mockImplementation(async () =>
createEmptyPerfectPixelProject(),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
currentUserId="user-a"
applyProjectSnapshotWithoutHistory={vi.fn(() => true)}
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
refreshAssetLibrary={refreshAssetLibrary}
/>,
);
await waitFor(() => {
expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0);
});
// 中文注释:什么都没落库,用户不需要知道任何事;弹提示只会制造噪音。
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
expect(refreshAssetLibrary).not.toHaveBeenCalled();
});
it('does not let a never-settling asset refresh block a terminal reconciliation verdict', async () => {
const applyProjectSnapshot = vi.fn();
const refreshAssetLibrary = vi.fn(() => new Promise(() => {}));
@@ -417,6 +417,26 @@ async function reconcilePerfectPixelProject(
throw options.signal?.reason ?? new DOMException('操作已取消', 'AbortError');
}
/**
* 中文注释:孤儿账本条目的收口读取——**一次确定性的读,不轮询**。
*
* `reconcileUntil` 那 75 秒窗口的意义是「结果可能还在飞,值得多读几次」。孤儿账本是上一个
* 会话留下的,发出它的标签页早已不在,服务端要么写完了要么没写;此时需要的是一次能回答
* 「到底落没落」的读,而不是轮询。窗口是否已过期与要不要读无关——恰恰相反,用户重新打开
* 项目几乎必然晚于 75 秒,按窗口短路会让孤儿对账在它唯一的目标场景下完全失效。
*/
async function readPerfectPixelOrphanVerdict(
projectId: string,
operation: PerfectPixelOperationSnapshot,
options: { signal?: AbortSignal } = {},
): Promise<PerfectPixelProjectVerdict> {
const project = await loadEditorProject(projectId, {
signal: options.signal,
deadlineAt: Date.now() + PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS,
});
return inspectPerfectPixelProjectSnapshot(project, operation);
}
function resolveConfirmedPerfectPixelAsset(
result: EditorPixelArtSnapResult | null,
operation: PerfectPixelOperationSnapshot,
@@ -2763,28 +2783,41 @@ export function useImageCanvasGenerationWorkflow({
dialog.perfectPixelOperation &&
(dialog.status === 'generating' ||
dialog.status === 'pending-confirmation')
? [{ dialogId: dialog.id, operation: dialog.perfectPixelOperation }]
? [
{
dialogId: dialog.id,
operation: dialog.perfectPixelOperation,
isOrphan: false,
},
]
: [],
);
// 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进
// 布局就断电」的窗口。那些孤儿账本同样要对账——结果多半已经落进素材库,用户有权知道。
// 这条路径不需要占位存在:下面所有 updateCanvasGenerationDialogById 对缺席 dialog 都是
// 无操作,applied / dialog-missing 分支本就带着「占位不在」的提示文案。
const recoverableDialogIds = new Set(
//
// 只要本地还存在同 id 的 dialog 就不走孤儿路径,无论它是什么状态:`generating` /
// `pending-confirmation` 由上面的 dialog 路径负责;`failed` 说明用户可以在原占位手动
// 重试,账本仍归那个占位所有,此时抢先当孤儿读掉并清账本会连带废掉重试能力。
const localDialogIds = new Set(
canvasGenerationDialogs.map((dialog) => dialog.id),
);
const nowMs = Date.now();
for (const [operationId, operation] of readPerfectPixelOperations(
currentUserId,
normalizedProjectId,
)) {
if (
recoverableDialogIds.has(operationId) ||
operation.reconcileUntil <= nowMs
) {
if (localDialogIds.has(operationId)) {
continue;
}
recoverableOperations.push({ dialogId: operationId, operation });
// 中文注释:这里**不能**按 `reconcileUntil` 短路。孤儿的目标场景就是「关掉浏览器、
// 稍后重开」,重开必然晚于 75 秒对账窗口;按窗口跳过等于让这条兜底分支永不生效,
// 而条目还会在本机躺满整个保留期反复被跳过。
recoverableOperations.push({
dialogId: operationId,
operation,
isOrphan: true,
});
}
const recoveryKeys = new Set(
recoverableOperations.map(({ operation }) =>
@@ -2801,7 +2834,7 @@ export function useImageCanvasGenerationWorkflow({
}
}
for (const { dialogId, operation } of recoverableOperations) {
for (const { dialogId, operation, isOrphan } of recoverableOperations) {
const recoveryKey = perfectPixelRecoveryKey(
currentUserId,
normalizedProjectId,
@@ -2852,19 +2885,44 @@ export function useImageCanvasGenerationWorkflow({
});
return;
}
const verdict = await reconcilePerfectPixelProject(
normalizedProjectId,
operation,
{
signal: controller.signal,
},
);
const verdict = isOrphan
? await readPerfectPixelOrphanVerdict(
normalizedProjectId,
operation,
{
signal: controller.signal,
},
)
: await reconcilePerfectPixelProject(normalizedProjectId, operation, {
signal: controller.signal,
});
if (
controller.signal.aborted ||
projectId?.trim() !== normalizedProjectId
) {
return;
}
if (isOrphan) {
// 中文注释:孤儿读到任何结论就收口清账本,避免它在保留期内每次加载都被重读。
// 读失败(走下面的 catch)不清——那是「不知道」,不是「知道没有」,留给下次加载。
forgetPerfectPixelOperation(
currentUserId,
normalizedProjectId,
operation.operationId,
);
if (verdict.kind === 'dialog-missing') {
// 中文注释:服务端当时没有占位可回填,结果只进了素材库——这正是「布局尽力保存
// 失败」的典型结局,用户必须被告知。
refreshPerfectPixelAssetLibrary();
showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE);
} else if (verdict.kind === 'applied') {
// 中文注释:服务端已经把结果回填进画布,而本次读到的正是当前项目的权威状态,
// 所以结果本就在用户眼前。静默刷新素材库即可,弹提示只是噪音。
refreshPerfectPixelAssetLibrary();
}
// 中文注释:pending / conflict 意味着什么都没落库,用户无需知道任何事。
return;
}
if (verdict.kind === 'applied' || verdict.kind === 'dialog-missing') {
// 中文注释:项目事实已给出终态,账本收口清掉,避免下次加载重复对账。
forgetPerfectPixelOperation(