Expose React canvas hooks with scoped selectors and resource lifecycles
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||
import {openProjectEngine} from '../../src/lib/canvas-runtime/engine.ts';
|
||||
async function fixture(){let broken=false;const errors=[],engine=await openProjectEngine({scope:{accountId:'a',workspaceId:'w',projectId:'p'},registry:createRegistry(builtinComponents()),document:createDocument('p',[{id:'canvas',parent:null}],[]),versions:new Map([['canvas',1]]),storage:{async claim(scope){return{scope,active:true,fence:1,async release(){this.active=false;}};},async recover(){return null;},async commit(_leader,batch){if(broken)throw{code:22,message:'quota'};return{durableRootRevision:batch.image.rootRevision};},async readFile(){return;},close(){}},persistence:{compile(){throw Error('unexpected write');},async submit(){throw Error('unexpected write');},async query(){throw Error('unexpected query');},canonical(){throw Error('unexpected canonical');}},onError:error=>errors.push(error)});await engine.persist();return{engine,errors,fail:value=>broken=value};}
|
||||
test('durability subscribers observe dirty metadata, final durable cut and closure without another business Root',async()=>{const{engine}=await fixture(),states=[];const stop=engine.subscribeDurability(()=>states.push(engine.getDurability()));assert.strictEqual(engine.getDurability(),engine.getDurability());engine.editor.updateRuntime(previous=>({...previous}));const revision=engine.getSnapshot().rootRevision;assert.ok(states.some(state=>state.rootRevision>state.durableRootRevision));await engine.persist();assert.equal(states.at(-1).durableRootRevision,revision);assert.equal(engine.getSnapshot().rootRevision,revision);await engine.close();assert.ok(states.some(state=>state.closing&&!state.closed));assert.equal(states.at(-1).closed,true);stop();const noop=engine.subscribeDurability(()=>assert.fail('closed owner notified'));noop();});
|
||||
test('storage failures remain observable and normalize numeric DOM-style codes without listener exceptions changing durability',async()=>{const f=await fixture();const states=[];f.engine.subscribeDurability(()=>{throw Error('observer');});f.engine.subscribeDurability(()=>states.push(f.engine.getDurability()));f.fail(true);f.engine.editor.updateRuntime(previous=>({...previous}));await assert.rejects(f.engine.persist());assert.equal(states.at(-1).error,'quota');assert.equal(typeof states.at(-1).error,'string');assert.ok(states.at(-1).durableRootRevision<states.at(-1).rootRevision);f.fail(false);await f.engine.persist();assert.equal(states.at(-1).error,undefined);assert.equal(states.at(-1).rootRevision,states.at(-1).durableRootRevision);assert.ok(f.errors.some(error=>error.message==='observer'));await f.engine.close();});
|
||||
@@ -78,6 +78,10 @@ try {
|
||||
import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createReactRenderers,CanvasRendererCommandsContext,useCanvasRendererCommands} from '@endoleap/headless-canvas/react-flow';
|
||||
import {createRegistry,builtinComponents} from '@endoleap/headless-canvas/core';
|
||||
import {CanvasProvider,useCanvas,useSyncStatus} from '@endoleap/headless-canvas/react';
|
||||
const snapshot={rootRevision:0,runtime:{ledger:{operations:new Map(),requests:new Map()}},document:{projectId:'react-consumer'}};const durable={rootRevision:0,durableRootRevision:0,closed:false,closing:false};const hookEngine={getSnapshot:()=>snapshot,getDurability:()=>durable,subscribeDurability:()=>()=>{},editor:{subscribe:()=>()=>{}}};
|
||||
function HookView(){const id=useCanvas(value=>value.document.projectId),status=useSyncStatus();return React.createElement('span',null,id+':'+status.status);}
|
||||
assert.equal(renderToStaticMarkup(React.createElement(CanvasProvider,{engine:hookEngine},React.createElement(HookView))),'<span>react-consumer:synced</span>');
|
||||
const registry=createRegistry(builtinComponents());let dispatched=0,seen;const commands={dispatch(){dispatched++;return{status:'applied',revision:1,operationIds:[]};},transaction(){throw Error('unused');}};
|
||||
function View(){seen=useCanvasRendererCommands();return React.createElement('span',null,'peer renderer');}
|
||||
const renderers=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'fixture',render:View}],registry);
|
||||
@@ -88,6 +92,10 @@ try {
|
||||
await writeFile(join(packageRoot,'react-consumer-types.mts'),`
|
||||
import {createElement} from 'react';import type {NodeProps,EdgeProps} from '@xyflow/react';
|
||||
import {createReactRenderers,type ReactFlowProjectionNode,type ReactFlowProjectionEdge} from '@endoleap/headless-canvas/react-flow';
|
||||
import {CanvasProvider,useCanvas,useElement,useActions,useMode,useGestureBindings,useOperation,useAsset,useSyncStatus} from '@endoleap/headless-canvas/react';
|
||||
import type {ProjectEngine} from '@endoleap/headless-canvas/runtime';
|
||||
function Hooks(){const ref={id:'a',incarnation:1};const title=useCanvas(root=>root.document.projectId);useElement(ref);const actions=useActions(ref);useMode(ref);useGestureBindings(ref);useOperation('operation');useAsset('resource');useSyncStatus('canvas');return createElement('button',{onClick:()=>actions.rename(title,{label:'Rename'})},title);}
|
||||
export function BoundHooks({engine}:{engine:ProjectEngine}){return createElement(CanvasProvider,{engine,viewId:'view'},createElement(Hooks));}
|
||||
function Card({data}:NodeProps<ReactFlowProjectionNode>){return createElement('div',null,data.title);}
|
||||
function Link({data}:EdgeProps<ReactFlowProjectionEdge>){return createElement('span',null,data?.__headlessRecord?.id);}
|
||||
export const adapter=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'card',render:Card},{componentId:'connection',dataVersion:1,structure:'connection',rendererId:'link',render:Link}]);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
import {StrictMode,memo,useEffect} from 'react';
|
||||
import {createRoot} from 'react-dom/client';import {flushSync} from 'react-dom';
|
||||
import {CanvasProvider,useCanvas,useElement,useActions,useMode,useGestureBindings,useOperation,useAsset,useSyncStatus} 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';
|
||||
const assert=(value:unknown,message:string)=>{if(!value)throw Error(message);};
|
||||
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');}
|
||||
async function fixture(partial=false){
|
||||
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;
|
||||
const files=new Map<string,Blob>(),receipts=new Map(),errors:unknown[]=[];let reads=0;
|
||||
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(){}};
|
||||
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};
|
||||
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)};
|
||||
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();
|
||||
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();}};
|
||||
}
|
||||
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};let title:any,element:any,actions:any,mode:any,gesture:any,operation:any,status:any,assetA:any,assetB:any;
|
||||
const Title=memo(function Title(){title=useCanvas(snapshot=>({title:snapshot.document.elements.get('a')?.data.title}),equal);counts.title++;return null;});
|
||||
const Element=memo(function Element(){element=useElement({id:'a',incarnation:1});counts.element++;return null;});
|
||||
const Actions=memo(function Actions(){actions=useActions({id:'a',incarnation:1});return null;});
|
||||
const Mode=memo(function Mode(){mode=useMode({id:'a',incarnation:1});counts.mode++;return null;});
|
||||
const Gesture=memo(function Gesture(){gesture=useGestureBindings({id:'a',incarnation:1});counts.gesture++;return null;});
|
||||
const Operation=memo(function Operation({id}:{id:string}){operation=useOperation(id);return null;});
|
||||
const Status=memo(function Status(){status=useSyncStatus();return null;});
|
||||
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;});
|
||||
const render=async(children:any,owner=engine)=>{flushSync(()=>root.render(<StrictMode><CanvasProvider engine={owner} viewId="v">{children}</CanvasProvider></StrictMode>));await tick();};
|
||||
try{
|
||||
await render(<><Title/><Element/><Actions/><Mode/><Gesture/><Operation id="missing"/><Status/></>);await until(()=>status?.status==='synced');const baseline={...counts},originalActions=actions;
|
||||
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');
|
||||
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');assert(element.status==='present'&&element.element.data.title==='renamed','element did not observe canonical rename');await render(<><Title/><Element/><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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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;
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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();}
|
||||
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();}
|
||||
return reports;
|
||||
}finally{flushSync(()=>root.unmount());container.remove();f.fail(false);f.detach();await engine.close();}
|
||||
}
|
||||
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;}
|
||||
@@ -3,9 +3,9 @@ import { Handle, Position, BaseEdge, getBezierPath, type NodeProps, type EdgePro
|
||||
import { defineComponent, editable, movable } from '@/lib/canvas-core/index';
|
||||
import type {ReactFlowProjectionNode as ProjectionNode,ReactFlowProjectionEdge as ProjectionEdge} from '@/lib/canvas-bindings/react-flow';
|
||||
import type { CanvasRuntimeExtension } from '@/lib/canvas-bindings/react-flow';
|
||||
import { useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow';
|
||||
import { useElement, useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow';
|
||||
function Meter({data}:NodeProps<ProjectionNode>){
|
||||
const commands=useCanvasRendererCommands(),record=data.__headlessRecord!;
|
||||
const commands=useCanvasRendererCommands(),lookup=useElement(data.__headlessRecord!),record=lookup.status==='present'?lookup.element:data.__headlessRecord!;
|
||||
return <div data-renderer-fixture="meter" style={{width:'100%',height:'100%',background:'white',border:'1px solid #ded7ce',padding:16}}>
|
||||
<span>Metric {String(record.data.metric)}</span><button className="nodrag" aria-label={`Increase ${record.id}`} onClick={event=>{event.stopPropagation();commands.transaction(tx=>{tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{metric:Number(record.data.metric)+1}});tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{raw:{retained:true,edited:true}}});},{label:'Increase metric'});}}>Increase</button>
|
||||
<Handle type="source" id="custom-out" position={Position.Right}/><Handle type="source" id="custom-in" position={Position.Left}/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { setTimeout as pause } from 'node:timers/promises';
|
||||
import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts';
|
||||
const performanceMode=process.argv.includes('--performance'),performanceCount=Number(process.env.HEADLESS_PERFORMANCE_COUNT??1000);
|
||||
const hooksOnly=process.argv.includes('--hooks-only'),performanceMode=process.argv.includes('--performance'),performanceCount=Number(process.env.HEADLESS_PERFORMANCE_COUNT??1000);
|
||||
if(performanceMode&&(!Number.isSafeInteger(performanceCount)||performanceCount<1||performanceCount>10000))throw Error('invalid performance count');
|
||||
const source = resolve(import.meta.dirname, '../..'), directory = await mkdtemp(join(tmpdir(), 'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-'));
|
||||
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
@@ -107,11 +107,12 @@ try {
|
||||
await cp(join(source, 'public'), join(directory, 'public'), { recursive: true });
|
||||
if(!performanceMode){
|
||||
await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx'));
|
||||
await copyFile(join(source,'tests/headless/react-hooks-fixture.tsx'),join(directory,'src/components/canvas/hooks-fixture.tsx'));
|
||||
// A fixture Host action exercises the public engine API; no debug entry point ships in the app.
|
||||
const importsFile=join(directory,'src/components/canvas/headless-imports.tsx'),importsSource=await readFile(importsFile,'utf8');
|
||||
await writeFile(importsFile,importsSource.replace('import { useCallback,','import { useEffect, useCallback,').replace(' const subscribe =',` useEffect(()=>{const host=globalThis as typeof globalThis&{__headlessCollectSources?:()=>ReturnType<ProjectEngine['collectImportSources']>};const collect=()=>engine.collectImportSources({retentionMs:0});host.__headlessCollectSources=collect;return()=>{if(host.__headlessCollectSources===collect)delete host.__headlessCollectSources;};},[engine]);
|
||||
const subscribe =`));
|
||||
const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";').replace('extensions=[]','extensions=[fixtureExtension]'));
|
||||
const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";\nimport {HookChecks} from "@/components/canvas/hooks-fixture";').replace('extensions=[]','extensions=[fixtureExtension]').replace('{children}</Renderers.Provider>','{children}<HookChecks/></Renderers.Provider>'));
|
||||
}
|
||||
|
||||
for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs']) await copyFile(join(source, file), join(directory, file));
|
||||
@@ -135,6 +136,7 @@ try {
|
||||
await call('Page.navigate', { url: route });
|
||||
if(performanceMode){
|
||||
await measureRenderer({call,evaluate,documents,root,partition,count:performanceCount,exceptions,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')});
|
||||
}else if(hooksOnly){await until('typeof window.__runCanvasHookChecks==="function"','hook fixture did not mount');const reports=await evaluate('window.__runCanvasHookChecks()');for(const report of reports)console.log('PASS React hooks: '+report);
|
||||
}else{
|
||||
await until('!!document.querySelector("[data-headless-canvas][data-model-loading] .react-flow__node[data-id=a]")','first editable model prefix did not open before the gated tail');
|
||||
assert.equal(await evaluate('!!document.querySelector(".react-flow__node[data-id=b]")'),false);
|
||||
@@ -310,6 +312,7 @@ try {
|
||||
assert.equal(uploads.length, 1); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false);
|
||||
console.log('PASS pending image displays before upload; navigation preserves work and fences late view updates; reload retains resource identity');
|
||||
console.log('PASS canvas floating controls remain visible while the sidebar is expanded');
|
||||
const hookReports=await evaluate('window.__runCanvasHookChecks()');for(const report of hookReports)console.log('PASS React hooks: '+report);
|
||||
const beforeImport=documents.get(root).elements.size,importRecord={kind:'element',key:'ui-import',record:{id:'ui-import',incarnation:1,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:'Imported NDJSON node',text:'Visible before ACK'},geometry:{x:700,y:450,width:240,height:160,rotation:0}}},nativeText=JSON.stringify(importRecord)+'\n{bad}';
|
||||
await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(nativeText)}],'canvas.ndjson',{type:'application/x-ndjson'}));const input=document.querySelector('[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
await until(`document.querySelectorAll('.react-flow__node').length===6&&document.body.innerText.includes('部分加入')`,'native import did not show its successful prefix and partial report');
|
||||
|
||||
@@ -21,3 +21,6 @@ test('installed node and edge renderers receive original records and transforms
|
||||
assert.equal(p.nodes[0].type,'meter');assert.equal(p.nodes[0].data.__headlessFallback,undefined);assert.equal(p.nodes[0].data.__headlessRecord,source.elements.get('meter'));assert.deepEqual(p.nodes[0].data.__headlessRecord.data.title,{opaque:true});assert.equal(p.nodes[0].data.__headlessTransform.scale,1);assert.equal(p.edges[0].type,'meter-edge');assert.equal(p.edges[0].data.__headlessRecord,source.elements.get('edge'));assert.deepEqual(p.edges[0].data.__headlessRecord.path,{type:'custom',retained:true});
|
||||
const missing=projectCanvas(source,'root',r,new Set(),()=>undefined);assert.equal(missing.nodes[0].type,'unsupported');assert.equal(missing.edges[0].type,'unsupported');const future=createDocument('project',[{id:'root',parent:null}],[{...node,dataVersion:2}]);assert.equal(projectCanvas(future,'root',r,new Set(),()=>undefined,null,catalog).nodes[0].type,'unsupported');
|
||||
});
|
||||
test('pre-engine preview renderers are explicit and captured independently from engine-bound renderers',()=>{
|
||||
const render=()=>null,preview=()=>null,assembly=createReactRenderers([{...descriptor,render,preview}]);assert.equal(assembly.nodeTypes.meter,render);assert.equal(assembly.previewNodeTypes.meter,preview);assert.equal(assembly.definitions[0].preview,preview);assert.ok(Object.isFrozen(assembly.previewNodeTypes));assert.equal(createReactRenderers([{...descriptor,render}]).previewNodeTypes.meter,undefined);assert.throws(()=>createReactRenderers([{...descriptor,render,preview:0}]),/invalid_react_preview/);assert.throws(()=>createReactRenderers([{...descriptor,render,preview},{...descriptor,componentId:'other',render}]),/renderer_id_conflict/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';
|
||||
import {createSelectorStore} from '../../src/lib/canvas-bindings/selectors.ts';
|
||||
function source(initial){let state=initial;const listeners=new Set();return{getSnapshot:()=>state,subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);},set(next){state=next;for(const fn of [...listeners])fn();},get size(){return listeners.size;}};}
|
||||
test('selector store caches immutable reads and notifies only changed selections',()=>{const s=source({a:1,b:1});let selections=0,notifications=0;const store=createSelectorStore(s,value=>{selections++;return{a:value.a};},(a,b)=>a.a===b.a),first=store.getSnapshot();assert.strictEqual(store.getSnapshot(),first);assert.equal(selections,1);const stop=store.subscribe(()=>notifications++);s.set({a:1,b:2});assert.strictEqual(store.getSnapshot(),first);assert.equal(notifications,0);s.set({a:2,b:2});assert.equal(notifications,1);assert.equal(store.getSnapshot().a,2);stop();assert.equal(s.size,0);});
|
||||
test('selector errors notify React to retry the snapshot and retain no invalid cached value',()=>{const s=source({value:1});const store=createSelectorStore(s,next=>{if(next.value<0)throw Error('invalid');return next.value;});let notifications=0;const stop=store.subscribe(()=>notifications++);s.set({value:-1});assert.equal(notifications,1);assert.throws(store.getSnapshot,/invalid/);s.set({value:2});assert.equal(store.getSnapshot(),2);assert.equal(notifications,2);stop();});
|
||||
test('distinct subscribers observe changes independently and unsubscribe during notification',()=>{const s=source(0),store=createSelectorStore(s,n=>n);let a=0,b=0,stopA;stopA=store.subscribe(()=>{a++;stopA();});const stopB=store.subscribe(()=>b++);s.set(1);s.set(2);assert.equal(a,1);assert.equal(b,2);stopB();assert.equal(s.size,0);});
|
||||
Reference in New Issue
Block a user