From be94446f12d95e90d01a1fbddd8dfe0dd84d4af3 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 14:37:23 +0800 Subject: [PATCH] feat(canvas): define copy schemas and plan hierarchical duplicates --- src/lib/canvas-core/duplicate.ts | 113 ++++++++++++++++++++++++++++++ src/lib/canvas-core/features.ts | 3 +- src/lib/canvas-core/index.ts | 1 + src/lib/canvas-core/registry.ts | 9 ++- src/lib/canvas-core/types.ts | 4 +- tests/headless/duplicate.test.mjs | 66 +++++++++++++++++ 6 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/lib/canvas-core/duplicate.ts create mode 100644 tests/headless/duplicate.test.mjs diff --git a/src/lib/canvas-core/duplicate.ts b/src/lib/canvas-core/duplicate.ts new file mode 100644 index 0000000..ed39d2f --- /dev/null +++ b/src/lib/canvas-core/duplicate.ts @@ -0,0 +1,113 @@ +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 { connectionPathValid } from './features.ts'; +import { canvasTransform, convertPath, toLocal, toWorld } from './coordinates.ts'; +import { clone, fault, freeze } from './values.ts'; + +export type DuplicatePlan = { readonly version: 1; readonly roots: readonly Id[]; readonly elementIds: Readonly>; readonly canvasIds: Readonly>; readonly changes: readonly Change[] }; +export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffset?: Point; readonly mapping?: { readonly elements: Readonly>; readonly canvases: Readonly> } }; + +function copyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap, canvases: ReadonlyMap, document: Document): Data { + const definition = registry.components.get(element.componentId), schema = definition?.copy; + if (registry.resolve(element) !== 'known' || !schema) throw fault('copy_schema_unavailable', [element.id]); + const data = clone(element.data) as Record; + const locate = (path: readonly string[]) => { + let parent: Json = data; + for (const key of path.slice(0, -1)) { if (!parent || typeof parent !== 'object' || !Object.hasOwn(parent, key)) return; parent = (parent as Record)[key]; } + if (parent && typeof parent === 'object' && Object.hasOwn(parent, path.at(-1)!)) return { parent: parent as Record, key: path.at(-1)! }; + }; + for (const ref of schema.references) { + const slot = locate(ref.path); if (!slot || slot.parent[slot.key] === null) continue; + const mapped = (value: Json): Json => { + if (typeof value !== 'string' || !value) throw fault('invalid_copy_reference', [element.id]); + const map = ref.target === 'element' ? elements : canvases, known = ref.target === 'element' ? document.elements : document.canvases; + if (map.has(value)) return map.get(value)!; + if (ref.external !== 'preserve' || !known.has(value)) throw fault('copy_external_reference', [element.id, value]); + return value; + }; + const value = slot.parent[slot.key]; + if (ref.many && !Array.isArray(value)) throw fault('invalid_copy_reference', [element.id]); + slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value); + } + for (const path of schema.omit) { const slot = locate(path); if (slot) { if (Array.isArray(slot.parent)) throw fault('invalid_copy_omit', [element.id]); delete slot.parent[slot.key]; } } + return data; +} + +/** 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); + 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)); + if (!selected.size) throw fault('empty_copy_selection'); + const childByEntry = new Map(), members = new Map(); + 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); })); + const copied = new Set(), copiedCanvases = new Set(), 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; + while (work.length) { + 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 }); + } 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(), canvasIds = new Map(), used = new Set([...document.elements.keys(), ...document.canvases.keys(), ...document.tombstones.keys()]); + 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'); + const changes: Change[] = []; + for (const item of order) { + 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)! } }; + changes.push({ kind: 'canvas', id: after.id, before: null, after }); + } else { + const old = item.record; if (old.structure !== 'node') throw fault('invalid_copy_node'); + let geometry = clone(old.geometry); + if (!copiedCanvases.has(old.ownerCanvasId)) { + const transform = canvasTransform(document, old.ownerCanvasId), zero = toLocal(transform, { x: 0, y: 0 }), shift = toLocal(transform, offset); + geometry = { ...geometry, x: geometry.x + shift.x - zero.x, y: geometry.y + shift.y - zero.y }; + } + const after = { ...clone(old), id: elementIds.get(old.id)!, incarnation: 1, ownerCanvasId: canvasIds.get(old.ownerCanvasId) ?? old.ownerCanvasId, data: copyData(old, registry, elementIds, canvasIds, document), geometry }; + changes.push({ kind: 'element', id: after.id, before: null, after }); + } + } + const nodes = applyChanges(document, changes); + for (const edge of edges) { + 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 }; + const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner); + let path = convertPath(edge.path, canvasTransform(document, edge.ownerCanvasId), transform); + if (path && typeof path === 'object' && !Array.isArray(path) && Array.isArray(path.knots)) { + const displacement = (beforeId: Id, afterId: Id) => { + const a = document.elements.get(beforeId)!, b = nodes.elements.get(afterId)!; + if (a.structure !== 'node' || b.structure !== 'node') throw fault('invalid_copy_endpoint'); + const from = toWorld(canvasTransform(document, a.ownerCanvasId), { x: a.geometry.x + a.geometry.width / 2, y: a.geometry.y + a.geometry.height / 2 }), to = toWorld(canvasTransform(nodes, b.ownerCanvasId), { x: b.geometry.x + b.geometry.width / 2, y: b.geometry.y + b.geometry.height / 2 }); + return { x: to.x - from.x, y: to.y - from.y }; + }; + const from = displacement(edge.source.elementId, source.elementId), to = displacement(edge.target.elementId, target.elementId), knots = path.knots; + path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) }; + } + const after: ElementRecord = { ...clone(edge), id: elementIds.get(edge.id)!, incarnation: 1, ownerCanvasId: owner, source, target, path, data: copyData(edge, registry, elementIds, canvasIds, document) }; + changes.push({ kind: 'element', id: after.id, before: null, 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 }); +} diff --git a/src/lib/canvas-core/features.ts b/src/lib/canvas-core/features.ts index 051cefb..d9a4904 100644 --- a/src/lib/canvas-core/features.ts +++ b/src/lib/canvas-core/features.ts @@ -48,7 +48,8 @@ export function builtinComponents(): readonly ComponentDefinition[] { id, dataVersion: 1, structure: 'node', validate: textData, features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()], resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [], - })).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: data => validJson(data), features: [editable(), connectionEditable()], resourceFields: [] })]); + copy: { references: [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] }, + })).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: data => validJson(data), features: [editable(), connectionEditable()], resourceFields: [], copy: { references: [], omit: [] } })]); } export function connectionPathValid(path: Json): boolean { diff --git a/src/lib/canvas-core/index.ts b/src/lib/canvas-core/index.ts index 09ee370..cd0e89a 100644 --- a/src/lib/canvas-core/index.ts +++ b/src/lib/canvas-core/index.ts @@ -9,3 +9,4 @@ export * from './coordinates.ts'; export * from './structure.ts'; export * from './units.ts'; export * from './history-transition.ts'; +export * from './duplicate.ts'; diff --git a/src/lib/canvas-core/registry.ts b/src/lib/canvas-core/registry.ts index 5ef8ca3..9bb2ab8 100644 --- a/src/lib/canvas-core/registry.ts +++ b/src/lib/canvas-core/registry.ts @@ -7,7 +7,14 @@ export function defineFeature(id: string, handlers: Partial defineFeature(feature.id, feature.handlers)), migrations: definition.migrations ? { ...definition.migrations } : undefined, resourceFields: definition.resourceFields ? [...definition.resourceFields] : undefined }); + const pathValid = (path: readonly string[]) => Array.isArray(path) && path.length > 0 && path.every(key => typeof key === 'string' && !!key && !['__proto__','prototype','constructor'].includes(key)); + if (definition.copy) { + const references = definition.copy.references, omitted = definition.copy.omit; + if (!Array.isArray(references) || !Array.isArray(omitted) || omitted.some(path => !pathValid(path)) || references.some(ref => !pathValid(ref.path) || !['element','canvas'].includes(ref.target) || !['preserve','reject'].includes(ref.external) || ref.many !== undefined && typeof ref.many !== 'boolean')) throw fault('invalid_copy_schema'); + const paths: readonly (readonly string[])[] = [...references.map(ref => ref.path), ...omitted]; + for (let i = 0; i < paths.length; i++) for (let j = 0; j < i; j++) if (paths[i].slice(0, Math.min(paths[i].length, paths[j].length)).every((key, index) => key === paths[j][index])) throw fault('conflicting_copy_paths'); + } + return freeze({ ...definition, copy: definition.copy ? clone(definition.copy) : undefined, features: definition.features.map(feature => defineFeature(feature.id, feature.handlers)), migrations: definition.migrations ? { ...definition.migrations } : undefined, resourceFields: definition.resourceFields ? [...definition.resourceFields] : undefined }); } export function createRegistry(definitions: readonly ComponentDefinition[]): Registry { const components = new Map(), capabilities = new Map>(), handlers = new Map>(); diff --git a/src/lib/canvas-core/types.ts b/src/lib/canvas-core/types.ts index b6ab597..984551e 100644 --- a/src/lib/canvas-core/types.ts +++ b/src/lib/canvas-core/types.ts @@ -50,5 +50,7 @@ export type ReadonlyValueSet = Pick, 'size' | 'has' | 'entries export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit'; export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly> }) => readonly FieldChange[]; export type Feature = { readonly id: string; readonly handlers: Readonly>> }; -export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly Data>>; readonly resourceFields?: readonly string[] }; +export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' }; +export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] }; +export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly Data>>; readonly resourceFields?: readonly string[]; readonly copy?: CopySchema }; export type Registry = { readonly components: ReadonlyMap; readonly capabilities: ReadonlyMap>; readonly handlers: ReadonlyMap>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean }; diff --git a/tests/headless/duplicate.test.mjs b/tests/headless/duplicate.test.mjs new file mode 100644 index 0000000..ff6fca6 --- /dev/null +++ b/tests/headless/duplicate.test.mjs @@ -0,0 +1,66 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createDocument,createRegistry,builtinComponents,defineComponent,planDuplicate,applyChanges,validateDocument,canvasTransform,toWorld} from '../../src/lib/canvas-core/index.ts'; +const node=(id,owner='P',componentId='text')=>({id,incarnation:3,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal A',presentation:{contentScale:1},future:{keep:true}},geometry:{x:10,y:20,width:200,height:100,rotation:0}}); +const path={type:'bezier-chain',startControl:{x:5,y:6},endControl:{x:-7,y:8},knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]}; +const edge=(id,source,target,owner='P',curve=null)=>({id,incarnation:2,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},path:curve,data:{title:id}}); +const registry=createRegistry(builtinComponents()),F={...node('F','P','frame'),geometry:{...node().geometry,rotation:30},data:{...node().data,title:'F',presentation:{contentScale:2}}},G={...node('G','S','frame'),data:{...node().data,title:'G',presentation:{contentScale:.75}}},A={...node('A','S','image'),data:{title:'A',text:'A',assetId:'shared',src:'asset:shared',runId:'paid-run',taskId:'task',progress:.5,runStatus:'running',custom:'A'}}; +const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),F,A,G,node('B','T'),node('D','T'),edge('incoming','C','F','P',path),edge('deep','C','B'),edge('internal','B','D','T',path),edge('across','A','B','S'),edge('outgoing','F','C')]); +const target=id=>({id,incarnation:document.elements.get(id).incarnation}); +const fixture=(selection=['F','A'],extra={})=>{let next=0;return planDuplicate(document,registry,selection.map(target),{createId:()=>`copy-${++next}`,worldOffset:{x:32,y:48},...extra});}; +const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`); + +test('deep copy deduplicates ancestor selection, maps the entire tree and preserves upper incoming sources',()=>{ + const before=JSON.stringify([...document.elements]),plan=fixture(),after=applyChanges(document,plan.changes); + assert.deepEqual(plan.roots,[plan.elementIds.F]);assert.equal(Object.keys(plan.canvasIds).length,2);assert.equal(Object.keys(plan.elementIds).length,9); + for(const id of ['F','A','G','B','D']){const copy=after.elements.get(plan.elementIds[id]);assert.notEqual(copy.id,id);assert.equal(copy.incarnation,1);assert.ok(document.elements.has(id));} + assert.equal(after.canvases.get(plan.canvasIds.S).parent.entryElementId,plan.elementIds.F);assert.equal(after.canvases.get(plan.canvasIds.T).parent.canvasId,plan.canvasIds.S); + const incoming=after.elements.get(plan.elementIds.incoming);assert.equal(incoming.source.elementId,'C');assert.equal(incoming.target.elementId,plan.elementIds.F);assert.equal(incoming.ownerCanvasId,'P');assert.equal(incoming.source.portId,'right'); + assert.equal(after.elements.get(plan.elementIds.internal).ownerCanvasId,plan.canvasIds.T);assert.equal(after.elements.get(plan.elementIds.across).ownerCanvasId,plan.canvasIds.S);assert.equal(after.elements.get(plan.elementIds.deep).target.elementId,plan.elementIds.B);assert.equal(plan.elementIds.outgoing,undefined); + assert.equal(JSON.stringify([...document.elements]),before);validateDocument(after,registry); + // Each record boundary is legal for later incremental commits. + let partial=document;for(const change of plan.changes){partial=applyChanges(partial,[change]);validateDocument(partial,registry);} +}); + +test('geometry uses one world offset across rotated/scaled ownership and does not offset descendants twice',()=>{ + const plan=fixture(),after=applyChanges(document,plan.changes); + for(const id of ['F','A','G','B','D']){ + const a=document.elements.get(id),b=after.elements.get(plan.elementIds[id]),from=toWorld(canvasTransform(document,a.ownerCanvasId),a.geometry),to=toWorld(canvasTransform(after,b.ownerCanvasId),b.geometry); + near(to.x-from.x,32);near(to.y-from.y,48);assert.equal(b.geometry.width,a.geometry.width);assert.equal(b.geometry.rotation,a.geometry.rotation); + } + const original=toWorld(canvasTransform(document,'T'),path.knots[0].position),copied=toWorld(canvasTransform(after,plan.canvasIds.T),after.elements.get(plan.elementIds.internal).path.knots[0].position);near(copied.x-original.x,32);near(copied.y-original.y,48); + const incoming=after.elements.get(plan.elementIds.incoming).path;near(incoming.knots[0].position.x,path.knots[0].position.x+16);near(incoming.knots[0].position.y,path.knots[0].position.y+24);assert.deepEqual(incoming.startControl,path.startControl); +}); + +test('image copies share resources and omit execution metadata while preserving ordinary literal values',()=>{ + const plan=fixture(),data=applyChanges(document,plan.changes).elements.get(plan.elementIds.A).data; + assert.equal(data.assetId,'shared');assert.equal(data.src,'asset:shared');assert.equal(data.custom,'A');for(const key of ['runId','taskId','progress','runStatus'])assert.equal(Object.hasOwn(data,key),false); +}); + +test('a supplied stable mapping reproduces the same plan without allocating another identity',()=>{ + const first=fixture();const second=fixture(['F','A'],{mapping:{elements:first.elementIds,canvases:first.canvasIds},createId:()=>{throw Error('new identity allocated');}});assert.deepEqual(second,first); + assert.throws(()=>fixture(['F'],{createId:()=> 'C'}),/duplicate_identity/); + assert.throws(()=>fixture(['F'],{mapping:{elements:{...first.elementIds,other:'unused'},canvases:first.canvasIds}}),/copy_mapping_scope_mismatch/); + assert.throws(()=>planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=> 'new'}),/stale_incarnation/); +}); + +test('selecting the upper source too maps one new source and does not duplicate the incoming edge',()=>{ + const plan=fixture(['F','A','C','incoming']),after=applyChanges(document,plan.changes),incoming=after.elements.get(plan.elementIds.incoming); + assert.equal(incoming.source.elementId,plan.elementIds.C);assert.equal(incoming.target.elementId,plan.elementIds.F);assert.equal(plan.changes.filter(change=>change.kind==='element'&&change.after?.id===incoming.id).length,1); +}); + +test('declared nested/list references are remapped, resources remain shared and only declared transient fields disappear',()=>{ + const definition=defineComponent({...builtinComponents()[0],id:'linked',copy:{references:[{path:['links'],target:'element',many:true,external:'reject'},{path:['view'],target:'canvas',external:'reject'}],omit:[['execution']]}}),r=createRegistry([...builtinComponents(),definition]),linked={...node('L'),componentId:'linked',data:{title:'L',text:'A',links:['A','B'],view:'S',execution:{taskId:'task'},literal:'A'}},source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),linked]);let next=0; + const plan=planDuplicate(source,r,[target('F'),{id:'L',incarnation:3}],{createId:()=>`ref-${++next}`}),copy=applyChanges(source,plan.changes).elements.get(plan.elementIds.L); + assert.deepEqual(copy.data.links,[plan.elementIds.A,plan.elementIds.B]);assert.equal(copy.data.view,plan.canvasIds.S);assert.equal(copy.data.literal,'A');assert.equal(copy.data.execution,undefined); + assert.throws(()=>planDuplicate(source,r,[{id:'L',incarnation:3}],{createId:()=>`external-${++next}`}),/copy_external_reference/); +}); + +test('missing copy schema, unknown versions, ambiguous paths and opaque curves are explicit failures',()=>{ + const known=defineComponent({...builtinComponents()[0],id:'opaque',copy:undefined}),r=createRegistry([...builtinComponents(),known]),source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),{...node('U'),componentId:'opaque'}]); + assert.throws(()=>planDuplicate(source,r,[{id:'U',incarnation:3}],{createId:()=> 'new'}),/copy_schema_unavailable/); + assert.throws(()=>defineComponent({...known,copy:{references:[{path:['nested'],target:'element',external:'preserve'}],omit:[['nested','id']]}}),/conflicting_copy_paths/); + assert.throws(()=>defineComponent({...known,copy:{references:[],omit:[['__proto__']]}}),/invalid_copy_schema/); + const future=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='A'?{...element,dataVersion:9}:element));assert.throws(()=>planDuplicate(future,registry,[target('F')],{createId:(()=>{let n=0;return()=>`future-${++n}`;})()}),/copy_schema_unavailable/); + const invalid=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='incoming'?{...element,path:{type:'future'}}:element));assert.throws(()=>planDuplicate(invalid,registry,[target('F')],{createId:(()=>{let n=0;return()=>`unknown-${++n}`;})()}),/unsupported_copy_path/); +});