From fa7dad44e7c3d40b3be11f3605c504707cd01093 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 23:57:53 +0800 Subject: [PATCH] Plan creation size enrichment across undo and redo baselines --- src/lib/canvas-core/creation-size-plan.ts | 92 ++++++++++++ tests/headless/creation-size-plan.test.mjs | 154 +++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 src/lib/canvas-core/creation-size-plan.ts create mode 100644 tests/headless/creation-size-plan.test.mjs diff --git a/src/lib/canvas-core/creation-size-plan.ts b/src/lib/canvas-core/creation-size-plan.ts new file mode 100644 index 0000000..ca83361 --- /dev/null +++ b/src/lib/canvas-core/creation-size-plan.ts @@ -0,0 +1,92 @@ +import type { Change, Document, ElementRecord, Geometry, HistoryState, Id, Registry, Target } from './types.ts'; +import { applyChanges, invert, validateDocument } from './document.ts'; +import { closeNumber, unitScale } from './units.ts'; +import { equal, fault, freeze, readMap } from './values.ts'; + +/** A pure proposal, not a publication API. The coordinator must still validate a + * durable sizing grant and record a recoverable history effect before adopting it. + * Already-issued operation bodies are deliberately outside this function. + */ +export function planCreationSize(document: Document, history: HistoryState, registry: Registry, + entryId: Id, target: Target, size: { readonly width: number; readonly height: number }) { + const fail = (code: string): never => { throw fault(code, [target.id]); }; + if (![size.width, size.height].every(value => Number.isFinite(value) && value > 0)) fail('invalid_creation_size'); + const index = history.undo.indexOf(entryId), entry = history.entries.get(entryId); + const current = document.elements.get(target.id); + if (index < 0 || !entry || current?.incarnation !== target.incarnation || current.structure !== 'node') fail('creation_size_target_changed'); + const creations = entry!.changes.filter(change => change.kind === 'element' && change.id === target.id && !change.before && change.after?.incarnation === target.incarnation); + if (creations.length !== 1 || creations[0].kind !== 'element' || creations[0].after?.structure !== 'node') fail('creation_size_entry_mismatch'); + const created = creations[0].after as ElementRecord & { structure: 'node' }; + if (registry.resolve(current!) !== 'known' || !registry.supports(current!, 'geometry.resize')) fail('unsupported_capability'); + const units = unitScale(created.data.presentation); + const baseline = { width: created.geometry.width / units, height: created.geometry.height / units }; + const ratio = { width: size.width / baseline.width, height: size.height / baseline.height }; + const matches = (element: ElementRecord | undefined) => element?.incarnation === target.incarnation && element.structure === 'node'; + const unchanged = (element: ElementRecord | undefined) => matches(element) && element!.structure === 'node' + && closeNumber(element!.geometry.width / unitScale(element!.data.presentation), baseline.width) + && closeNumber(element!.geometry.height / unitScale(element!.data.presentation), baseline.height); + if (!unchanged(current)) fail('creation_size_user_resized'); + const scaled = (geometry: Geometry): Geometry => freeze({ ...geometry, width: geometry.width * ratio.width, height: geometry.height * ratio.height }); + // Rewind on private roots. applyChanges intentionally preserves historical + // incarnations here; this is inspection, not a user-visible delete/restore. + let original = document; + const applied = history.undo.slice(index); + for (const id of [...applied].reverse()) original = applyChanges(original, invert(history.entries.get(id)!.changes)); + let amended = original, inheriting = false; + const entries = new Map(history.entries); + const timeline = [...applied, ...[...history.redo].reverse()]; + for (let cursor = 0; cursor < timeline.length; cursor++) { + const id = timeline[cursor], source = entries.get(id)!; + const end = applyChanges(original, source.changes), isApplied = cursor < applied.length; + if (isApplied && !unchanged(end.elements.get(target.id))) fail('creation_size_user_resized'); + // A future explicit resize remains a resize to the user's chosen dimensions. + // Only its before value inherits the asynchronous default correction. + const futureResize = !isApplied && inheriting && matches(end.elements.get(target.id)) && !unchanged(end.elements.get(target.id)); + let resizeConsumed = false; + const preserveFutureResize = () => { + if (!futureResize || resizeConsumed) return; + const value = end.elements.get(target.id) as ElementRecord & { structure: 'node' }; + if (!closeNumber(value.geometry.width / unitScale(value.data.presentation), baseline.width)) ratio.width = 1; + if (!closeNumber(value.geometry.height / unitScale(value.data.presentation), baseline.height)) ratio.height = 1; + resizeConsumed = true; + }; + const changes: Change[] = []; + for (const change of source.changes) { + let next = change; + if (change.kind === 'element' && change.id === target.id) { + const before = amended.elements.get(target.id) ?? null; + const birth = id === entryId && change === creations[0]; + if (birth) inheriting = true; + else preserveFutureResize(); + const after = change.after && inheriting && change.after.incarnation === target.incarnation && change.after.structure === 'node' + ? freeze({ ...change.after, geometry: scaled(change.after.geometry) }) : change.after; + next = freeze({ ...change, before, after }); + if (!change.after || change.after.incarnation !== target.incarnation) inheriting = false; + } else if (change.kind === 'field' && change.target.id === target.id && change.target.incarnation === target.incarnation && change.field === 'geometry') { + const before = amended.elements.get(target.id); + if (before?.structure !== 'node') fail('creation_size_target_changed'); + preserveFutureResize(); + next = freeze({ ...change, before: (before as ElementRecord & { structure: 'node' }).geometry, after: inheriting ? scaled(change.after as Geometry) : change.after }); + } + amended = applyChanges(amended, [next]); + changes.push(next); + } + original = end; + if (!equal(changes, source.changes)) entries.set(id, freeze({ ...source, changes })); + } + // Publishable proposal changes only the currently live geometry. Replaying + // redo above was validation; it must never become the working document. + const after = freeze({ ...(current as ElementRecord & { structure: 'node' }).geometry, + width: (current as ElementRecord & { structure: 'node' }).geometry.width * size.width / baseline.width, + height: (current as ElementRecord & { structure: 'node' }).geometry.height * size.height / baseline.height }); + const changes: readonly Change[] = equal(after, (current as ElementRecord & { structure: 'node' }).geometry) ? [] : freeze([ + { kind: 'field', target, field: 'geometry', before: (current as ElementRecord & { structure: 'node' }).geometry, after }, + ]); + const nextDocument = applyChanges(document, changes); + validateDocument(nextDocument, registry); + const handler = registry.handlers.get(current!.componentId)?.get('geometry.resize'); + const proposed = handler!({ element: current!, input: freeze({ patch: { width: after.width, height: after.height } }) }); + for (const change of proposed) if (change.target.id !== target.id || change.target.incarnation !== target.incarnation || change.field !== 'geometry') fail('invalid_handler_change'); + if (!equal(applyChanges(document, proposed).elements.get(target.id), nextDocument.elements.get(target.id))) fail('creation_size_constraint'); + return Object.freeze({ document: nextDocument, changes, history: Object.freeze({ undo: history.undo, redo: history.redo, entries: readMap(entries) }) }); +} diff --git a/tests/headless/creation-size-plan.test.mjs b/tests/headless/creation-size-plan.test.mjs new file mode 100644 index 0000000..d6a3163 --- /dev/null +++ b/tests/headless/creation-size-plan.test.mjs @@ -0,0 +1,154 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createDocument, createEditor, createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts'; +import { planCreationSize } from '../../src/lib/canvas-core/creation-size-plan.ts'; +import { applyChanges, invert } from '../../src/lib/canvas-core/document.ts'; +import { describeHistoryTransition, applyHistoryTransition } from '../../src/lib/canvas-core/history-transition.ts'; + +const registry = createRegistry(builtinComponents()); +const target = { id: 'image', incarnation: 1 }; +function setup(options = {}) { + let id = 0; + const editor = createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], []), registry, createId: () => `id-${++id}`, ...options }); + const element = { ...target, ownerCanvasId: 'root', componentId: 'image', dataVersion: 1, structure: 'node', data: { title: 'Image', text: '', assetId: 'asset' }, geometry: { x: 10, y: 20, width: 260, height: 200, rotation: 0 } }; + assert.equal(editor.dispatch({ type: 'element.create', element }).status, 'applied'); + const entry = editor.getSnapshot().history.undo[0]; + return { editor, entry, propose() { const { document, history } = editor.getSnapshot(); return planCreationSize(document, history, registry, entry, target, { width: 260, height: 130 }); } }; +} +function traverse(plan) { + let document = plan.document; + for (const id of [...plan.history.undo].reverse()) document = applyChanges(document, invert(plan.history.entries.get(id).changes)); + assert.equal(document.elements.size, 0); + for (const id of plan.history.undo) document = applyChanges(document, plan.history.entries.get(id).changes); + assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image')); + return document; +} +test('default size enriches the creation unit across move, rotation and title edits without mutating issued intents', () => { + const { editor, entry, propose } = setup(); + editor.dispatch({ type: 'element.geometry', target, patch: { x: 80, rotation: 25 } }); + editor.dispatch({ type: 'element.data', target, patch: { title: 'Renamed' } }); + const before = editor.getSnapshot(), intents = [...before.operations.values()].map(value => JSON.stringify(value)); + const plan = propose(); + assert.deepEqual(plan.history.undo, before.history.undo); + assert.equal(plan.history.entries.get(entry).changes[0].after.geometry.height, 130); + assert.deepEqual(plan.document.elements.get('image').geometry, { x: 80, y: 20, width: 260, height: 130, rotation: 25 }); + assert.equal(plan.document.elements.get('image').data.title, 'Renamed'); + assert.strictEqual(editor.getSnapshot(), before); + assert.deepEqual([...before.operations.values()].map(value => JSON.stringify(value)), intents); + traverse(plan); +}); +test('an unrelated redo stack is retained and rebased without becoming the working document', () => { + const { editor, propose } = setup(); + editor.dispatch({ type: 'element.geometry', target, patch: { x: 90 } }); editor.history.undo(); + const before = editor.getSnapshot(), plan = propose(); + assert.deepEqual(plan.history.redo, before.history.redo); + assert.equal(plan.document.elements.get('image').geometry.x, 10); + let document = traverse(plan); + document = applyChanges(document, plan.history.entries.get(plan.history.redo.at(-1)).changes); + assert.equal(document.elements.get('image').geometry.x, 90); + assert.equal(document.elements.get('image').geometry.height, 130); +}); +test('a future explicit resize keeps the chosen dimensions, with a corrected undo baseline', () => { + const { editor, propose } = setup(); + editor.dispatch({ type: 'element.geometry', target, patch: { width: 400, height: 300 } }); editor.history.undo(); + const plan = propose(), resize = plan.history.entries.get(plan.history.redo.at(-1)); + const resized = applyChanges(plan.document, resize.changes); + assert.equal(resized.elements.get('image').geometry.height, 300); + assert.equal(applyChanges(resized, invert(resize.changes)).elements.get('image').geometry.height, 130); +}); +test('applied resize rejects even after a later resize returns to the initial dimensions', () => { + const { editor, propose } = setup(); + editor.dispatch({ type: 'element.geometry', target, patch: { height: 400 } }); + editor.dispatch({ type: 'element.geometry', target, patch: { height: 200 } }); + const before = editor.getSnapshot(); + assert.throws(propose, { code: 'creation_size_user_resized' }); assert.strictEqual(editor.getSnapshot(), before); +}); +test('an undone creation cannot be enriched', () => { + const { editor, propose } = setup(); editor.history.undo(); + assert.throws(propose, { code: 'creation_size_target_changed' }); +}); +test('a restored element has a new incarnation and rejects the old asynchronous task', () => { + const { editor, propose } = setup(); editor.dispatch({ type: 'element.remove', target }); editor.history.undo(); + // Undo of removal creates a new incarnation: an old asynchronous task is stale. + assert.throws(propose, { code: 'creation_size_target_changed' }); +}); +test('the complete history proposal is reversible using the existing transition proof format', () => { + const { editor, entry, propose } = setup(); editor.dispatch({ type: 'element.geometry', target, patch: { x: 50 } }); + const before = editor.getSnapshot(), plan = propose(); + const transition = describeHistoryTransition(before.history, plan.history, before.rootRevision + 1, entry); + const replay = applyHistoryTransition(before.history, JSON.parse(JSON.stringify(transition)), 'forward'); + assert.deepEqual([...replay.entries], [...plan.history.entries]); + const restored = applyHistoryTransition(replay, transition, 'reverse'); + assert.deepEqual([...restored.entries], [...before.history.entries]); + assert.equal(plan.history.entries.set, undefined); +}); +test('a logical unit conversion preserves size, latest position and the full undo path', () => { + const { editor, propose } = setup({ allowRawChanges: true }); + const before = editor.getSnapshot().document.elements.get('image'); + editor.transaction(tx => { + tx.dispatch({ type: 'changes.apply', changes: [ + { kind: 'field', target, field: 'geometry', before: before.geometry, after: { ...before.geometry, x: 45, width: 520, height: 400 } }, + { kind: 'field', target, field: 'data.presentation', before: null, beforePresent: false, after: { unitScale: 2 } }, + ] }); + }); + const plan = propose(); + assert.equal(plan.document.elements.get('image').geometry.height, 260); + assert.equal(plan.document.elements.get('image').geometry.x, 45); + traverse(plan); +}); +test('a real move into a scaled child canvas is retained through enrichment and undo', () => { + const frame = { id: 'frame', incarnation: 1, ownerCanvasId: 'root', componentId: 'frame', dataVersion: 1, structure: 'node', data: { title: 'Frame', text: '', presentation: { contentScale: 2 } }, geometry: { x: 0, y: 0, width: 600, height: 400, rotation: 0 } }; + const { editor, propose } = setup({ document: createDocument('p', [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'frame' } }], [frame]) }); + assert.equal(editor.dispatch({ type: 'element.move', target, canvasId: 'child' }).status, 'applied'); + const current = editor.getSnapshot().document.elements.get('image'), plan = propose(); + assert.equal(plan.document.elements.get('image').ownerCanvasId, 'child'); + assert.equal(plan.document.elements.get('image').geometry.x, current.geometry.x); + assert.equal(plan.document.elements.get('image').geometry.height, 65); + let document = plan.document; + for (const id of [...plan.history.undo].reverse()) document = applyChanges(document, invert(plan.history.entries.get(id).changes)); + assert.equal(document.elements.has('image'), false); assert.equal(document.elements.has('frame'), true); + for (const id of plan.history.undo) document = applyChanges(document, plan.history.entries.get(id).changes); + assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image')); +}); +test('evicted creation history is rejected without synthesizing a new undo entry', () => { + const { editor, propose } = setup({ historyLimit: 1 }); + editor.dispatch({ type: 'element.data', target, patch: { title: 'New title' } }); + assert.throws(propose, { code: 'creation_size_target_changed' }); +}); +test('a future resize followed by movement retains the user size through both redo entries', () => { + const { editor, propose } = setup(); + editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } }); + editor.dispatch({ type: 'element.geometry', target, patch: { x: 75 } }); + editor.history.undo(); editor.history.undo(); + const plan = propose(); let document = plan.document; + for (const id of [...plan.history.redo].reverse()) document = applyChanges(document, plan.history.entries.get(id).changes); + assert.equal(document.elements.get('image').geometry.height, 300); + assert.equal(document.elements.get('image').geometry.x, 75); + for (const id of plan.history.redo) document = applyChanges(document, invert(plan.history.entries.get(id).changes)); + assert.deepEqual(document.elements.get('image'), plan.document.elements.get('image')); +}); +test('invalid sizes and stale document baselines are rejected without mutation', () => { + const { editor, entry } = setup(); const root = editor.getSnapshot(); + assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: Infinity, height: 1 }), { code: 'invalid_creation_size' }); + editor.dispatch({ type: 'element.data', target, patch: { title: 'Remote change' } }, { origin: 'remote' }); + const stale = editor.getSnapshot(); + assert.throws(() => planCreationSize(stale.document, stale.history, registry, entry, target, { width: 260, height: 130 }), { code: 'element_conflict' }); + assert.strictEqual(editor.getSnapshot(), stale); +}); +test('component constraints cannot be bypassed by a default size proposal', () => { + const { editor, entry } = setup(); const root = editor.getSnapshot(); + assert.throws(() => planCreationSize(root.document, root.history, registry, entry, target, { width: 260, height: 0.5 }), { code: 'creation_size_constraint' }); +}); +test('a redo edit to one size axis preserves the corrected default on the other axis', () => { + const { editor, entry } = setup(); + editor.dispatch({ type: 'element.geometry', target, patch: { height: 300 } }); + editor.dispatch({ type: 'element.geometry', target, patch: { x: 75 } }); + editor.history.undo(); editor.history.undo(); + const root = editor.getSnapshot(), plan = planCreationSize(root.document, root.history, registry, entry, target, { width: 520, height: 130 }); + assert.equal(plan.document.elements.get('image').geometry.width, 520); + assert.equal(plan.document.elements.get('image').geometry.height, 130); + let document = plan.document; + for (const id of [...plan.history.redo].reverse()) document = applyChanges(document, plan.history.entries.get(id).changes); + assert.equal(document.elements.get('image').geometry.width, 520); + assert.equal(document.elements.get('image').geometry.height, 300); +});