/** * 浏览器迁移控制器与旧画布编辑租约测试。旧编辑视图持 shared lease,迁移要求独占且不等待的 lease,避免新旧写入并行。 * 使用内存 localStorage/Web Locks 替身验证协调逻辑,不代表真实多标签 Web Locks 已在本文件执行。 */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {claimLegacyCanvasEditing,createBrowserCanvasMigration} from '../../src/lib/browser-canvas-migration.ts'; import {canvasMigrationStorageKey} from '../../src/lib/canvas-migration.ts'; import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts'; // 确定性 UUID 及账户/workspace 作用域;editing 锁名与迁移持久化键都从此作用域派生。 const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,scope={accountId:id(1),workspaceId:id(2)},root=id(3),registry=createRegistry(builtinComponents()); // 搭建 records、held 锁表及调用记录,controller 控制当前视图生命周期。 function fixture(){ const records=new Map(),held=new Map(),calls=[]; // 最小 localStorage 接口,保存原字符串以检查未来/损坏材料不会被覆盖。 const storage={getItem:key=>records.get(key)??null,setItem:(key,value)=>records.set(key,value),removeItem:key=>records.delete(key)}; // 异步模拟共享/独占互斥:不可用时调用 work(null),成功期间增加持有计数,finally 总是归还锁。 const locks={request(name,options,work){calls.push({name,options});return Promise.resolve().then(async()=>{ // shared 只排斥 exclusive,exclusive 排斥任意持有者;这是 fixture 的即时 ifAvailable 模拟,不实现公平等待队列。 const state=held.get(name)??{shared:0,exclusive:false},granted=options.mode==='shared'?!state.exclusive:!state.exclusive&&!state.shared; if(!granted)return work(null); if(options.mode==='shared')state.shared++;else state.exclusive=true;held.set(name,state); try{return await work({name,mode:options.mode});}finally{if(options.mode==='shared')state.shared--;else state.exclusive=false;if(!state.shared&&!state.exclusive)held.delete(name);} });}}; // 所有租约和迁移实例共享当前 AbortSignal;isCurrentIdentity 可在用例中覆写为过期身份。 const controller=new AbortController(),options={scope,registry,locks,storage,signal:controller.signal,isCurrentIdentity:()=>true}; return{records,held,calls,controller,options}; } // 两个旧编辑器可共享锁,释放一个仍不能迁移;全部释放后 exclusive 迁移检查可进入 idle,所有请求均 ifAvailable。 test('editable legacy views share a lease and exclude migration until every editor releases',async()=>{ const f=fixture(),one=await claimLegacyCanvasEditing({...f.options,canvasId:root}),two=await claimLegacyCanvasEditing({...f.options,canvasId:root}); const migration=createBrowserCanvasMigration(f.options); await assert.rejects(()=>migration.resume(),/migration_busy/);await one.release();await assert.rejects(()=>migration.resume(),/migration_busy/); await two.release();assert.equal((await migration.resume()).status,'idle');assert.equal(f.held.size,0); assert.ok(f.calls.every(call=>call.name===canvasMigrationStorageKey(scope)+':editing'&&call.options.ifAvailable===true)); assert.deepEqual(f.calls.slice(0,2).map(call=>call.options.mode),['shared','shared']);assert.ok(f.calls.slice(2).every(call=>call.options.mode==='exclusive')); }); // pending 迁移只阻止受影响旧 canvas,其他 canvas 可编辑;未来记录版本无法解释时拒绝但原字符串保持。 test('pending migration blocks affected old views and corrupt records cannot enable editing',async()=>{ const f=fixture(),key=canvasMigrationStorageKey(scope),job={version:1,...scope,rootId:root,id:id(4),phase:'pending',body:JSON.stringify({protocol_version:2,request_id:id(4),expected_versions:{[root]:1}})}; f.storage=f.options.storage;f.storage.setItem(key,JSON.stringify(job)); await assert.rejects(()=>claimLegacyCanvasEditing({...f.options,canvasId:root}),/migration_response_unknown/);assert.equal(f.held.size,0); const unrelated=await claimLegacyCanvasEditing({...f.options,canvasId:id(99)});await unrelated.release(); const saved=JSON.stringify({version:2,data:'retain'});f.storage.setItem(key,saved); await assert.rejects(()=>claimLegacyCanvasEditing({...f.options,canvasId:root}),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.held.size,0); }); // 视图 abort 或身份失效不能取得迟到编辑授权;已领取和未领取路径都归还锁,重复 release 安全。 test('aborted or stale view lifetimes release leases and cannot acquire a late editing grant',async()=>{ const f=fixture(),lease=await claimLegacyCanvasEditing({...f.options,canvasId:root});f.controller.abort();await lease.release();assert.equal(f.held.size,0);await lease.release(); const g=fixture(),waiting=claimLegacyCanvasEditing({...g.options,canvasId:root});g.controller.abort();await assert.rejects(waiting,{name:'AbortError'});assert.equal(g.held.size,0); const h=fixture();let current=true;const stale=claimLegacyCanvasEditing({...h.options,canvasId:root,isCurrentIdentity:()=>current});current=false;await assert.rejects(stale,/identity_changed/);assert.equal(h.held.size,0); });