From bcc11e2e84d85118d325e82f194bd1fd87dce250 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 22:09:30 +0800 Subject: [PATCH] Fence session lifetimes and cancel owned previews on tool changes --- headless/README.md | 20 ++++++++ .../canvas/headless-graph-canvas.tsx | 5 +- src/lib/canvas-core/gestures.ts | 48 ++++++++++++------- tests/headless/renderer.integration.mjs | 14 ++++++ tests/headless/session-lifecycle.test.mjs | 16 +++++++ 5 files changed, 83 insertions(+), 20 deletions(-) create mode 100644 tests/headless/session-lifecycle.test.mjs diff --git a/headless/README.md b/headless/README.md index 03a36b2..a26eb85 100644 --- a/headless/README.md +++ b/headless/README.md @@ -582,3 +582,23 @@ this is not a claim that the entire view is O(changed IDs). Numeric geometry cac remain derived Maps, with no history or durable task ownership. Engine/view replacement discards this cache; `clear()` also drops the retained scene and numeric caches. Query provenance is validated even on a cache hit. + +Session transitions publish the final view and any owned preview cancellation in +one snapshot. Changing tools cancels only that view's active node/path gesture; +`begin`/`beginConnection` require the selection tool (`tool_not_select` otherwise). +Repeated selection of the current tool is a no-op. The React Flow adapter also +disables native connection handles in pan mode and rejects a late connection +callback against the current tool. Multitouch interruption is view scoped. + +Modes remain `Map` in each view. On document publication, the +session compares the previous and current target incarnation, owner, structure, +component and data version: replacing or moving the target clears its mode, +while content edits and equivalent canonical clones preserve it. Missing targets +cannot acquire a mode. Identical mode updates and redundant clears do not notify. +This uses a borrowed current document reference, not a second mode identity store. + +Each attach disposer runs once, so a late second cleanup cannot detach a newly +attached view with the same ID. Explicit canvas navigation and detach publish the +new view set together with cancellation. Session disposal fences admission before +notifying, clears all views/previews, and is idempotent. These transient actions +create no document/history/ledger entries and change no recovery format. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 24b7e29..e7e4a13 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -158,6 +158,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi return id; } function connect(connection: Connection) { + if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return; const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target); if (!source || !destination) return; if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;} @@ -240,7 +241,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setEditorFocused(true); } }} onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setEditorFocused(false); }); }} onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:pointerStart.current?.pointerId}; }} - onTouchStartCapture={event => { if (event.touches.length > 1) { engine.sessions.cancel('multi_touch'); drag.current = null; return; } const touch = event.touches[0]; if (touch && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: touch.clientX, y: touch.clientY }),pointerId:pointerStart.current?.pointerId}; }} + onTouchStartCapture={event => { if (event.touches.length > 1) { const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId||state.connectionGesture?.viewId===viewId)engine.sessions.cancel('multi_touch');drag.current = null;pointerStart.current=null;return; } const touch = event.touches[0]; if (touch && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: touch.clientX, y: touch.clientY }),pointerId:pointerStart.current?.pointerId}; }} onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:event.pointerId}; }} onPointerCancelCapture={event => { const currentDrag=drag.current;if(!currentDrag||currentDrag.pointerId!==undefined&¤tDrag.pointerId!==event.pointerId)return;const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId&&state.gesture.token===currentDrag.token)engine.sessions.cancel('pointer_cancel');drag.current=null; }} onKeyDown={event => { @@ -256,7 +257,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi elevateEdgesOnSelect onlyRenderVisibleElements={cullVisible} nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose} - nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport} + nodesConnectable={view.tool === 'select'} nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport} onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)} onNodeDragStart={(event, node, nodes) => { diff --git a/src/lib/canvas-core/gestures.ts b/src/lib/canvas-core/gestures.ts index aa7bdeb..225acd3 100644 --- a/src/lib/canvas-core/gestures.ts +++ b/src/lib/canvas-core/gestures.ts @@ -12,32 +12,45 @@ export type SessionSnapshot = { readonly views: ReadonlyMap; read export function createSessions(editor: Editor) { let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map()), gesture: null, connectionGesture: null, lastCancelReason: null }); let baseline = new Map(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0; + // Compare adjacent published identities without duplicating the per-view mode table. + let publishedDocument=editor.getSnapshot().document; const listeners = new Set<() => void>(); const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } }; - function setView(view: ViewState) { const views = new Map(snapshot.views); views.set(view.id, view); snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); } + function publishViews(views:Map,cancelOwnedView?:Id,reason='view_interrupted'){ + const owned=cancelOwnedView!==undefined&&(snapshot.gesture?.viewId===cancelOwnedView||snapshot.connectionGesture?.viewId===cancelOwnedView); + if(owned){baseline.clear();connectionBaseline=undefined;} + snapshot=Object.freeze({...snapshot,views:readMap(views),...(owned?{gesture:null,connectionGesture:null,lastCancelReason:reason}:{})});notify(); + } + function setView(view: ViewState,cancelOwnedReason?:string) { const views = new Map(snapshot.views); views.set(view.id, view); publishViews(views,cancelOwnedReason?view.id:undefined,cancelOwnedReason); } function cancel(reason = 'canceled') { if (!snapshot.gesture && !snapshot.connectionGesture) return; baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify(); } const detachEditor = editor.subscribe(() => { - const document = editor.getSnapshot().document; + const document = editor.getSnapshot().document,beforeDocument=publishedDocument;publishedDocument=document; const views = new Map(snapshot.views); let changed = false; for (const [id, view] of views) { const canvasMissing = !document.canvases.has(view.canvasId); - const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => document.elements.has(target))); + const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => { + const before=beforeDocument.elements.get(target),after=document.elements.get(target); + return !!before&&!!after&&before.incarnation===after.incarnation&&before.ownerCanvasId===after.ownerCanvasId&&before.structure===after.structure&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion; + })); if (canvasMissing || selection.length !== view.selection.size || modes.size !== view.modes.size) { views.set(id, freeze({ ...view, canvasId: canvasMissing ? [...document.canvases.values()].find(canvas => !canvas.parent)!.id : view.canvasId, selection: readSet(canvasMissing ? [] : selection), modes: readMap(canvasMissing ? new Map() : modes) })); changed = true; } } - if (changed) { snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); } + let cancelReason:string|undefined; if (snapshot.connectionGesture?.phase === 'active') { const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline; - if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || !equal(current.path, before.path)) cancel('target_changed'); - else if (!editor.can({ type: 'connection.path', target: snapshot.connectionGesture.target, path: current.path }).enabled) cancel('permission_changed'); + if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || !equal(current.path, before.path)) cancelReason='target_changed'; + else if (!editor.can({ type: 'connection.path', target: snapshot.connectionGesture.target, path: current.path }).enabled) cancelReason='permission_changed'; } - if (snapshot.gesture?.phase !== 'active') return; - for (const target of snapshot.gesture.targets) { + if (snapshot.gesture?.phase === 'active'&&!cancelReason) for (const target of snapshot.gesture.targets) { const before = baseline.get(target.id), current = document.elements.get(target.id); - if (!current || current.structure !== 'node' || before?.structure !== 'node' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || !equal(current.geometry, before.geometry)) { cancel('target_changed'); return; } - if (!editor.can({ type: 'element.geometry', target, patch: {} }).enabled) { cancel('permission_changed'); return; } + if (!current || current.structure !== 'node' || before?.structure !== 'node' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || !equal(current.geometry, before.geometry)) { cancelReason='target_changed'; break; } + if (!editor.can({ type: 'element.geometry', target, patch: {} }).enabled) { cancelReason='permission_changed'; break; } + } + if(changed||cancelReason){ + if(cancelReason){baseline.clear();connectionBaseline=undefined;} + snapshot=Object.freeze({...snapshot,...(changed?{views:readMap(views)}:{}),...(cancelReason?{gesture:null,connectionGesture:null,lastCancelReason:cancelReason}:{})});notify(); } }); function view(id: Id) { const found = snapshot.views.get(id); if (!found || disposed) throw fault('view_detached', [id]); return found; } @@ -75,13 +88,12 @@ export function createSessions(editor: Editor) { if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]); if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]); setView(freeze({ id, canvasId, selection: readSet([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map()) })); - return () => { if ((snapshot.gesture?.viewId === id || snapshot.connectionGesture?.viewId === id)) cancel('view_detached'); const views = new Map(snapshot.views); views.delete(id); snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); }; + let attached=true;return () => { if(!attached)return;attached=false;if(!snapshot.views.has(id))return;const views=new Map(snapshot.views);views.delete(id);publishViews(views,id,'view_detached'); }; }, canvas(id: Id, canvasId: Id) { const current = view(id); if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]); - if ((snapshot.gesture?.viewId === id || snapshot.connectionGesture?.viewId === id)) cancel('canvas_changed'); - setView(freeze({ ...current, canvasId, selection: readSet([]), modes: readMap(new Map()) })); + setView(freeze({ ...current, canvasId, selection: readSet([]), modes: readMap(new Map()) }),'canvas_changed'); }, select(id: Id, ids: readonly Id[]) { const current = view(id), document = editor.getSnapshot().document; @@ -92,11 +104,11 @@ export function createSessions(editor: Editor) { if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport'); setView(freeze({ ...view(id), viewport: { ...viewport } })); }, - tool(id: Id, tool: ViewState['tool']) { setView(freeze({ ...view(id), tool })); }, - mode(id: Id, target: Id, mode: string | null) { const current = view(id), modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); }, + tool(id: Id, tool: ViewState['tool']) { const current=view(id);if(tool!=='select'&&tool!=='pan')throw fault('invalid_tool');if(current.tool===tool)return;setView(freeze({ ...current, tool }),'tool_changed'); }, + mode(id: Id, target: Id, mode: string | null) { const current = view(id);if(mode!==null&&typeof mode!=='string')throw fault('invalid_mode');if(mode&&!editor.getSnapshot().document.elements.has(target))throw fault('element_missing',[target]);if((current.modes.get(target)??null)===(mode||null))return;const modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); }, begin(viewId: Id, targets: readonly Target[]): Availability { try { - view(viewId); + if(view(viewId).tool!=='select')throw fault('tool_not_select'); if (snapshot.gesture || snapshot.connectionGesture || !targets.length || new Set(targets.map(target => target.id)).size !== targets.length) throw fault('gesture_busy'); baseline = new Map(targets.map(target => { const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'node' || editor.registry.resolve(element) !== 'known') throw fault('unsupported_component', [target.id]); return [target.id, element]; })); for (const target of targets) { const allowed = editor.can({ type: 'element.geometry', target, patch: {} }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); } @@ -106,7 +118,7 @@ export function createSessions(editor: Editor) { }, beginConnection(viewId: Id, target: Target): Availability { try { - view(viewId); + if(view(viewId).tool!=='select')throw fault('tool_not_select'); if (snapshot.gesture || snapshot.connectionGesture) throw fault('gesture_busy'); const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'connection') throw fault('unsupported_capability', [target.id]); @@ -161,6 +173,6 @@ export function createSessions(editor: Editor) { return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined); }, subscribe(listener: () => void) { if (disposed) throw fault('sessions_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; }, - dispose() { cancel('disposed'); disposed = true; detachEditor(); snapshot = Object.freeze({ ...snapshot, views: readMap(new Map()) }); notify(); listeners.clear(); }, + dispose() { if(disposed)return;disposed = true;detachEditor();baseline.clear();connectionBaseline=undefined;snapshot = Object.freeze({ ...snapshot, views: readMap(new Map()),gesture:null,connectionGesture:null,lastCancelReason:'disposed' }); notify();listeners.clear(); }, }); } diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 8e58d60..2cd0bf7 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -192,6 +192,20 @@ try { await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: moved.x + 35, y: moved.y + 25, button: 'left', buttons: 0 }); await pause(100); assert.equal(documents.get(root).elements.get('b').geometry.x, 500); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel); + const toolPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()`); + await call('Input.dispatchMouseEvent',{type:'mousePressed',...toolPoint,button:'left',buttons:1,clickCount:1}); + await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:toolPoint.x+35,y:toolPoint.y+25,button:'left',buttons:1}); + await until(`!!window.__fixtureActualEngine.sessions.getSnapshot().gesture`,'tool-switch drag did not acquire preview'); + await evaluate(`document.querySelector('[data-headless-canvas]').focus()`); + await call('Input.dispatchKeyEvent',{type:'keyDown',key:'h',code:'KeyH',windowsVirtualKeyCode:72});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'h',code:'KeyH',windowsVirtualKeyCode:72}); + await until(`[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()][0]?.tool==='pan'&&!window.__fixtureActualEngine.sessions.getSnapshot().gesture`,'H did not cancel the owned drag when switching tools'); + await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:toolPoint.x+35,y:toolPoint.y+25,button:'left',buttons:0});await pause(100); + assert.equal(documents.get(root).elements.get('b').geometry.x,500);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeCancel,'late pointerup committed a drag canceled by switching tools'); + await call('Input.dispatchKeyEvent',{type:'keyDown',key:'v',code:'KeyV',windowsVirtualKeyCode:86});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'v',code:'KeyV',windowsVirtualKeyCode:86}); + await until(`[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()][0]?.tool==='select'`,'V did not restore the selection tool'); + await until(`document.querySelector('[aria-label="选择工具(V)"]')?.getAttribute('aria-pressed')==='true'`,'selection tool did not render'); + await until(`(()=>{const n=document.querySelector('.react-flow__node[data-id=b]'),m=new DOMMatrix(n.style.transform);return Math.abs(m.m41-500)<.01&&!n.classList.contains('dragging');})()`,'canceled native node did not return to its committed position'); + console.log('PASS actual drag-to-pan tool switch cancels preview and late pointerup produces no document request'); const corner = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label="拖动右下角调整大小"]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`); await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: corner.x, y: corner.y, button: 'left', buttons: 1, clickCount: 1 }); await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: corner.x + 20, y: corner.y + 10, button: 'left', buttons: 1 }); diff --git a/tests/headless/session-lifecycle.test.mjs b/tests/headless/session-lifecycle.test.mjs new file mode 100644 index 0000000..95730a0 --- /dev/null +++ b/tests/headless/session-lifecycle.test.mjs @@ -0,0 +1,16 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts'; +const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}); +function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],[node('F','root','frame'),node('A'),node('B'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor),detach=sessions.attach('v');sessions.attach('w');return{editor,sessions,detach};} +test('an atomic restored incarnation cannot inherit the old element editing mode',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','resize');const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.has('A'),false);}); +test('switching tools clears the owned preview atomically and blocks fresh transform/connection gestures in pan',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot();const observed=[];sessions.subscribe(()=>observed.push(sessions.getSnapshot()));sessions.tool('v','pan');assert.equal(observed.length,1);assert.equal(observed[0].views.get('v').tool,'pan');assert.equal(observed[0].gesture,null);assert.equal(observed[0].lastCancelReason,'tool_changed');assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'tool_not_select');assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).code,'tool_not_select');assert.strictEqual(editor.getSnapshot(),before);}); +test('old detach cleanup cannot remove a freshly attached view with the same ID',()=>{const{sessions,detach}=fixture();detach();sessions.attach('v');detach();assert.equal(sessions.getSnapshot().views.has('v'),true);}); + +test('mode lifetime follows identity and placement while ordinary content edits preserve it',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','editing');sessions.mode('w','A','resize');editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'new content'}});assert.equal(sessions.getSnapshot().views.get('v').modes.get('A'),'editing');assert.equal(sessions.getSnapshot().views.get('w').modes.get('A'),'resize');assert.equal(editor.dispatch({type:'element.move',target:{id:'A',incarnation:1},canvasId:'child'}).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.size,0);assert.equal(sessions.getSnapshot().views.get('w').modes.size,0);assert.throws(()=>sessions.mode('v','missing','editing'),/element_missing/);const before=sessions.getSnapshot();sessions.mode('v','missing',null);assert.strictEqual(sessions.getSnapshot(),before);}); +test('target replacement publishes mode invalidation and gesture cancellation together',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','resize');sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('v').modes.has('A'),false);assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'target_changed');}); +test('another view tool change preserves the gesture, and same-tool selection is idempotent',()=>{const{sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;sessions.tool('w','pan');assert.strictEqual(sessions.getSnapshot().gesture,gesture);const before=sessions.getSnapshot();sessions.tool('v','select');assert.strictEqual(sessions.getSnapshot(),before);assert.throws(()=>sessions.tool('v','unknown'),/invalid_tool/);assert.strictEqual(sessions.getSnapshot(),before);sessions.cancel();sessions.beginConnection('w',{id:'edge',incarnation:1});assert.equal(sessions.getSnapshot().connectionGesture,null);sessions.tool('w','select');assert.equal(sessions.beginConnection('w',{id:'edge',incarnation:1}).enabled,true);sessions.tool('w','pan');assert.equal(sessions.getSnapshot().connectionGesture,null);}); +test('canvas navigation and detach notify only a final session, never a new target with the old gesture',()=>{const{sessions,detach}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);let observed=[];const stop=sessions.subscribe(()=>observed.push(sessions.getSnapshot()));sessions.canvas('v','child');assert.equal(observed.length,1);assert.equal(observed[0].views.get('v').canvasId,'child');assert.equal(observed[0].gesture,null);sessions.canvas('v','root');sessions.begin('v',[{id:'A',incarnation:1}]);observed=[];detach();assert.equal(observed.length,1);assert.equal(observed[0].views.has('v'),false);assert.equal(observed[0].gesture,null);stop();}); +test('dispose fences reentrant admission and cannot leave a gesture or accept repeated cleanup',()=>{const{sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);let attempt,calls=0;sessions.subscribe(()=>{calls++;attempt=sessions.begin('v',[{id:'A',incarnation:1}]);});sessions.dispose();assert.equal(attempt.enabled,false);assert.equal(attempt.code,'view_detached');assert.equal(calls,1);assert.equal(sessions.getSnapshot().views.size,0);assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().connectionGesture,null);sessions.dispose();assert.equal(calls,1);}); + +test('component/version changes invalidate modes while invalid tags never enter a view',()=>{for(const patch of [{componentId:'plain-text'},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.mode('v','A','editing');const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.has('A'),false);const snapshot=sessions.getSnapshot();assert.throws(()=>sessions.mode('v','B',{}),/invalid_mode/);assert.strictEqual(sessions.getSnapshot(),snapshot);}});