feat(canvas): schedule deep copy planning cooperatively
This commit is contained in:
@@ -34,12 +34,14 @@ export function commonCanvas(document: Document, left: Id, right: Id): Id {
|
||||
if (!result) throw fault('no_common_canvas', [left, right]);
|
||||
return result;
|
||||
}
|
||||
export function validateDocument(document: Document, registry: Registry): void {
|
||||
/** Cooperative validation over an immutable document; synchronous callers drain the same checks. */
|
||||
export function* validateDocumentSteps(document: Document, registry: Registry): Generator<void, void> {
|
||||
if (!document.canvases.size) throw fault('missing_root');
|
||||
for (const [id, incarnation] of document.tombstones) if (!id || !Number.isSafeInteger(incarnation) || incarnation < 1) throw fault('invalid_tombstone', [id]);
|
||||
for (const [id, incarnation] of document.tombstones) { yield; if (!id || !Number.isSafeInteger(incarnation) || incarnation < 1) throw fault('invalid_tombstone', [id]); }
|
||||
const entries = new Set<Id>();
|
||||
let roots = 0;
|
||||
for (const canvas of document.canvases.values()) {
|
||||
yield;
|
||||
if (typeof canvas.id !== 'string' || !canvas.id || !Object.hasOwn(canvas,'parent') || canvas.parent !== null && (!canvas.parent || typeof canvas.parent.canvasId !== 'string' || typeof canvas.parent.entryElementId !== 'string')) throw fault('invalid_canvas');
|
||||
ancestors(document, canvas.id);
|
||||
if (!canvas.parent) { roots++; continue; }
|
||||
@@ -49,6 +51,7 @@ export function validateDocument(document: Document, registry: Registry): void {
|
||||
}
|
||||
if (roots !== 1) throw fault('invalid_roots');
|
||||
for (const element of document.elements.values()) {
|
||||
yield;
|
||||
if (typeof element.id !== 'string' || !element.id || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !document.canvases.has(element.ownerCanvasId) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data)) throw fault('invalid_element', [element.id]);
|
||||
const dead = document.tombstones.get(element.id);
|
||||
if (dead !== undefined && element.incarnation <= dead) throw fault('stale_incarnation', [element.id]);
|
||||
@@ -64,6 +67,9 @@ export function validateDocument(document: Document, registry: Registry): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
export function validateDocument(document: Document, registry: Registry): void {
|
||||
for (const _ of validateDocumentSteps(document, registry)) { /* Drain identical validation without scheduling. */ }
|
||||
}
|
||||
export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document {
|
||||
const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones);
|
||||
for (const change of changes) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts';
|
||||
import { ancestors, applyChanges, commonCanvas, targetElement, validateDocument } from './document.ts';
|
||||
import { ancestors, commonCanvas, targetElement, validateDocumentSteps } from './document.ts';
|
||||
import { connectionPathValid } from './features.ts';
|
||||
import { canvasTransform, convertPath, toLocal, toWorld } from './coordinates.ts';
|
||||
import { clone, fault, freeze } from './values.ts';
|
||||
@@ -34,45 +34,51 @@ export function remapCopyData(element: ElementRecord, registry: Registry, elemen
|
||||
}
|
||||
|
||||
/** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */
|
||||
export function planDuplicate(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): DuplicatePlan {
|
||||
validateDocument(document, registry);
|
||||
export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> {
|
||||
yield* validateDocumentSteps(document, registry);
|
||||
const offset = options.worldOffset ?? { x: 32, y: 32 };
|
||||
if (![offset.x, offset.y].every(Number.isFinite)) throw fault('invalid_copy_offset');
|
||||
const selected = new Set(selection.map(target => targetElement(document, target).id));
|
||||
const selected = new Set<Id>();
|
||||
for (const target of selection) { yield; selected.add(targetElement(document, target).id); }
|
||||
if (!selected.size) throw fault('empty_copy_selection');
|
||||
const childByEntry = new Map<Id, CanvasRecord>(), members = new Map<Id, ElementRecord[]>();
|
||||
for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas);
|
||||
for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
|
||||
const roots = [...selected].filter(id => !ancestors(document, document.elements.get(id)!.ownerCanvasId).some(canvas => { const parent = document.canvases.get(canvas)!.parent; return parent && selected.has(parent.entryElementId); }));
|
||||
for (const canvas of document.canvases.values()) { yield; if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas); }
|
||||
for (const element of document.elements.values()) { yield; const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
|
||||
const roots: Id[] = [];
|
||||
for (const id of selected) { yield; if (!ancestors(document, document.elements.get(id)!.ownerCanvasId).some(canvas => { const parent = document.canvases.get(canvas)!.parent; return parent && selected.has(parent.entryElementId); })) roots.push(id); }
|
||||
const copied = new Set<Id>(), copiedCanvases = new Set<Id>(), order: ({ kind: 'element'; record: ElementRecord } | { kind: 'canvas'; record: CanvasRecord })[] = [];
|
||||
const stack = roots.filter(id => document.elements.get(id)!.structure === 'node').reverse().map(id => ({ kind: 'element' as const, record: document.elements.get(id)! }));
|
||||
const work: typeof order = stack;
|
||||
const work: typeof order = [];
|
||||
for (let index = roots.length - 1; index >= 0; index--) { yield; const record = document.elements.get(roots[index])!; if (record.structure === 'node') work.push({kind:'element',record}); }
|
||||
while (work.length) {
|
||||
yield;
|
||||
const item = work.pop()!;
|
||||
if (item.kind === 'canvas') {
|
||||
if (copiedCanvases.has(item.record.id)) continue;
|
||||
copiedCanvases.add(item.record.id); order.push(item);
|
||||
for (const member of [...(members.get(item.record.id) ?? [])].reverse()) if (member.structure === 'node') work.push({ kind: 'element', record: member });
|
||||
const list = members.get(item.record.id) ?? [];
|
||||
for (let index = list.length - 1; index >= 0; index--) { yield; const member = list[index]; if (member.structure === 'node') work.push({ kind: 'element', record: member }); }
|
||||
} else {
|
||||
if (copied.has(item.record.id)) continue;
|
||||
copied.add(item.record.id); order.push(item);
|
||||
const child = childByEntry.get(item.record.id); if (child) work.push({ kind: 'canvas', record: child });
|
||||
}
|
||||
}
|
||||
const edges = [...document.elements.values()].filter(element => element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId)));
|
||||
for (const edge of edges) copied.add(edge.id);
|
||||
const elementIds = new Map<Id, Id>(), canvasIds = new Map<Id, Id>(), used = new Set([...document.elements.keys(), ...document.canvases.keys(), ...document.tombstones.keys()]);
|
||||
const edges: ElementRecord[] = [];
|
||||
for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } }
|
||||
const elementIds = new Map<Id, Id>(), canvasIds = new Map<Id, Id>(), used = new Set<Id>();
|
||||
for (const map of [document.elements, document.canvases, document.tombstones]) for (const id of map.keys()) { yield; used.add(id); }
|
||||
const allocate = (source: Id, kind: 'elements' | 'canvases') => {
|
||||
const supplied = options.mapping?.[kind];
|
||||
const id = supplied && Object.hasOwn(supplied, source) ? supplied[source] : options.createId();
|
||||
if (typeof id !== 'string' || !id || used.has(id)) throw fault('duplicate_identity', [id]);
|
||||
used.add(id); return id;
|
||||
};
|
||||
for (const id of copied) elementIds.set(id, allocate(id, 'elements'));
|
||||
for (const id of copiedCanvases) canvasIds.set(id, allocate(id, 'canvases'));
|
||||
if (options.mapping && (Object.keys(options.mapping.elements).some(id => !copied.has(id)) || Object.keys(options.mapping.canvases).some(id => !copiedCanvases.has(id)))) throw fault('copy_mapping_scope_mismatch');
|
||||
for (const id of copied) { yield; elementIds.set(id, allocate(id, 'elements')); }
|
||||
for (const id of copiedCanvases) { yield; canvasIds.set(id, allocate(id, 'canvases')); }
|
||||
if (options.mapping) for (const [mapping, scope] of [[options.mapping.elements,copied],[options.mapping.canvases,copiedCanvases]] as const) for (const id in mapping) { yield; if (Object.hasOwn(mapping,id) && !scope.has(id)) throw fault('copy_mapping_scope_mismatch'); }
|
||||
const changes: Change[] = [];
|
||||
for (const item of order) {
|
||||
yield;
|
||||
if (item.kind === 'canvas') {
|
||||
const parent = item.record.parent!;
|
||||
const after = { ...clone(item.record), id: canvasIds.get(item.record.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
|
||||
@@ -88,8 +94,11 @@ export function planDuplicate(document: Document, registry: Registry, selection:
|
||||
changes.push({ kind: 'element', id: after.id, before: null, after });
|
||||
}
|
||||
}
|
||||
const nodes = applyChanges(document, changes);
|
||||
const nodeElements = new Map<Id, ElementRecord>(), nodeCanvases = new Map<Id, CanvasRecord>();
|
||||
for (const change of changes) { yield; if (change.kind === 'element' && change.after) nodeElements.set(change.id, change.after); else if (change.kind === 'canvas' && change.after) nodeCanvases.set(change.id, change.after); }
|
||||
const nodes: Document = { ...document, elements: overlay(document.elements, nodeElements), canvases: overlay(document.canvases, nodeCanvases) };
|
||||
for (const edge of edges) {
|
||||
yield;
|
||||
if (edge.structure !== 'connection') continue;
|
||||
if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]);
|
||||
const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId };
|
||||
@@ -107,7 +116,33 @@ export function planDuplicate(document: Document, registry: Registry, selection:
|
||||
}
|
||||
const after: ElementRecord = { ...clone(edge), id: elementIds.get(edge.id)!, incarnation: 1, ownerCanvasId: owner, source, target, path, data: remapCopyData(edge, registry, elementIds, canvasIds, document) };
|
||||
changes.push({ kind: 'element', id: after.id, before: null, after });
|
||||
nodeElements.set(after.id, after);
|
||||
}
|
||||
validateDocument(applyChanges(document, changes), registry);
|
||||
return freeze({ version: 1, roots: roots.map(id => elementIds.get(id)!), elementIds: Object.fromEntries(elementIds), canvasIds: Object.fromEntries(canvasIds), changes });
|
||||
yield* validateDocumentSteps(nodes, registry);
|
||||
const elementMapping: Record<Id,Id> = {}, canvasMapping: Record<Id,Id> = {}, copiedRoots: Id[] = [];
|
||||
for (const [id, value] of elementIds) { yield; Object.defineProperty(elementMapping,id,{value,enumerable:true}); }
|
||||
for (const [id, value] of canvasIds) { yield; Object.defineProperty(canvasMapping,id,{value,enumerable:true}); }
|
||||
for (const id of roots) { yield; copiedRoots.push(elementIds.get(id)!); }
|
||||
for (const change of changes) { yield; freeze(change); }
|
||||
return Object.freeze({ version: 1, roots: Object.freeze(copiedRoots), elementIds: Object.freeze(elementMapping), canvasIds: Object.freeze(canvasMapping), changes: Object.freeze(changes) });
|
||||
}
|
||||
|
||||
/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
|
||||
function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
|
||||
const view: ReadonlyMap<K,V> = {
|
||||
get size() { return base.size + additions.size; },
|
||||
get: key => additions.has(key) ? additions.get(key) : base.get(key), has: key => additions.has(key) || base.has(key),
|
||||
*entries(): Generator<[K,V],undefined> { yield* base; yield* additions; return undefined; },
|
||||
*keys(): Generator<K,undefined> { yield* base.keys(); yield* additions.keys(); return undefined; },
|
||||
*values(): Generator<V,undefined> { yield* base.values(); yield* additions.values(); return undefined; },
|
||||
[Symbol.iterator]() { return this.entries(); },
|
||||
forEach(callback,thisArg) { for (const [key,value] of view) callback.call(thisArg,value,key,view); },
|
||||
};
|
||||
return Object.freeze(view);
|
||||
}
|
||||
|
||||
/** Synchronous API retained for small snapshots and callers that own scheduling. */
|
||||
export function planDuplicate(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): DuplicatePlan {
|
||||
const steps = planDuplicateSteps(document,registry,selection,options);
|
||||
while (true) { const step = steps.next(); if (step.done) return step.value; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Document, Registry, Target } from '../canvas-core/types.ts';
|
||||
import { planDuplicateSteps, type DuplicateOptions, type DuplicatePlan } from '../canvas-core/duplicate.ts';
|
||||
import { clone, fault, freeze } from '../canvas-core/values.ts';
|
||||
|
||||
export type DuplicateScheduling = {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly frameBudgetMs?: number;
|
||||
readonly maxStepsPerSlice?: number;
|
||||
readonly now?: () => number;
|
||||
readonly yieldTask?: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cooperative planning from a captured immutable StateRoot document, not a live editor getter.
|
||||
* No document publication or network effects. The caller must persist source/mapping and
|
||||
* revalidate external identities before applying a plan; this is not the import executor.
|
||||
*/
|
||||
export async function planDuplicateAsync(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions, scheduling: DuplicateScheduling = {}): Promise<DuplicatePlan> {
|
||||
const budget = scheduling.frameBudgetMs ?? 6, limit = scheduling.maxStepsPerSlice ?? 512;
|
||||
if (!Number.isFinite(budget) || budget <= 0 || !Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_copy_budget');
|
||||
const signal = scheduling.signal, now = scheduling.now ?? (() => performance.now()), yieldTask = scheduling.yieldTask ?? (() => new Promise<void>(resolve => setTimeout(resolve, 0)));
|
||||
const check = () => { if (signal?.aborted) throw fault('copy_aborted'); };
|
||||
check();
|
||||
// Capture caller-owned small inputs before the first await. Document/registry are immutable core snapshots.
|
||||
const targets = freeze(clone(selection)), settings: DuplicateOptions = Object.freeze({ createId: options.createId, ...(options.worldOffset ? {worldOffset: freeze(clone(options.worldOffset))} : {}), ...(options.mapping ? {mapping: freeze(clone(options.mapping))} : {}) });
|
||||
const steps = planDuplicateSteps(document, registry, targets, settings);
|
||||
try {
|
||||
while (true) {
|
||||
check();
|
||||
const start = now(); let count = 0;
|
||||
do {
|
||||
check();
|
||||
const step = steps.next();
|
||||
check();
|
||||
if (step.done) return step.value;
|
||||
count++;
|
||||
} while (count < limit && now() - start < budget);
|
||||
await yieldTask();
|
||||
}
|
||||
} finally {
|
||||
// Release snapshot references after cancellation or host scheduling failures.
|
||||
steps.return(undefined as never);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {planDuplicateAsync} from '../../src/lib/canvas-runtime/duplicate.ts';
|
||||
import {replayHistoryEvidence,sameHistory} from '../../src/lib/canvas-runtime/history-replay.ts';
|
||||
import { createBrowserStorage } from '../../src/lib/canvas-runtime/storage.ts';
|
||||
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
||||
@@ -6,6 +7,18 @@ 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 cooperativeCopyCase(assert) {
|
||||
const registry=createRegistry(builtinComponents()),source=createDocument('copy-project',[{id:'copy-root',parent:null}],Array.from({length:1500},(_,index)=>({id:`source-${index}`,incarnation:1,structure:'node',ownerCanvasId:'copy-root',componentId:'text',dataVersion:1,data:{title:`item ${index}`,text:''},geometry:{x:index,y:0,width:100,height:100,rotation:0}})));
|
||||
let next=0,timerRan=false;
|
||||
const timer=setTimeout(()=>{timerRan=true;},0);
|
||||
try {
|
||||
const plan=await planDuplicateAsync(source,registry,[{id:'source-0',incarnation:1}],{createId:()=>`copy-${++next}`},{maxStepsPerSlice:512});
|
||||
assert(timerRan,'copy planning starved a real browser task');assert(plan.changes.length===1,'copy planning changed selection scope');assert(source.elements.size===1500,'copy planning mutated its source');
|
||||
const controller=new AbortController(),cancel=setTimeout(()=>controller.abort(),0);
|
||||
try { let code;try { await planDuplicateAsync(source,registry,[{id:'source-0',incarnation:1}],{createId:()=>`unused-${++next}`},{signal:controller.signal,maxStepsPerSlice:128}); } catch(error){code=error.code;}assert(code==='copy_aborted','browser cancellation did not stop planning'); } finally {clearTimeout(cancel);}
|
||||
} finally {clearTimeout(timer);}
|
||||
}
|
||||
|
||||
async function adoptionConsumerCase(name,assert){
|
||||
for(const structural of [false,true]){
|
||||
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),database=`${name}-adoption-consumer-${structural}`,storage=createBrowserStorage({name:database}),calls=[],saved=new Map();let document=original,versions=new Map([[root,1]]),reject=true,engine;
|
||||
@@ -481,6 +494,8 @@ export async function run() {
|
||||
results.push('project engine reopens real IndexedDB with one history unit and the original request; ACK resolves without duplicate edit');
|
||||
await resourceRecoveryCase(name, assert);
|
||||
results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity');
|
||||
await cooperativeCopyCase(assert);
|
||||
results.push('cooperative duplicate planning yields to actual browser tasks and cancellation without mutating the source');
|
||||
await nativeImportRecoveryCase(name,assert);
|
||||
results.push('native import source/cursor/IDs survive real IndexedDB pause/reopen/resume, preserve user edits, report partial failures and do not revive undone records');
|
||||
await elisionRecoveryCase(name,assert);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate,planDuplicateSteps,applyChanges,validateDocument,validateDocumentSteps,validJson} from '../../src/lib/canvas-core/index.ts';
|
||||
import {planDuplicateAsync} from '../../src/lib/canvas-runtime/duplicate.ts';
|
||||
const registry=createRegistry(builtinComponents());
|
||||
const node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'copy'},geometry:{x:0,y:0,width:100,height:100,rotation:0}});
|
||||
const edge={id:'edge',incarnation:1,ownerCanvasId:'P',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}};
|
||||
const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame'),node('C'),...Array.from({length:2000},(_,i)=>node(`n${i}`,'S')),edge]);
|
||||
const selection=[{id:'F',incarnation:1}],options=()=>{let id=0;return{createId:()=>`new-${++id}`};};
|
||||
|
||||
test('cooperative deep planning yields throughout scanning, allocation and remapping and matches the synchronous plan',async()=>{
|
||||
let slices=0;const plan=await planDuplicateAsync(document,registry,selection,options(),{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{slices++;}});
|
||||
assert.ok(slices>200);assert.deepEqual(plan,planDuplicate(document,registry,selection,options()));assert.equal(validJson(plan),true);
|
||||
const after=applyChanges(document,plan.changes);validateDocument(after,registry);
|
||||
assert.equal(after.elements.get(plan.elementIds.edge).source.elementId,'C');assert.equal(after.elements.get(plan.elementIds.edge).target.elementId,plan.elementIds.F);
|
||||
assert.equal(Object.isFrozen(plan.changes[0].after.data),true);assert.equal(document.elements.size,2003);
|
||||
});
|
||||
|
||||
test('default task yielding lets actual timers cancel a large copy without publishing content',async()=>{
|
||||
const controller=new AbortController();let ids=0,ran=false;
|
||||
const timer=setTimeout(()=>{ran=true;controller.abort();},0);
|
||||
try{await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`unused-${++ids}`},{signal:controller.signal,maxStepsPerSlice:16}),/copy_aborted/);assert.equal(ran,true);assert.equal(ids,0);assert.equal(document.elements.size,2003);}finally{clearTimeout(timer);}
|
||||
});
|
||||
|
||||
test('cancellation during allocation never returns a partial plan or changes the source',async()=>{
|
||||
const controller=new AbortController();let allocated=0,slices=0;
|
||||
await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`new-${++allocated}`},{signal:controller.signal,now:()=>0,maxStepsPerSlice:32,yieldTask:async()=>{slices++;if(allocated>20)controller.abort();}}),/copy_aborted/);
|
||||
assert.ok(allocated>20&&allocated<2003);assert.ok(slices>1);assert.equal(document.elements.has('new-1'),false);
|
||||
});
|
||||
|
||||
test('planning captures caller inputs and retains the original document when the live root changes',async()=>{
|
||||
const small=createDocument('project',[{id:'P',parent:null}],[node('A')]);let live=small,changed=false;
|
||||
const targets=[{id:'A',incarnation:1}],settings={...options(),worldOffset:{x:40,y:50}};
|
||||
const result=await planDuplicateAsync(live,registry,targets,settings,{now:()=>0,maxStepsPerSlice:1,yieldTask:async()=>{if(changed)return;changed=true;targets[0].id='missing';settings.worldOffset.x=999;live=applyChanges(small,[{kind:'element',id:'A',before:small.elements.get('A'),after:null}]);}});
|
||||
assert.equal(live.elements.size,0);assert.equal(result.changes[0].after.geometry.x,40);assert.equal(result.changes[0].after.geometry.y,50);assert.equal(small.elements.size,1);
|
||||
});
|
||||
|
||||
test('time budget forces a task yield even when the step limit is high and scheduler errors propagate',async()=>{
|
||||
let tick=0,yields=0;
|
||||
await planDuplicateAsync(createDocument('p',[{id:'P',parent:null}],[node('A')]),registry,[{id:'A',incarnation:1}],options(),{now:()=>++tick,frameBudgetMs:1,maxStepsPerSlice:99999,yieldTask:async()=>{yields++;}});assert.ok(yields>10);
|
||||
await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{maxStepsPerSlice:1,yieldTask:async()=>{throw Error('host stopped');}}),/host stopped/);
|
||||
for(const value of [0,-1,NaN,Infinity])await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{frameBudgetMs:value}),/invalid_copy_budget/);
|
||||
const controller=new AbortController();controller.abort();await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{signal:controller.signal}),/copy_aborted/);
|
||||
});
|
||||
|
||||
test('cooperative validation and planning retain invalid-input errors rather than skipping failed records',async()=>{
|
||||
const invalid={...document,canvases:new Map([...document.canvases,['bad',{id:'bad',parent:null}]])};
|
||||
assert.throws(()=>validateDocument(invalid,registry),/invalid_roots/);assert.throws(()=>[...validateDocumentSteps(invalid,registry)],/invalid_roots/);
|
||||
await assert.rejects(planDuplicateAsync(invalid,registry,selection,options(),{maxStepsPerSlice:10,yieldTask:async()=>{}}),/invalid_roots/);
|
||||
assert.throws(()=>[...planDuplicateSteps(document,registry,[{id:'F',incarnation:9}],options())],/stale_incarnation/);
|
||||
});
|
||||
|
||||
test('mapping output preserves special source IDs as own JSON fields without prototype mutation',async()=>{
|
||||
const source=createDocument('special',[{id:'P',parent:null}],[node('__proto__'),node('constructor')]);
|
||||
const plan=await planDuplicateAsync(source,registry,[{id:'__proto__',incarnation:1},{id:'constructor',incarnation:1}],options());
|
||||
assert.equal(Object.getPrototypeOf(plan.elementIds),Object.prototype);assert.equal(Object.hasOwn(plan.elementIds,'__proto__'),true);assert.equal(validJson(plan),true);assert.deepEqual(JSON.parse(JSON.stringify(plan.elementIds)),{['__proto__']:'new-1',constructor:'new-2'});
|
||||
});
|
||||
Reference in New Issue
Block a user