/** * 渲染定义目录与 React 组装的契约测试。模型 registry 定义数据与能力,renderer catalog 定义宿主如何显示,两者按类型/版本/结构匹配。 * 这里检查描述捕获、投影记录与函数身份,不挂载真实 ReactFlow 页面。 */ import {test} from 'node:test'; import assert from 'node:assert/strict'; import {createRendererCatalog,builtinRendererDefinitions} from '../../src/lib/canvas-bindings/renderers.ts'; import {createReactRenderers} from '../../src/lib/canvas-bindings/react-renderers.ts'; import {createDocument,createRegistry,builtinComponents,defineComponent,movable,createEditor} from '../../src/lib/canvas-core/index.ts'; import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts'; // 自定义计量节点的精确渲染键,不允许用同一映射自动兼容未来版本或不同 structure。 const descriptor={componentId:'plugin:meter',dataVersion:1,structure:'node',rendererId:'meter'}; // 模型独立声明 metric 校验和移动能力;同一个模型可由多个宿主使用不同渲染器。 const definition=defineComponent({id:'plugin:meter',dataVersion:1,structure:'node',validate:data=>typeof data.metric==='number',features:[movable()]}),registry=createRegistry([...builtinComponents(),definition]); // 保留不透明标题和看似内部字段的数据,用来核对投影注入的可信原记录不会被用户 data 冒充。 const node={id:'meter',incarnation:1,ownerCanvasId:'root',structure:'node',componentId:'plugin:meter',dataVersion:1,data:{metric:7,title:{opaque:true},__headlessRecord:{untrusted:true}},geometry:{x:0,y:0,width:200,height:100,rotation:0}}; // 目录只捕获不可变元数据,不持有 React render 值;输入描述后续变化无效,未来版本/不同结构不匹配,重复或保留 ID 被拒绝。 test('renderer catalog captures exact version/structure mappings without React values or mutable descriptors',()=>{ const input={...descriptor,render(){}};const catalog=createRendererCatalog([input]);input.rendererId='changed';assert.equal(catalog.resolve(node).rendererId,'meter');assert.equal(catalog.resolve({...node,dataVersion:2}),undefined);assert.equal(catalog.resolve({...node,structure:'connection'}),undefined);assert.equal(catalog.definitions[0].render,undefined);assert.ok(Object.isFrozen(catalog.definitions[0]));assert.throws(()=>createRendererCatalog([descriptor,descriptor]),/duplicate_renderer_definition/);assert.throws(()=>createRendererCatalog([{...descriptor,rendererId:'unsupported'}]),/invalid_renderer_definition/); }); // 组装仅在同一个 render 实现时共享别名包装;拒绝空 renderer、ID 冲突与模型契约不符,getter 只读取一次并保留 memo 对象。 test('React assembly preserves aliases only for the same component and rejects invalid renderers or model contracts',()=>{ // render 返回 null,仅用作可比较的组件身份;此例检查包装与别名缓存,没有执行实际节点 UI。 const render=()=>null,assembly=createReactRenderers([{...descriptor,render}],registry);assert.equal(assembly.nodeTypes.meter.type,render);assert.ok(Object.isFrozen(assembly.nodeTypes));assert.equal(assembly.edgeTypes.meter,undefined); assert.throws(()=>createReactRenderers([{...descriptor,render:null}]),/invalid_react_renderer/);assert.throws(()=>createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render:()=>null}]),/renderer_id_conflict/);assert.throws(()=>createReactRenderers([{...descriptor,dataVersion:2,render}],registry),/renderer_model_mismatch/); let reads=0;const getter=createReactRenderers([{...descriptor,get render(){reads++;return render;}}]);assert.equal(reads,1);assert.equal(getter.definitions[0].render,render);assert.equal(getter.nodeTypes.meter,assembly.nodeTypes.meter); const alias=createReactRenderers([{...descriptor,render},{...descriptor,componentId:'other',render}]);assert.equal(alias.nodeTypes.meter,assembly.nodeTypes.meter);const memo={$$typeof:Symbol.for('react.memo'),type:render};assert.equal(createReactRenderers([{...descriptor,render:memo}]).nodeTypes.meter,memo); }); // 自定义节点与边投影携带原记录和 transform,未知 data/path 不丢;未安装映射或未来 schema 走 unsupported。 test('installed node and edge renderers receive original records and transforms while absent schemas still fall back',()=>{ const customEdge=defineComponent({id:'plugin:edge',dataVersion:1,structure:'connection',validate:()=>true,features:[]}),r=createRegistry([...builtinComponents(),definition,customEdge]),other={...node,id:'second',geometry:{...node.geometry,x:300}},edge={id:'edge',incarnation:1,structure:'connection',componentId:'plugin:edge',dataVersion:1,ownerCanvasId:'root',source:{elementId:'meter',portId:'custom-out'},target:{elementId:'second',portId:'custom-in'},path:{type:'custom',retained:true},data:{path:{own:true},metric:99}},source=createDocument('project',[{id:'root',parent:null}],[node,other,edge]),catalog=createRendererCatalog([...builtinRendererDefinitions,descriptor,{...descriptor,componentId:'plugin:edge',structure:'connection',rendererId:'meter-edge'}]),p=projectCanvas(source,'root',r,new Set(),()=>undefined,null,catalog); 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}); // 已知模型但未装 renderer 与未来数据版本都走占位;前面的可信 __headlessRecord 必须覆盖用户同名字段。 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'); }); // 加载前预览必须显式提供 preview,实现与正式 renderer 独立捕获;非法 preview 或不一致别名被拒绝。 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.type,render);assert.equal(assembly.previewNodeTypes.meter.type,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/); }); // 两个宿主对同一 component 选择不同 rendererId,但共享同一文档记录与移动结果,命令仍只产生一个历史单元。 test('independent host renderers for one component share model data and capability behavior',()=>{ const editor=createEditor({registry,document:createDocument('project',[{id:'root',parent:null}],[node])}),one=createReactRenderers([{...descriptor,rendererId:'host-one',render:()=>null}],registry),two=createReactRenderers([{...descriptor,rendererId:'host-two',render:()=>null}],registry); assert.equal(editor.dispatch({type:'element.geometry',target:{id:node.id,incarnation:node.incarnation},patch:{x:42}}).status,'applied'); const document=editor.getSnapshot().document,a=projectCanvas(document,'root',registry,new Set(),()=>undefined,null,one.catalog),b=projectCanvas(document,'root',registry,new Set(),()=>undefined,null,two.catalog); assert.equal(a.nodes[0].type,'host-one');assert.equal(b.nodes[0].type,'host-two');assert.strictEqual(a.nodes[0].data.__headlessRecord,document.elements.get(node.id));assert.strictEqual(b.nodes[0].data.__headlessRecord,a.nodes[0].data.__headlessRecord);assert.equal(a.nodes[0].position.x,42);assert.deepEqual(b.nodes[0].position,a.nodes[0].position);assert.equal(editor.getSnapshot().history.undo.length,1); });