Files
Endoleap/tests/headless/react-hooks-fixture.tsx
T

103 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 供真实浏览器页面调用的 React hooks 场景。组件挂在隐藏容器中,在 StrictMode 下检查订阅路由、手势租约和资源句柄。
* engine 与 hooks 使用生产实现,存储、上传和文档服务为内存替身;此处不验证远程授权、数据库或图片解码。
* 断言通过页面全局入口返回文字报告,由外层浏览器 runner 收集;不会在组件导入时自动运行全部场景。
*/
"use client";
import {StrictMode,memo,useEffect,useLayoutEffect} from 'react';
import {createRoot} from 'react-dom/client';import {flushSync} from 'react-dom';
import {CanvasProvider,useCanvas,useElement,useActions,useMode,useGestureBindings,useOperation,useAsset,useSyncStatus,type ElementView} from '@/lib/canvas-bindings/react';
import {createRegistry,builtinComponents,createDocument,applyChanges} from '@/lib/canvas-core/index';
import {openProjectEngine} from '@/lib/canvas-runtime/engine';
import {equal} from '@/lib/canvas-core/values';
// 浏览器断言失败直接抛错,使外层 Runtime.evaluate 得到失败;不把失败写成通过报告。
const assert=(value:unknown,message:string)=>{if(!value)throw Error(message);};
// 短暂等待浏览器计时器;until 最多轮询 1000 次,观察 React effect/订阅结果,而非假设 flush 后同步可读。
const tick=()=>new Promise(resolve=>setTimeout(resolve,5));async function until(check:()=>boolean){for(let i=0;i<1000;i++){if(check())return;await tick();}throw Error('hook observation timed out');}
// 装配两个节点和两个 view;partial 模式只发布 a,但完整来源仍含 b,供 unloaded→present/absent 检查。
async function fixture(partial=false){
// 合法文本节点外壳;版本、身份和 owner 固定,序列号另用于命令/请求 ID。
const node=(id:string)=>({id,incarnation:1,structure:'node' as const,componentId:'text',dataVersion:1,ownerCanvasId:'canvas',geometry:{x:0,y:0,width:100,height:80,rotation:0},data:{title:id,text:''}});let server=createDocument(partial?'canvas':'hooks',[{id:'canvas',parent:null}],[node('a'),node('b')]),version=1,serial=0,image:any=null,fail=false,readGate:Promise<void>|undefined;
// 分别保存本地 Blob、文档回执及异步错误,read 次数用于验证资源 hooks 共享读取。
const files=new Map<string,Blob>(),receipts=new Map(),errors:unknown[]=[];let reads=0;
// 内存存储可注入 quota,readFile 可被 Promise 屏障延迟;不实现真实浏览器锁或 IndexedDB。
const storage={async claim(scope:any){return{scope,fence:1,active:true,async release(){this.active=false;}};},async recover(){return image;},async commit(_leader:any,batch:any){if(fail)throw Error('quota');for(const file of batch.files??[])files.set(file.id,file.bytes);image=structuredClone(batch.image);return{durableRootRevision:image.rootRevision};},async readFile(_leader:any,id:string){reads++;if(readGate)await readGate;return files.get(id);},close(){}};
// 上传替身返回固定 2×1 元数据与资源 URL,不验证图片字节;查询保持 unresolved,accept 直接取 canonical。
const assets={sourceURL:(id:string)=>`https://assets.test/${id}`,displayURL:(id:string)=>`/assets/${id}`,async upload(request:any,bytes:Blob){const payload=JSON.parse(request.body);return{outcome:'applied' as const,canonical:{id:payload.resource_id,url:assets.sourceURL(payload.resource_id),bytes:bytes.size,mime_type:bytes.type,width:2,height:1}};},async query(){return{outcome:'unresolved' as const};},accept:(_request:any,status:any)=>status.canonical};
// 文档替身应用真实 changes 并递增版本,把成功模型放在回执表;不实现真实 HTTP 或请求去重。
const persistence={compile(intent:any,id:string,snapshot:any){return{payload:{intent},canvasIds:intent.canvasIds,expectedVersions:{canvas:snapshot.runtime.versions.get('canvas')}};},async submit(request:any){server=applyChanges(server,JSON.parse(request.body).intent.changes);receipts.set(request.id,{document:server,versions:new Map([['canvas',++version]])});return{outcome:'applied' as const};},async query(){return{outcome:'unresolved' as const};},canonical:(request:any)=>receipts.get(request.id)};
// 装配真实引擎与 loading 状态,显式 attach v/w 后等待本地初始工作收敛。
const engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:partial?'canvas':'hooks'},document:partial?createDocument('canvas',[{id:'canvas',parent:null}],[node('a')]):server,...(partial?{loading:{version:1 as const,sourceToken:'hook-partial',declaredCanvasIds:['canvas'],receivedElements:1,totalElements:2}}:{}),versions:new Map([['canvas',1]]),registry:createRegistry(builtinComponents()),storage,assets,persistence,createId:()=>`hook-${++serial}`,onError:error=>errors.push(error)});const detachA=engine.sessions.attach('v','canvas'),detachB=engine.sessions.attach('w','canvas');await engine.settle();
// 向场景暴露保存故障、读取屏障与读次数;detach 只清理 fixture 自己附加的两个会话。
return{engine,node,files,errors,full:server,get reads(){return reads;},fail(value:boolean){fail=value;},gate(value?:Promise<void>){readGate=value;},detach(){detachA();detachB();}};
}
// 创建隐藏 React root 并串行运行场景;保留组件读到的最新 hook 结果和渲染/选择器计数供断言。
async function run(){
const f=await fixture(),engine=f.engine,container=document.createElement('div');container.style.display='none';document.body.append(container);const root=createRoot(container),reports:string[]=[],counts={title:0,element:0,mode:0,gesture:0,titleOnly:0};let titleOnly:unknown,titleSelections=0;let title:any,element:any,actions:any,mode:any,gesture:any,operation:any,status:any,assetA:any,assetB:any;
// 全局选择器只提取 a 的标题,equal 比较结果对象,检查无关 Root 发布不引发重渲染。
const Title=memo(function Title(){title=useCanvas(snapshot=>({title:snapshot.document.elements.get('a')?.data.title}),equal);counts.title++;return null;});
// 完整元素 hook 固定到 a 的第一代身份,稍后用于验证删除恢复后状态变为 stale。
const Element=memo(function Element(){element=useElement({id:'a',incarnation:1});counts.element++;return null;});
// 计数元素选择器实际执行次数,区分“跳过无关目标”与“目标变化但结果相等不渲染”。
const selectTitle=(view:ElementView)=>{titleSelections++;return view.status==='present'?view.element.data.title:view.status;};
// 只订阅目标标题字段;同元素其他字段变化允许重新选择,但不能增加组件渲染次数。
const ElementTitle=memo(function ElementTitle(){titleOnly=useElement({id:'a',incarnation:1},selectTitle);counts.titleOnly++;return null;});
// 捕获绑定到目标身份的命令 facade,检查稳定引用、历史选项和过期身份拒绝。
const Actions=memo(function Actions(){actions=useActions({id:'a',incarnation:1});return null;});
// 观察 v 视图的 mode,另一个 view 的 mode 更新不能使它重渲染。
const Mode=memo(function Mode(){mode=useMode({id:'a',incarnation:1});counts.mode++;return null;});
// 捕获目标绑定手势控制器;beginOnLayout 在替换目标的 layout effect 中立即开启新手势,以暴露旧 cleanup 误取消问题。
const Gesture=memo(function Gesture({targetId='a',beginOnLayout=false}:{targetId?:string;beginOnLayout?:boolean}){const binding=useGestureBindings({id:targetId,incarnation:1});gesture=binding;counts.gesture++;useLayoutEffect(()=>{if(beginOnLayout){assert(binding.begin().enabled,'replacement gesture did not begin');assert(binding.update({x:88}).enabled,'replacement gesture did not preview');}},[targetId,beginOnLayout]);return null;});
// 按逻辑操作 ID 观察操作及其请求;最初 missing 应没有记录,rename 后改绑真实 ID。
const Operation=memo(function Operation({id}:{id:string}){operation=useOperation(id);return null;});
// 独立观察同步、持久化错误和关闭状态,不靠业务文档再次变化触发观察。
const Status=memo(function Status(){status=useSyncStatus();return null;});
// 两个 slot 可同时借用同一资源,分别保存 hook 结果以检查共享 URL 及引用计数释放。
const Asset=memo(function Asset({id,slot}:{id:string;slot:'a'|'b'}){const value=useAsset(id);if(slot==='a')assetA=value;else assetB=value;return null;});
// 每次以 StrictMode 和 CanvasProvider 同步替换子树;owner 可替换成另一个 engine,随后等待 effects。
const render=async(children:any,owner=engine)=>{flushSync(()=>root.render(<StrictMode><CanvasProvider engine={owner} viewId="v">{children}</CanvasProvider></StrictMode>));await tick();};
try{
// 先等 synced 再记录动态基线;StrictMode 可能重复渲染,因此断言增量而非写死初始次数。
await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id="missing"/><Status/></>);await until(()=>status?.status==='synced');const baseline={...counts},originalActions=actions,initialTitleSelections=titleSelections;
// 改无关 b:窄订阅计数和 action 引用不变,元素标题选择器也不执行。
engine.editor.dispatch({type:'element.data',target:{id:'b',incarnation:1},patch:{title:'unrelated'}});await engine.settle();await tick();assert(equal(baseline,counts),'unrelated element/ACK rerendered narrow hooks');assert(actions===originalActions,'action facade identity drifted');assert(titleSelections===initialTitleSelections,'unrelated edit/ACK evaluated the element selector');
// 改 a 的 text 会执行其标题选择器,但标题没变时不得重渲染 ElementTitle。
const titleRenders=counts.titleOnly;engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{text:'other field'}});await engine.settle();await tick();assert(titleSelections>initialTitleSelections&&counts.titleOnly===titleRenders&&titleOnly==='a','field selector did not isolate title from another field on the same target');reports.push('element ID routing skips unrelated selector evaluation; field selectors observe their target without rerendering for other fields');
// 经 actions.rename 验证命令历史 label/groupId、canonical 观察及 operation/request 状态。
const renamed=actions.rename('renamed',{label:'Hook rename',groupId:'hook-rename'});await engine.settle();const renamedHistory=engine.getSnapshot().history.entries.get(engine.getSnapshot().history.undo.at(-1)!);assert(renamedHistory?.label==='Hook rename'&&renamedHistory.groupId==='hook-rename','action facade dropped command history options');await until(()=>title.title==='renamed'&&titleOnly==='renamed');assert(element.status==='present'&&element.element.data.title==='renamed','element did not observe canonical rename');await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);assert(operation?.operation.phase==='resolved'&&operation.request?.phase==='resolved','operation hook did not include its request');reports.push('selectors suppress unrelated element and ACK renders; bound actions share validated history/ledger and operation/request observation');
// 手势预览只改变交互状态,不改业务几何、revision 或 history;取消不留撤销记录,mode 仍按 view 隔离。
const history=engine.getSnapshot().history.undo.length,revision=engine.getSnapshot().revision;assert(gesture.begin().enabled&&gesture.update({x:42}).enabled,'gesture did not acquire/update');await until(()=>gesture.active&&gesture.geometry.x===42);assert(engine.getSnapshot().document.elements.get('a')!.structure==='node'&&(engine.getSnapshot().document.elements.get('a') as any).geometry.x===0,'preview mutated committed geometry');assert(engine.getSnapshot().revision===revision,'preview created an edit');gesture.cancel();await until(()=>!gesture.active);assert(engine.getSnapshot().history.undo.length===history,'canceled preview changed history');assert(mode.setMode('editing').enabled,'view mode rejected');await until(()=>mode.mode==='editing');const modeRenders=counts.mode;engine.sessions.mode('w','a','resize');await tick();assert(counts.mode===modeRenders,'another view changed this view mode');
// 第二次手势提交只新增一个历史单元,租约释放后旧 commit 不再可写。
assert(gesture.begin().enabled,'second gesture did not acquire');gesture.update({x:55});assert(gesture.commit().status==='applied','gesture did not commit');await engine.settle();assert(engine.getSnapshot().history.undo.length===history+1,'gesture did not create exactly one history entry');assert(gesture.commit().status==='rejected','released gesture lease remained writable');reports.push('gesture preview/cancel/commit owns a token and one history unit; modes stay scoped to their view');
// 切换 hook 目标后立即在 layout effect 开新租约;旧控制器的 cancel/commit 和 cleanup 都不能碰新目标。
const previousGesture=gesture,beforeOwnerSwap=engine.getSnapshot();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture targetId="b" beginOnLayout/><Operation id={renamed.operationIds[0]}/><Status/></>);const replacementToken=engine.sessions.getSnapshot().gesture?.token;assert(!!replacementToken&&engine.sessions.getSnapshot().gesture?.targets[0].id==='b','old target cleanup canceled the new layout gesture');assert(previousGesture.commit().status==='rejected','old controls committed the replacement gesture');previousGesture.cancel();assert(engine.sessions.getSnapshot().gesture?.token===replacementToken,'old controls canceled the replacement gesture');assert(engine.getSnapshot()===beforeOwnerSwap,'owner switch or obsolete callback published document/history work');gesture.cancel();await render(<><Title/><Element/><ElementTitle/><Actions/><Mode/><Gesture/><Operation id={renamed.operationIds[0]}/><Status/></>);reports.push('gesture controller replacement owns an independent lease; old cleanup, cancel and commit cannot touch a new target gesture');
// 先检查只有 durability 完成也能清除 dirty,再注入 quota 验证 blocked/dirty,重试成功才恢复 synced。
engine.editor.updateRuntime(previous=>({...previous}));await engine.persist();await tick();await until(()=>status.status==='synced'&&!status.dirty);assert(!status.dirty,'durable-only metadata completion was not observable');f.fail(true);actions.rename('unsaved');await until(()=>status.status==='blocked'&&status.error==='quota');assert(status.dirty,'failed persistence appeared durable');f.fail(false);await engine.retry();await engine.settle();await until(()=>status.status==='synced'&&!status.dirty);reports.push('sync status observes durable-only completion and quota failure/retry without claiming in-memory edits are synced');
// 暂存任意图片类型字节只用于资源引用测试;不是 PNG 解码验证。
let resource='';engine.stageFiles([{name:'image.png',bytes:new Blob(['image bytes'],{type:'image/png'})}],([ref],tx)=>{resource=ref.id;tx.dispatch({type:'element.create',element:{...f.node('image'),componentId:'image',data:{title:'image',text:'',assetId:ref.id,src:ref.src}}});});await engine.settle();const reads=f.reads;
// 两个 hooks 必须共用一次读取和一个 URL;移除一个不影响另一个,最后一个卸载后 URL 才失效。
await render(<><Asset id={resource} slot="a"/><Asset id={resource} slot="b"/><Status/></>);await until(()=>assetA?.status==='ready'&&assetB?.status==='ready');assert(assetA.url===assetB.url&&f.reads===reads+1,'two resource hooks did not share one display read/URL');const url=assetA.url;assert(await(await fetch(url)).text()==='image bytes','asset hook did not expose original bytes');await render(<><Asset id={resource} slot="a"/><Status/></>);assert(await(await fetch(url)).text()==='image bytes','unmounting one reader revoked the other');await render(<Status/>);let revoked=false;try{await fetch(url);}catch{revoked=true;}assert(revoked,'last resource reader did not release its URL');reports.push('resource hooks share a URL, retain remaining readers and release the last handle on unmount');
// 读取尚未返回时卸载 hook,随后才放行;迟到资源结果应释放显示 pin,不能泄漏借用。
let release!:()=>void;f.gate(new Promise<void>(resolve=>release=resolve));const before=f.reads;await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>f.reads>before);await render(<Status/>);release();f.gate();await tick();assert(!engine.getResourcePins().pins.get(resource)?.has('display'),'late resource load leaked a display pin');
// 删除并撤销恢复元素后 incarnation 已变化;旧 facade 必须拒绝写入,不能自动改绑同 ID 的新对象。
await render(<><Element/><Actions/><Gesture/><Status/></>);const staleActions=actions;engine.editor.dispatch({type:'element.remove',target:{id:'a',incarnation:1}});await engine.settle();engine.editor.history.undo();await engine.settle();await until(()=>element.status==='stale');const snapshot=engine.getSnapshot();assert(staleActions.rename('stale').status==='rejected'&&engine.getSnapshot()===snapshot,'old incarnation facade edited a restored element');reports.push('late resource loads release their pins and stale action targets cannot retarget restored incarnations');
// 关闭 engine 时 mounted hooks 也要观察到 closed/error 并释放资源。
await render(<><Asset id={resource} slot="a"/><Status/></>);await until(()=>assetA.status==='ready');await engine.close();await until(()=>status.status==='closed'&&assetA.status==='error');reports.push('engine close is observable and releases mounted resource handles without owning a second project');
// 换 Provider 所属引擎后,资源字节和命令目标都必须来自新 owner,不能修改旧引擎快照。
const other=await fixture();try{
let otherResource='';other.engine.stageFiles([{name:'other.png',bytes:new Blob(['other identity bytes'],{type:'image/png'})}],([ref],tx)=>{otherResource=ref.id;tx.dispatch({type:'element.create',element:{...other.node('other-image'),componentId:'image',data:{title:'other',text:'',assetId:ref.id,src:ref.src}}});});await other.engine.settle();
const oldSnapshot=engine.getSnapshot();await render(<><Asset id={otherResource} slot="a"/><Actions/><Status/></>,other.engine);await until(()=>assetA.status==='ready');assert(await(await fetch(assetA.url)).text()==='other identity bytes','Provider change reused another engine resource handle');assert(actions.rename('second owner').status==='applied','new owner action did not dispatch');await other.engine.settle();assert(other.engine.getSnapshot().document.elements.get('a')!.data.title==='second owner'&&engine.getSnapshot()===oldSnapshot,'Provider change routed commands to the old owner');reports.push('Provider owner changes replace resource handles and action targets without writing to the previous engine');
}finally{await other.engine.close();}
// 部分来源完成前 b 和未知 ID 都是 unloaded;loading.complete 后才能分别判断 present 与 absent。
const partial=await fixture(true);try{
let b:any,missing:any;function Lookup(){b=useElement('b');missing=useElement('not-in-source');return null;}
await render(<><Lookup/><Status/></>,partial.engine);assert(b.status==='unloaded'&&missing.status==='unloaded','partial lookup guessed absence');const completed=await partial.engine.loading.complete(partial.full,new Map([['canvas',1]]),{sourceToken:'hook-partial'});assert(completed.status==='applied','hook partial fixture did not complete');await partial.engine.settle();await until(()=>b.status==='present'&&missing.status==='absent');reports.push('element hooks preserve unloaded versus absent until source completion and then expose the newly loaded element');
}finally{await partial.engine.close();}
// 只有所有场景通过才返回报告;finally 卸载 root、移除隐藏容器、恢复故障开关并清理 fixture。
return reports;
}finally{flushSync(()=>root.unmount());container.remove();f.fail(false);f.detach();await engine.close();}
}
// effect 仅注册页面测试入口;清理时先比较函数身份,防止旧挂载删除新实例注册的入口。
export function HookChecks(){useEffect(()=>{const host=globalThis as typeof globalThis&{__runCanvasHookChecks?:()=>ReturnType<typeof run>};host.__runCanvasHookChecks=run;return()=>{if(host.__runCanvasHookChecks===run)delete host.__runCanvasHookChecks;};},[]);return null;}