diff --git a/src/components/canvas/headless-recovery-dialog.tsx b/src/components/canvas/headless-recovery-dialog.tsx index c718679..91068c2 100644 --- a/src/components/canvas/headless-recovery-dialog.tsx +++ b/src/components/canvas/headless-recovery-dialog.tsx @@ -21,6 +21,8 @@ function message(error:unknown){ if(code==='stale_endpoint')return '连线所指元素已删除或进入新的生命周期,请重新选择端点。'; if(code==='unsupported_presentation_units'||code==='invalid_presentation_units')return '这个元素的展示单位暂时无法转换,原内容已保留,请确认展示格式后重试。'; if(code==='unsupported_coordinate_path')return '这条曲线使用未知坐标格式,当前无法安全转换归属,原路径仍已保留。'; + if(code==='recovery_history_traversal_order')return '这些撤销或重做有先后顺序,不能跳过前一项继续处理后一项。请重新选择。'; + if(code==='recovery_history_traversal_conflict')return '服务端内容与这项历史记录已有冲突,暂时无法安全调整撤销或重做;原内容和历史均已保留。'; if(code?.startsWith('recovery_history')||code==='recovery_record_replacement_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。'; if(code==='recovery_action_requires_new_command')return '生成任务需要重新手动发起。可以在这里放弃尚未执行的请求。'; if(code==='forbidden'||code==='unauthorized'||code==='identity_changed')return '当前账号无法读取或修改此画布,请确认权限后重试。'; @@ -66,13 +68,13 @@ export function HeadlessRecoveryDialog({engine,close}:{engine:ProjectEngine;clos {!!session?.acknowledgedRequestIds.length&&

已找回 {session.acknowledgedRequestIds.length} 个成功回执,仅处理后续尚未同步的修改。

} {session&&records.length===0&&

没有待处理的本地修改,可以采用服务端最新内容。

} {session&&records.map((intent,index)=>
- 修改 {index+1}{intent.operationType==='run.start'?' · 尚未受理的生成':intent.operationType==='run.cancel'?' · 尚未确认的取消':''} + 修改 {index+1}{intent.origin==='undo'?' · 未同步的撤销':intent.origin==='redo'?' · 未同步的重做':''}{intent.operationType==='run.start'?' · 尚未受理的生成':intent.operationType==='run.cancel'?' · 尚未确认的取消':''} {intent.changes.map((change,changeIndex)=>{if(change.kind!=='field')return ;const element=canonical?.elements.get(change.target.id),key=change.field.startsWith('data.')?change.field.slice(5):undefined,value=key===undefined?(element as unknown as Record|undefined)?.[change.field]:element?.data[key],proposed=plan?.document.elements.get(change.target.id),proposedValue=key===undefined?(proposed as unknown as Record|undefined)?.[change.field]:proposed?.data[key];return
{fieldLabel(change.field)}
服务端:{element?:'元素已不存在'}
本地:{change.afterPresent===false?'移除此字段':}
{plan&&
处理后:{proposed?:'元素已不存在'}
}
;})}
)} - {plan&&

方案已生成:保留 {plan.audit.decisions.filter(item=>item.choice==='keep-local').length} 项,放弃 {plan.audit.decisions.filter(item=>item.choice==='discard-local').length} 项。撤销记录由 {engine.getSnapshot().history.undo.length} 项调整为 {plan.history.undo.length} 项。

} + {plan&&

方案已生成:保留 {plan.audit.decisions.filter(item=>item.choice==='keep-local').length} 项,放弃 {plan.audit.decisions.filter(item=>item.choice==='discard-local').length} 项。撤销记录由 {engine.getSnapshot().history.undo.length} 项调整为 {plan.history.undo.length} 项,重做记录由 {engine.getSnapshot().history.redo.length} 项调整为 {plan.history.redo.length} 项。

} {plan&&} {error&&

{error}

} diff --git a/src/lib/canvas-runtime/change-rebase.ts b/src/lib/canvas-runtime/change-rebase.ts index 1d4cd1b..096d37a 100644 --- a/src/lib/canvas-runtime/change-rebase.ts +++ b/src/lib/canvas-runtime/change-rebase.ts @@ -59,6 +59,29 @@ function movedNode(current:Extract,originalBef return{geometry,presentation}; } +/** Restore batches may have inverse storage order (canvas before entry). Only reorder adjacent creates, never move across an edit/delete. */ +function orderedCreates(changes:readonly Change[]):readonly Change[]{ + const result:Change[]=[]; + const creating=(change:Change)=>change.kind!=='field'&&!change.before&&!!change.after; + for(let offset=0;offset`${change.kind}:${(change as Extract).id}`),positions=new Map(keys.map((key,index)=>[key,index])); + if(positions.size!==batch.length)throw fault('recovery_creation_dependency_conflict'); + const counts=batch.map(()=>0),waiting=new Map(),ready:number[]=[]; + batch.forEach((change,index)=>{ + const dependencies=new Set(); + if(change.kind==='canvas'&&change.after?.parent){dependencies.add(`canvas:${change.after.parent.canvasId}`);dependencies.add(`element:${change.after.parent.entryElementId}`);} + if(change.kind==='element'&&change.after){dependencies.add(`canvas:${change.after.ownerCanvasId}`);if(change.after.structure==='connection'){dependencies.add(`element:${change.after.source.elementId}`);dependencies.add(`element:${change.after.target.elementId}`);}} + for(const key of dependencies){const dependency=positions.get(key);if(dependency===undefined)continue;counts[index]++;const list=waiting.get(dependency)??[];list.push(index);waiting.set(dependency,list);} + if(!counts[index])ready.push(index); + }); + let emitted=0;for(let next=0;next(),deferredPaths:FieldChange[]=[]; @@ -66,11 +89,12 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));} function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;} function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);} - for(const original of intent.changes){ + for(const original of orderedCreates(intent.changes)){ if(original.kind==='element'){ const current=document.elements.get(original.id); if(original.before===null&&original.after){ - if(current||document.tombstones.has(original.id))throw fault('recovery_identity_collision',[original.id]); + const dead=document.tombstones.get(original.id),restoring=['undo','redo'].includes(intent.origin)&&dead!==undefined&&original.after.incarnation===dead+1; + if(current||dead!==undefined&&!restoring)throw fault('recovery_identity_collision',[original.id]); if(registry.resolve(original.after)!=='known')throw fault('unsupported_component',[original.id]); let after=original.after; if(after.structure==='node'){const basis=context.transforms.get(after.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[after.id]);const destination=canvasTransform(document,after.ownerCanvasId),presentation=withUnitScale(after.data.presentation,unitScale(after.data.presentation)*basis.scale/destination.scale),data={...after.data};if(presentation===undefined)delete data.presentation;else data.presentation=presentation;after={...after,data,geometry:convertGeometry(after.geometry,basis,destination)};} diff --git a/src/lib/canvas-runtime/conflicts.ts b/src/lib/canvas-runtime/conflicts.ts index 8c888a7..c77a65c 100644 --- a/src/lib/canvas-runtime/conflicts.ts +++ b/src/lib/canvas-runtime/conflicts.ts @@ -5,6 +5,7 @@ import { equal, fault, freeze, readMap } from '../canvas-core/values.ts'; import type { CanonicalProposal, EngineSnapshot } from './contracts.ts'; import { createLedger, type Presence } from './ledger.ts'; import { intentContexts, rebaseIntent } from './change-rebase.ts'; +import { replanTraversals } from './traversal-recovery.ts'; import { operationCanvases, validateCanonicalRead } from './reconcile.ts'; import { referencedResources } from './resources.ts'; @@ -38,14 +39,14 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni for(const record of records){ const intent=record.intent,choice=choices.get(intent.id)!; if(intent.operationSchemaVersion!==1)throw fault('operation_interpreter_unavailable',[intent.id]); - if(intent.changes.length&&intent.origin!=='user')throw fault('recovery_history_traversal_not_supported',[intent.id]); + if(intent.changes.length&&!['user','undo','redo'].includes(intent.origin))throw fault('recovery_history_traversal_not_supported',[intent.id]); const before=document,entryId=historyOwner.get(intent.id); if(entryId&&!boundaries.has(entryId))boundaries.set(entryId,{before,after:before}); for(const original of intent.changes)if(original.kind==='field'){ const owner=historyOwner.get(intent.id),key=owner?JSON.stringify([owner,fieldKey(original)]):undefined; if(key&&!starts.has(key))starts.set(key,value(document,original,true)); } - if(choice==='keep-local')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]); + if(choice==='keep-local'&&intent.origin==='user')for(const change of intent.changes)if(change.kind==='canvas'&&!change.before&&canonical.versions.has(change.id))throw fault('recovery_identity_collision',[change.id]); const rebased=choice==='keep-local'?rebaseIntent(document,intent,contexts.get(intent.id)!,registry):{document,changes:[]}; const changes=rebased.changes;document=rebased.document; if(entryId)boundaries.get(entryId)!.after=document; @@ -66,7 +67,7 @@ export function planConflictRecovery(snapshot: EngineSnapshot, canonical: Canoni replacements.set(intent.id,replacement); decisions.push(freeze({operationId:intent.id,choice,resolution:choice==='discard-local'?'discarded':replacement?'superseded':'satisfied',...(replacement?{replacementId:replacement.id}:{})})); } - const history=rewriteHistory(snapshot,records.map(record=>record.intent),replacements,starts,boundaries,registry); + const pending=records.map(record=>record.intent),history=pending.some(intent=>['undo','redo'].includes(intent.origin)&&intent.changes.length)?replanTraversals(snapshot,pending,replacements,new Set(decisions.filter(decision=>decision.choice==='keep-local').map(decision=>decision.operationId)),boundaries,registry):rewriteHistory(snapshot,pending,replacements,starts,boundaries,registry); ledger.retire(id,decisions); for(const replacement of replacements.values())if(replacement){ const presence:Record={};for(const change of replacement.changes)if(change.kind==='field')presence[change.target.id]??='exists';else if(change.kind==='element')presence[change.id]??=change.before?'exists':'absent'; diff --git a/src/lib/canvas-runtime/traversal-recovery.ts b/src/lib/canvas-runtime/traversal-recovery.ts new file mode 100644 index 0000000..9fbb9bd --- /dev/null +++ b/src/lib/canvas-runtime/traversal-recovery.ts @@ -0,0 +1,38 @@ +import type { Document, HistoryState, Id, OperationIntent, Registry } from '../canvas-core/types.ts'; +import type { EngineSnapshot } from './contracts.ts'; +import { invert } from '../canvas-core/document.ts'; +import { planHistoryChanges } from '../canvas-core/history.ts'; +import { fault, freeze, readMap } from '../canvas-core/values.ts'; + +/** Rewind only proved local stack moves, then replay the explicitly retained traversals. Never publish the temporary cursor. */ +export function replanTraversals(snapshot:EngineSnapshot,pending:readonly OperationIntent[],replacements:ReadonlyMap,kept:ReadonlySet,boundaries:ReadonlyMap,registry:Registry):HistoryState { + const traversals=pending.filter(intent=>intent.changes.length); + if(traversals.some(intent=>!['undo','redo'].includes(intent.origin)))throw fault('recovery_history_mixed_traversal_not_supported'); + const owner=new Map();for(const entry of snapshot.history.entries.values())for(const id of entry.operationIds)owner.set(id,entry.id); + const seen=new Set(),undo=[...snapshot.history.undo],redo=[...snapshot.history.redo],entries=new Map(snapshot.history.entries); + function entryId(intent:OperationIntent){const id=owner.get(intent.id);if(!id)throw fault('recovery_history_traversal_evidence_missing',[intent.id]);return id;} + function move(source:Id[],destination:Id[],id:Id){if(source.at(-1)!==id)throw fault('recovery_history_traversal_order',[id]);source.pop();destination.push(id);} + for(const intent of [...traversals].reverse()){ + const id=entryId(intent);if(seen.has(id))throw fault('recovery_history_repeated_traversal_not_supported',[id]);seen.add(id); + if(intent.origin==='undo')move(redo,undo,id);else move(undo,redo,id); + } + for(const intent of traversals){ + const id=entryId(intent),entry=entries.get(id)!,source=intent.origin==='undo'?undo:redo,destination=intent.origin==='undo'?redo:undo; + if(source.at(-1)!==id)throw fault('recovery_history_traversal_order',[id]); + const replacement=replacements.get(intent.id),boundary=boundaries.get(id)!; + if(kept.has(intent.id)){ + if(!replacement){source.pop();entries.delete(id);continue;} // No remaining local effect, retired with an audit rather than a fabricated redo. + const changes=intent.origin==='undo'?invert(replacement.changes):replacement.changes; + try{planHistoryChanges(boundary.after,intent.origin==='undo'?changes:invert(changes),registry);} + catch{throw fault('recovery_history_traversal_conflict',[id]);} + move(source,destination,id); + entries.set(id,freeze({...entry,changes,operationIds:entry.operationIds.map(operationId=>operationId===intent.id?replacement.id:operationId)})); + }else{ + const changes=intent.origin==='undo'?invert(intent.changes):intent.changes; + try{planHistoryChanges(boundary.before,intent.origin==='undo'?invert(changes):changes,registry);} + catch{throw fault('recovery_history_traversal_conflict',[id]);} + entries.set(id,freeze({...entry,changes,operationIds:entry.operationIds.filter(operationId=>operationId!==intent.id)})); + } + } + return Object.freeze({undo:freeze(undo),redo:freeze(redo),entries:readMap(entries)}); +} diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 2bd8b72..faa6a7d 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -5,6 +5,22 @@ import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts'; import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts'; import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts'; +async function traversalRecoveryCase(name,assert){ + const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()); + const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:300,height:200,rotation:0},data:{title:id,text:''}}); + const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'entry'}}],[node('anchor'),{...node('entry'),componentId:'frame'},node('member',child)]),storage=createBrowserStorage({name:`${name}-traversal`}),calls=[];let document=original,versions=new Map([[root,1],[child,1]]),reject=false,engine; + const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),canvases:[...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)),tombstones:Object.fromEntries(document.tombstones),versions:Object.fromEntries(participants.map(id=>[id,versions.get(id)]))}); + const transport=async request=>{calls.push(request);if(request.method==='GET')return{status:200,body:{model:model(true)}};const body=JSON.parse(request.body);if(reject)return{status:409,body:{protocol_version:2,request_id:request.requestId,status:'not-applied',terminal:true,error:'version_conflict'}};for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'traversal restoration lost an archive version fence');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(false,Object.keys(body.expected_versions))}};}; + const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + try{ + engine=await openProjectEngine(options);engine.editor.dispatch({type:'element.remove',target:{id:'entry',incarnation:1}});await engine.settle();reject=true;const undo=engine.editor.history.undo();await engine.settle();const old=[...engine.getSnapshot().runtime.ledger.requests.values()].find(request=>request.operationIds.includes(undo.operationIds[0])); + document=applyChanges(document,[{kind:'field',target:{id:'anchor',incarnation:1},field:'data.title',before:'anchor',after:'remote',beforePresent:true,afterPresent:true}]);versions.set(root,versions.get(root)+1); + const session=await engine.prepareRecovery(),plan=session.plan(new Map([[undo.operationIds[0],'keep-local']]));reject=false;await session.resolve(plan);await engine.settle();await engine.close();engine=await openProjectEngine(options); + assert(engine.getSnapshot().history.redo.length===1&&engine.getSnapshot().history.undo.length===0,'recovered undo did not retain its stack transition');assert(document.elements.get('member').incarnation===2,'restoration used the deleted incarnation');assert(engine.getSnapshot().runtime.ledger.requests.get(old.id).body===old.body,'rejected undo bytes changed'); + assert(engine.editor.history.redo().status==='applied','redo after recovered undo failed');await engine.settle();assert(!document.canvases.has(child),'redo did not archive the restored child');assert(engine.editor.history.undo().status==='applied','next lifecycle undo failed');await engine.settle();assert(document.elements.get('member').incarnation===3,'next undo did not advance incarnation');assert(document.elements.get('anchor').data.title==='remote','traversal recovery erased remote content');await engine.close();engine=await openProjectEngine(options);assert(engine.getSnapshot().document.elements.get('member').incarnation===3&&engine.getSnapshot().history.redo.length===1,'restored document and history were not in one durable cut');assert(calls.filter(call=>call.method==='POST').length===5,'traversal recovery duplicated a write'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-traversal`);} +} + async function mixedHistoryCase(name,assert){ const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()); const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:20,y:30,width:300,height:200,rotation:0},data:{title:id,text:''}}); @@ -329,6 +345,8 @@ export async function run() { results.push('scaled container units preserve nested transforms and typography through atomic protocol writes, IndexedDB reopening, undo and redo'); await mixedHistoryCase(name,assert); results.push('mixed acknowledged entry/pending partition history persists one undo unit, preserves original request facts and restores fresh lifecycles after reopening'); + await traversalRecoveryCase(name,assert); + results.push('rejected partition undo recovers archived scopes and history through IndexedDB; redo/undo advance lifecycles without repeating the old request'); return results; } finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); } } diff --git a/tests/headless/conflicts.test.mjs b/tests/headless/conflicts.test.mjs index 6e5bab0..14ced2d 100644 --- a/tests/headless/conflicts.test.mjs +++ b/tests/headless/conflicts.test.mjs @@ -78,9 +78,9 @@ test('recovery publication failure keeps one plan and blocks sends until the exa f.broken=false;await session.resolve(plan);await engine.settle();assert.equal(f.calls.length,2);assert.ok(engine.getDurability().durableRootRevision>revision);assert.equal(engine.getSnapshot().runtime.recoveries.size,1);await engine.close(); }); -test('incomplete choices and pending history traversal require explicit handling',async()=>{ +test('incomplete choices and mixed user/traversal recovery require explicit handling',async()=>{ const f=fixture(),engine=await f.open();title(engine,'local');await engine.settle();const session=await engine.prepareRecovery();assert.throws(()=>session.plan(new Map()),/recovery_choices_incomplete/);await session.cancel(); - assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const undoSession=await engine.prepareRecovery();assert.throws(()=>undoSession.plan(choices(engine)),/recovery_history_traversal_not_supported/);await undoSession.cancel();await engine.close(); + assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();const undoSession=await engine.prepareRecovery();assert.throws(()=>undoSession.plan(choices(engine)),/recovery_history_mixed_traversal_not_supported/);await undoSession.cancel();await engine.close(); }); test('retirement rejects unknown work and incomplete dependency closures without mutation',()=>{ diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 8780855..2633280 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -174,7 +174,11 @@ try { console.log('PASS actual recovery dialog reviews remote/local content, recompiles with new identities, saves before dispatch and preserves the reviewed undo baseline'); rejectNextEdit=true;await click('添加文本卡片');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected creation did not expose recovery');const createdId=rejectedEdit.operations[0].changes.find(change=>change.kind==='element'&&change.before===null).id; await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('创建元素')`,'creation review omitted the structural change'); - await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('关联结构变更')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'structural plan did not expose related changes');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'structural review did not finish');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.ok(documents.get(root).elements.has(createdId));await click('撤销');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false); + await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('关联结构变更')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'structural plan did not expose related changes');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'structural review did not finish');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.ok(documents.get(root).elements.has(createdId));rejectNextEdit=true;await click('撤销');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected undo did not expose recovery'); + await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('未同步的撤销')`,'undo review did not identify the history action');assert.ok(await evaluate(`document.querySelector('select[aria-label="处理第1项修改"]').innerText.includes('保留这次撤销')`)); + await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('重做记录由')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'undo recovery omitted its redo stack');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'undo recovery did not finish');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false); + await click('重做');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.get(createdId).incarnation,2);await click('撤销');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false); + console.log('PASS actual rejected undo review labels the traversal, previews both stacks, replans deletion and retains redo with a new lifecycle'); console.log('PASS actual structural recovery dialog reviews a rejected creation and related changes, applies it with a new identity and retains undo'); const selectB=async()=>{await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height-30}})()`);await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI tool did not accept the selected element');}; await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="AI 模型"] option[value="fixture-text"]')`,'AI catalog did not load through the host'); diff --git a/tests/headless/structure-recovery.test.mjs b/tests/headless/structure-recovery.test.mjs index feb0cb9..5658953 100644 --- a/tests/headless/structure-recovery.test.mjs +++ b/tests/headless/structure-recovery.test.mjs @@ -233,3 +233,67 @@ test('a later confirmed group operation cannot be moved before an earlier reject assert.equal(f.server().elements.get('c').data.title,'confirmed later');assert.equal(pending(engine).length,1); const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history_nonprefix_ack/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close(); }); + + +test('rejected undo and redo can be explicitly kept or discarded with the correct history stack',async()=>{ + for(const direction of ['undo','redo'])for(const keep of [true,false]){ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle(); + if(direction==='redo'){assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();} + f.reject=true;assert.equal(engine.editor.history[direction]().status,'applied');await engine.settle();const old=pending(engine)[0].intent; + f.remote([{kind:'field',target:target('a'),field:'data.text',before:'',after:'remote text',beforePresent:true,afterPresent:true}]); + const session=await engine.prepareRecovery(),plan=session.plan(new Map([[old.id,keep?'keep-local':'discard-local']]));f.reject=false;await session.resolve(plan);await engine.settle(); + const undone=direction==='undo'?keep:!keep;assert.equal(f.server().elements.get('a').data.title,undone?'a':'edited');assert.equal(engine.getSnapshot().history.redo.length,undone?1:0);assert.equal(engine.getSnapshot().history.undo.length,undone?0:1); + assert.equal(engine.getSnapshot().runtime.ledger.operations.get(old.id).resolution,keep?'superseded':'discarded');assert.equal(f.server().elements.get('a').data.text,'remote text'); + assert.equal(engine.editor.history[undone?'redo':'undo']().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,undone?'edited':'a');await engine.close(); + } +}); + +test('a kept rejected undo uses the reviewed remote value as its redo baseline',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle(); + f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a'); + assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('a').data.title,'remote');await engine.close(); +}); + +test('a rejected undo of a confirmed partition deletion restores only the proved next lifecycle',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('f')});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle(); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('f').incarnation,2);assert.equal(f.server().elements.get('c').incarnation,2);assert.equal(f.server().canvases.get('child').parent.entryElementId,'f'); + assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('child'),false);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').incarnation,3);await engine.close(); +}); + +test('a rejected redo after confirmed undo can restore creation with the archived canvas version',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:node('entry','root',{componentId:'frame'})});tx.dispatch({type:'canvas.create',canvas:{id:'inside',parent:{canvasId:'root',entryElementId:'entry'}}});tx.dispatch({type:'element.create',element:node('member','inside')});});await engine.settle();engine.editor.history.undo();await engine.settle();f.reject=true;engine.editor.history.redo();await engine.settle(); + const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('member').incarnation,2);assert.ok(f.calls.at(-1).expectedVersions.inside>0);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().canvases.has('inside'),false);await engine.close(); +}); + +test('multiple rejected undos retain stack order and cannot skip a discarded top traversal',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'first'}});await engine.settle();engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'second'}});await engine.settle();f.reject=true;engine.editor.history.undo();engine.editor.history.undo();await engine.settle(); + const ids=pending(engine).map(record=>record.intent.id),session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map([[ids[0],'discard-local'],[ids[1],'keep-local']])),/recovery_history_traversal_order/);assert.equal(engine.getSnapshot(),before); + const plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('a').data.title,'a');assert.equal(f.server().elements.get('b').data.title,'b');assert.equal(engine.getSnapshot().history.redo.length,2); + engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('a').data.title,'first');engine.editor.history.redo();await engine.settle();assert.equal(f.server().elements.get('b').data.title,'second');await engine.close(); +}); + +test('a failed restoration cannot claim a remotely advanced tombstone and leaves its original history intact',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.remove',target:target('a')});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle(); + f.remote([{kind:'element',id:'a',before:null,after:{...node('a'),incarnation:2}},{kind:'element',id:'a',before:{...node('a'),incarnation:2},after:null}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_identity_collision/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close(); +}); + + +test('an already satisfied kept undo retires only its empty contribution with an audit',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle();const original=pending(engine)[0].intent; + f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'a',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));assert.equal(plan.history.entries.size,0);assert.equal(plan.operationIds.length,0);assert.equal(plan.audit.decisions[0].resolution,'satisfied');await session.resolve(plan);assert.equal(engine.getSnapshot().runtime.ledger.operations.get(original.id).resolution,'satisfied');await engine.close(); +}); + +test('discarding a rejected traversal never broadens its old inverse over a remote field conflict',async()=>{ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true;engine.editor.history.undo();await engine.settle(); + f.remote([{kind:'field',target:target('a'),field:'data.title',before:'edited',after:'remote',beforePresent:true,afterPresent:true}]);const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(new Map(pending(engine).map(record=>[record.intent.id,'discard-local']))),/recovery_history_traversal_conflict/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close(); +}); + +test('mixed user and traversal recovery and repeated traversals require additional history evidence',async()=>{ + for(const mode of ['mixed','repeat','orphan']){ + const f=fixture(),engine=await f.open();f.reject=false;engine.editor.dispatch({type:'element.data',target:target('a'),patch:{title:'edited'}});await engine.settle();f.reject=true; + if(mode==='mixed')engine.editor.dispatch({type:'element.data',target:target('b'),patch:{title:'pending'}}); + engine.editor.history.undo();if(mode==='repeat')engine.editor.history.redo(); + if(mode==='orphan'){engine.editor.dispatch({type:'element.data',target:target('c'),patch:{title:'later confirmed'}});} + await engine.settle();const session=await engine.prepareRecovery(),before=engine.getSnapshot();assert.throws(()=>session.plan(choices(engine)),/recovery_history/);assert.equal(engine.getSnapshot(),before);await session.cancel();await engine.close(); + } +});