Files
Endoleap/tests/headless/retained-projection.test.mjs
T

61 lines
6.5 KiB
JavaScript
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.
/**
* 应用保留投影及固定版ReactFlow编译扩展测试。既检查业务数据不被标记污染,也截取实际依赖源码在VM中执行可见性/选择selector。
* VM以简化Hook/几何服务替代浏览器,只验证编译补丁与选择逻辑,不能代表真实DOM、布局和交互端到端验证。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {createRequire} from 'node:module';
import {runInNewContext} from 'node:vm';
import {createRetainedProjection} from '../../src/components/canvas/retained-projection.ts';
// 通过createRequire加载CommonJS loader,直接处理已安装包的两种ESM入口,避免测试只验证手写仿制字符串。
const require=createRequire(import.meta.url),transform=require('../../scripts/loaders/xyflow-retained.cjs');
// 保留仅浅复制被点名的行,嵌套data/position共享且原对象未写标记;无保留时返回原数组,新源对象不复用旧副本。
test('retention keeps the complete native graph, preserves business data and releases flags after the owner ends',()=>{
const project=createRetainedProjection(),a=Object.freeze({id:'a',dragging:false,data:Object.freeze({text:'draft'}),position:{x:2000,y:0}}),b={id:'b'},edge={id:'edge',source:'a',target:'b'},nodes=[a,b],edges=[edge];
const retained=project(nodes,edges,new Set(['a','edge']));
assert.equal(retained.nodes.length,2);assert.equal(retained.edges.length,1);assert.equal(retained.nodes[0].__endoleapRetain,true);assert.equal(retained.edges[0].__endoleapRetain,true);
assert.equal(retained.nodes[1],b);assert.equal(retained.nodes[0].data,a.data);assert.equal(retained.nodes[0].position,a.position);assert.equal(retained.nodes[0].dragging,false);assert.equal(a.__endoleapRetain,undefined);
assert.equal(project(nodes,edges,new Set(['a','edge'])),retained);
const released=project(nodes,edges,new Set());assert.equal(released.nodes,nodes);assert.equal(released.edges,edges);
const restored={...a,data:{text:'new incarnation'}},next=project([restored,b],edges,new Set(['a']));assert.notEqual(next.nodes[0],retained.nodes[0]);assert.equal(next.nodes[0].data,restored.data);
});
// 分别处理index.js/mjs,保留未hidden显式节点与边,关闭裁剪仍返完整图;源码锚点改动必须拒绝转换。
test('pinned xyflow compiler extension retains only explicit offscreen owners and keeps native visibility',async()=>{
for(const file of ['index.js','index.mjs']){
const source=await readFile(new URL('../../node_modules/@xyflow/react/dist/esm/'+file,import.meta.url),'utf8'),code=transform(source);
// 固定源码锚点定位依赖函数,后面的锚点篡改用例确保依赖结构变化时拒绝套用补丁。
const start=code.indexOf('const selector$c = '),end=code.indexOf('\n};',start)+3;
// 仅执行修改后的节点selector,getNodesInside固定返回原生可见节点,便于验证额外保留集合。
const select=runInNewContext(code.slice(start,end)+'; selector$c', {getNodesInside:()=>[{id:'visible'}]});
const nodes=[{id:'visible'},{id:'pinned',__endoleapRetain:true},{id:'other'},{id:'hidden',__endoleapRetain:true,hidden:true}],state={nodes,nodeLookup:new Map(nodes.map(node=>[node.id,node])),width:100,height:100,transform:[0,0,1]};
assert.deepEqual([...select(true)(state)],['visible','pinned']);assert.deepEqual([...select(false)(state)],nodes.map(node=>node.id));assert.equal(state.nodeLookup.size,4);
const edgeStart=code.indexOf('function useVisibleEdgeIds('),edgeEnd=code.indexOf('\n}',edgeStart)+2;
const edges=[{id:'near',source:'visible',target:'other'},{id:'editing',source:'pinned',target:'other',__endoleapRetain:true},{id:'offscreen',source:'other',target:'pinned'}];
// 用简化useStore/useCallback执行边可见ID函数;原生可见near与显式保留editing都要在,offscreen不应在。
const result=runInNewContext(code.slice(edgeStart,edgeEnd)+'; useVisibleEdgeIds(true)',{useCallback:fn=>fn,useStore:fn=>fn({...state,edges}),shallow:()=>{},isEdgeVisible:({sourceNode})=>sourceNode.id==='visible'});
assert.deepEqual([...result],['near','editing']);
assert.throws(()=>transform(source.replace('const selector$c =','const changedSelector =').replace('getNodesInside(s.nodeLookup,','changedVisibility(s.nodeLookup,')),/Unsupported xyflow/);
}
});
// 空监听数组不扫描也不运行effect,hook订阅或callback prop存在才扫描;返回userNode原引用而非内部包装。
test('native selection scanning stays dormant without listeners and still serves hooks and the callback prop',async()=>{
for(const file of ['index.js','index.mjs']){
const source=await readFile(new URL('../../node_modules/@xyflow/react/dist/esm/'+file,import.meta.url),'utf8'),code=transform(source);
const start=code.indexOf('const selector$l = '),end=code.indexOf('\n}',code.indexOf('function SelectionListener({',start))+2;
let scans=0,effects=0;const callbacks=[],userNode={id:'selected',position:{x:0,y:0},data:{title:'original payload'},selected:true},node={...userNode,internals:{userNode}},edge={id:'edge',source:'selected',target:'other',selected:true};
// 生成器每次开始遍历都计一次;nodes/edges 各扫描一遍会增加 2,不能把无监听场景混入这个成本。
const lookup=value=>({*[Symbol.iterator](){scans++;yield[value.id,value];}}),state={nodeLookup:lookup(node),edgeLookup:lookup(edge),onSelectionChangeHandlers:[]};
// 把React Hook替为同步桩并计数扫描/副作用;jsx直接调用组件,仍使用依赖源码中的实际选择逻辑。
const render=runInNewContext(code.slice(start,end)+'; SelectionListener',{useStore:selector=>selector(state),useStoreApi:()=>({getState:()=>state}),useEffect:callback=>{effects++;callback();},jsx:(Component,props)=>Component(props),shallow:(a,b)=>JSON.stringify(a)===JSON.stringify(b)});
assert.equal(render({}),null);assert.equal(scans,0);assert.equal(effects,0);
state.onSelectionChangeHandlers=[value=>callbacks.push(value)];render({});assert.equal(scans,2);assert.deepEqual([...callbacks[0].nodes].map(node=>node.id),['selected']);assert.deepEqual([...callbacks[0].edges].map(edge=>edge.id),['edge']);assert.equal(callbacks[0].nodes[0],userNode);assert.equal(callbacks[0].edges[0],edge);
state.onSelectionChangeHandlers=[];render({});assert.equal(scans,2,'unsubscribed hook kept scanning');
let prop;render({onSelectionChange:value=>{prop=value;}});assert.equal(scans,4);assert.deepEqual([...prop.nodes].map(node=>node.id),['selected']);assert.equal(prop.nodes[0],userNode);
}
});