Recover rejected history traversals with atomic stack replanning
This commit is contained in:
@@ -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&&<p>已找回 {session.acknowledgedRequestIds.length} 个成功回执,仅处理后续尚未同步的修改。</p>}
|
||||
{session&&records.length===0&&<p>没有待处理的本地修改,可以采用服务端最新内容。</p>}
|
||||
{session&&records.map((intent,index)=><article key={intent.id} style={{borderBottom:'1px solid #ddd5ca',paddingBottom:12}}>
|
||||
<strong>修改 {index+1}{intent.operationType==='run.start'?' · 尚未受理的生成':intent.operationType==='run.cancel'?' · 尚未确认的取消':''}</strong>
|
||||
<strong>修改 {index+1}{intent.origin==='undo'?' · 未同步的撤销':intent.origin==='redo'?' · 未同步的重做':''}{intent.operationType==='run.start'?' · 尚未受理的生成':intent.operationType==='run.cancel'?' · 尚未确认的取消':''}</strong>
|
||||
{intent.changes.map((change,changeIndex)=>{if(change.kind!=='field')return <StructureReview key={changeIndex} change={change} document={canonical!}/>;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<string,Json>|undefined)?.[change.field]:element?.data[key],proposed=plan?.document.elements.get(change.target.id),proposedValue=key===undefined?(proposed as unknown as Record<string,Json>|undefined)?.[change.field]:proposed?.data[key];return <div key={changeIndex} style={{fontSize:13,whiteSpace:'pre-wrap',overflowWrap:'anywhere',marginTop:8}}><span>{fieldLabel(change.field)}</span><div>服务端:{element?<PreviewValue value={value}/>:'元素已不存在'}</div><div>本地:{change.afterPresent===false?'移除此字段':<PreviewValue value={change.after}/>}</div>{plan&&<div>处理后:{proposed?<PreviewValue value={proposedValue}/>:'元素已不存在'}</div>}</div>;})}
|
||||
<select aria-label={`处理第${index+1}项修改`} value={choices.get(intent.id)??''} disabled={busy||frozen} onChange={event=>{const next=new Map(choices);if(event.target.value)next.set(intent.id,event.target.value as RecoveryChoice);else next.delete(intent.id);setChoices(next);setPlan(undefined);setError('');}}>
|
||||
<option value="">选择处理方式</option>{['document.change','run.apply'].includes(intent.operationType)&&<option value="keep-local">保留这项本地修改</option>}<option value="discard-local">放弃这项本地修改</option>
|
||||
<option value="">选择处理方式</option>{['document.change','run.apply'].includes(intent.operationType)&&<option value="keep-local">{intent.origin==='undo'?'保留这次撤销':intent.origin==='redo'?'保留这次重做':'保留这项本地修改'}</option>}<option value="discard-local">{intent.origin==='undo'?'放弃这次撤销':intent.origin==='redo'?'放弃这次重做':'放弃这项本地修改'}</option>
|
||||
</select>
|
||||
</article>)}
|
||||
{plan&&<p role="status">方案已生成:保留 {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} 项。</p>}
|
||||
{plan&&<p role="status">方案已生成:保留 {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} 项。</p>}
|
||||
{plan&&<PlanStructureReview plan={plan}/>}
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
<DialogFooter><button disabled={busy&&!!session} onClick={()=>void cancel()}>{session?'取消并恢复编辑':'恢复编辑'}</button>
|
||||
|
||||
@@ -59,6 +59,29 @@ function movedNode(current:Extract<ElementRecord,{structure:'node'}>,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<changes.length;){
|
||||
if(!creating(changes[offset])){result.push(changes[offset++]);continue;}
|
||||
let end=offset;while(end<changes.length&&creating(changes[end]))end++;
|
||||
const batch=changes.slice(offset,end),keys=batch.map(change=>`${change.kind}:${(change as Extract<Change,{kind:'element'|'canvas'}>).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<number,number[]>(),ready:number[]=[];
|
||||
batch.forEach((change,index)=>{
|
||||
const dependencies=new Set<string>();
|
||||
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<ready.length;next++){const index=ready[next];result.push(batch[index]);emitted++;for(const dependent of waiting.get(index)??[])if(--counts[dependent]===0)ready.push(dependent);}
|
||||
if(emitted!==batch.length)throw fault('recovery_creation_dependency_conflict');offset=end;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Re-plan against current data. Whole records are used only for explicit creation/removal. */
|
||||
export function rebaseIntent(document:Document,intent:OperationIntent,context:IntentContext,registry:Registry):{document:Document;changes:readonly Change[]} {
|
||||
const before=document,changes:Change[]=[],moved=new Set<Id>(),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)};}
|
||||
|
||||
@@ -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<Id,Presence>={};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';
|
||||
|
||||
@@ -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<Id,OperationIntent|undefined>,kept:ReadonlySet<Id>,boundaries:ReadonlyMap<Id,{before:Document;after:Document}>,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<Id,Id>();for(const entry of snapshot.history.entries.values())for(const id of entry.operationIds)owner.set(id,entry.id);
|
||||
const seen=new Set<Id>(),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)});
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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',()=>{
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user