import { fromJS, Map } from 'immutable'; const makeMap = Map; describe('NodesLayout', () => { const NodesLayout = require('../nodes-layout'); function getNodeCoordinates(nodes) { const coords = []; nodes .sortBy(node => node.get('id')) .forEach(node => { coords.push(node.get('x')); coords.push(node.get('y')); }); return coords; } let options; let nodes; let coords; let resultCoords; const nodeSets = { initial4: { nodes: fromJS({ n1: {id: 'n1'}, n2: {id: 'n2'}, n3: {id: 'n3'}, n4: {id: 'n4'} }), edges: fromJS({ 'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'}, 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'}, 'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'} }) }, rank4: { nodes: fromJS({ n1: {id: 'n1', rank: 'A'}, n2: {id: 'n2', rank: 'A'}, n3: {id: 'n3', rank: 'B'}, n4: {id: 'n4', rank: 'B'} }), edges: fromJS({ 'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'}, 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'}, 'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'} }) }, rank6: { nodes: fromJS({ n1: {id: 'n1', rank: 'A'}, n2: {id: 'n2', rank: 'A'}, n3: {id: 'n3', rank: 'B'}, n4: {id: 'n4', rank: 'B'}, n5: {id: 'n5', rank: 'A'}, n6: {id: 'n6', rank: 'B'}, }), edges: fromJS({ 'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'}, 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'}, 'n1-n5': {id: 'n1-n5', source: 'n1', target: 'n5'}, 'n2-n4': {id: 'n2-n4', source: 'n2', target: 'n4'}, 'n2-n6': {id: 'n2-n6', source: 'n2', target: 'n6'}, }) }, removeEdge24: { nodes: fromJS({ n1: {id: 'n1'}, n2: {id: 'n2'}, n3: {id: 'n3'}, n4: {id: 'n4'} }), edges: fromJS({ 'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'}, 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'} }) }, removeNode2: { nodes: fromJS({ n1: {id: 'n1'}, n3: {id: 'n3'}, n4: {id: 'n4'} }), edges: fromJS({ 'n1-n3': {id: 'n1-n3', source: 'n1', target: 'n3'}, 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'} }) }, removeNode23: { nodes: fromJS({ n1: {id: 'n1'}, n4: {id: 'n4'} }), edges: fromJS({ 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'} }) }, single3: { nodes: fromJS({ n1: {id: 'n1'}, n2: {id: 'n2'}, n3: {id: 'n3'} }), edges: fromJS({}) }, singlePortrait: { nodes: fromJS({ n1: {id: 'n1'}, n2: {id: 'n2'}, n3: {id: 'n3'}, n4: {id: 'n4'}, n5: {id: 'n5'} }), edges: fromJS({ 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'} }) }, singlePortrait6: { nodes: fromJS({ n1: {id: 'n1'}, n2: {id: 'n2'}, n3: {id: 'n3'}, n4: {id: 'n4'}, n5: {id: 'n5'}, n6: {id: 'n6'} }), edges: fromJS({ 'n1-n4': {id: 'n1-n4', source: 'n1', target: 'n4'} }) } }; beforeEach(() => { // clear feature flags window.localStorage.clear(); options = { nodeCache: makeMap(), edgeCache: makeMap() }; }); it('detects unseen nodes', () => { const set1 = fromJS({ n1: {id: 'n1'} }); const set12 = fromJS({ n1: {id: 'n1'}, n2: {id: 'n2'} }); const set13 = fromJS({ n1: {id: 'n1'}, n3: {id: 'n3'} }); let hasUnseen; hasUnseen = NodesLayout.hasUnseenNodes(set12, set1); expect(hasUnseen).toBeTruthy(); hasUnseen = NodesLayout.hasUnseenNodes(set13, set1); expect(hasUnseen).toBeTruthy(); hasUnseen = NodesLayout.hasUnseenNodes(set1, set12); expect(hasUnseen).toBeFalsy(); hasUnseen = NodesLayout.hasUnseenNodes(set1, set13); expect(hasUnseen).toBeFalsy(); hasUnseen = NodesLayout.hasUnseenNodes(set12, set13); expect(hasUnseen).toBeTruthy(); }); it('shifts layouts to center', () => { let xMin; let xMax; let yMin; let yMax; let xCenter; let yCenter; // make sure initial layout is centered const original = NodesLayout.doLayout( nodeSets.initial4.nodes, nodeSets.initial4.edges ); xMin = original.nodes.minBy(n => n.get('x')); xMax = original.nodes.maxBy(n => n.get('x')); yMin = original.nodes.minBy(n => n.get('y')); yMax = original.nodes.maxBy(n => n.get('y')); xCenter = (xMin.get('x') + xMax.get('x')) / 2; yCenter = (yMin.get('y') + yMax.get('y')) / 2; expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2); expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2); // make sure re-running is idempotent const rerun = NodesLayout.shiftLayoutToCenter(original); xMin = rerun.nodes.minBy(n => n.get('x')); xMax = rerun.nodes.maxBy(n => n.get('x')); yMin = rerun.nodes.minBy(n => n.get('y')); yMax = rerun.nodes.maxBy(n => n.get('y')); xCenter = (xMin.get('x') + xMax.get('x')) / 2; yCenter = (yMin.get('y') + yMax.get('y')) / 2; expect(xCenter).toEqual(NodesLayout.DEFAULT_WIDTH / 2); expect(yCenter).toEqual(NodesLayout.DEFAULT_HEIGHT / 2); // shift after window was resized const shifted = NodesLayout.shiftLayoutToCenter(original, { width: 128, height: 256 }); xMin = shifted.nodes.minBy(n => n.get('x')); xMax = shifted.nodes.maxBy(n => n.get('x')); yMin = shifted.nodes.minBy(n => n.get('y')); yMax = shifted.nodes.maxBy(n => n.get('y')); xCenter = (xMin.get('x') + xMax.get('x')) / 2; yCenter = (yMin.get('y') + yMax.get('y')) / 2; expect(xCenter).toEqual(128 / 2); expect(yCenter).toEqual(256 / 2); }); it('lays out initial nodeset in a rectangle', () => { const result = NodesLayout.doLayout( nodeSets.initial4.nodes, nodeSets.initial4.edges); // console.log('initial', result.get('nodes')); nodes = result.nodes.toJS(); expect(nodes.n1.x).toBeLessThan(nodes.n2.x); expect(nodes.n1.y).toEqual(nodes.n2.y); expect(nodes.n1.x).toEqual(nodes.n3.x); expect(nodes.n1.y).toBeLessThan(nodes.n3.y); expect(nodes.n3.x).toBeLessThan(nodes.n4.x); expect(nodes.n3.y).toEqual(nodes.n4.y); }); it('keeps nodes in rectangle after removing one edge', () => { let result = NodesLayout.doLayout( nodeSets.initial4.nodes, nodeSets.initial4.edges); options.cachedLayout = result; options.nodeCache = options.nodeCache.merge(result.nodes); options.edgeCache = options.edgeCache.merge(result.edge); coords = getNodeCoordinates(result.nodes); result = NodesLayout.doLayout( nodeSets.removeEdge24.nodes, nodeSets.removeEdge24.edges, options ); nodes = result.nodes.toJS(); // console.log('remove 1 edge', nodes, result); resultCoords = getNodeCoordinates(result.nodes); expect(resultCoords).toEqual(coords); }); it('keeps nodes in rectangle after removed edge reappears', () => { let result = NodesLayout.doLayout( nodeSets.initial4.nodes, nodeSets.initial4.edges); coords = getNodeCoordinates(result.nodes); options.cachedLayout = result; options.nodeCache = options.nodeCache.merge(result.nodes); options.edgeCache = options.edgeCache.merge(result.edge); result = NodesLayout.doLayout( nodeSets.removeEdge24.nodes, nodeSets.removeEdge24.edges, options ); options.cachedLayout = result; options.nodeCache = options.nodeCache.merge(result.nodes); options.edgeCache = options.edgeCache.merge(result.edge); result = NodesLayout.doLayout( nodeSets.initial4.nodes, nodeSets.initial4.edges, options ); nodes = result.nodes.toJS(); // console.log('re-add 1 edge', nodes, result); resultCoords = getNodeCoordinates(result.nodes); expect(resultCoords).toEqual(coords); }); it('keeps nodes in rectangle after node disappears', () => { let result = NodesLayout.doLayout( nodeSets.initial4.nodes, nodeSets.initial4.edges); options.cachedLayout = result; options.nodeCache = options.nodeCache.merge(result.nodes); options.edgeCache = options.edgeCache.merge(result.edge); result = NodesLayout.doLayout( nodeSets.removeNode2.nodes, nodeSets.removeNode2.edges, options ); nodes = result.nodes.toJS(); resultCoords = getNodeCoordinates(result.nodes); expect(resultCoords.slice(0, 2)).toEqual(coords.slice(0, 2)); expect(resultCoords.slice(2, 6)).toEqual(coords.slice(4, 8)); }); it('keeps nodes in rectangle after removed node reappears', () => { let result = NodesLayout.doLayout( nodeSets.initial4.nodes, nodeSets.initial4.edges); nodes = result.nodes.toJS(); coords = getNodeCoordinates(result.nodes); options.cachedLayout = result; options.nodeCache = options.nodeCache.merge(result.nodes); options.edgeCache = options.edgeCache.merge(result.edge); result = NodesLayout.doLayout( nodeSets.removeNode23.nodes, nodeSets.removeNode23.edges, options ); nodes = result.nodes.toJS(); expect(nodes.n1.x).toBeLessThan(nodes.n4.x); expect(nodes.n1.y).toBeLessThan(nodes.n4.y); options.cachedLayout = result; options.nodeCache = options.nodeCache.merge(result.nodes); options.edgeCache = options.edgeCache.merge(result.edge); result = NodesLayout.doLayout( nodeSets.removeNode2.nodes, nodeSets.removeNode2.edges, options ); nodes = result.nodes.toJS(); // console.log('re-add 1 node', nodes); resultCoords = getNodeCoordinates(result.nodes); expect(resultCoords.slice(0, 2)).toEqual(coords.slice(0, 2)); expect(resultCoords.slice(2, 6)).toEqual(coords.slice(4, 8)); }); it('renders single nodes in a square', () => { const result = NodesLayout.doLayout( nodeSets.single3.nodes, nodeSets.single3.edges); nodes = result.nodes.toJS(); expect(nodes.n1.x).toEqual(nodes.n3.x); expect(nodes.n1.y).toEqual(nodes.n2.y); expect(nodes.n1.x).toBeLessThan(nodes.n2.x); expect(nodes.n1.y).toBeLessThan(nodes.n3.y); }); it('renders single nodes next to portrait graph', () => { const result = NodesLayout.doLayout( nodeSets.singlePortrait.nodes, nodeSets.singlePortrait.edges, { noCache: true } ); nodes = result.nodes.toJS(); // first square row on same level as top-most other node expect(nodes.n1.y).toEqual(nodes.n2.y); expect(nodes.n1.y).toEqual(nodes.n3.y); expect(nodes.n4.y).toEqual(nodes.n5.y); // all singles right to other nodes expect(nodes.n1.x).toEqual(nodes.n4.x); expect(nodes.n1.x).toBeLessThan(nodes.n2.x); expect(nodes.n1.x).toBeLessThan(nodes.n3.x); expect(nodes.n1.x).toBeLessThan(nodes.n5.x); expect(nodes.n2.x).toEqual(nodes.n5.x); }); it('renders an additional single node in single nodes group', () => { let result = NodesLayout.doLayout( nodeSets.singlePortrait.nodes, nodeSets.singlePortrait.edges, { noCache: true } ); nodes = result.nodes.toJS(); // first square row on same level as top-most other node expect(nodes.n1.y).toEqual(nodes.n2.y); expect(nodes.n1.y).toEqual(nodes.n3.y); expect(nodes.n4.y).toEqual(nodes.n5.y); // all singles right to other nodes expect(nodes.n1.x).toEqual(nodes.n4.x); expect(nodes.n1.x).toBeLessThan(nodes.n2.x); expect(nodes.n1.x).toBeLessThan(nodes.n3.x); expect(nodes.n1.x).toBeLessThan(nodes.n5.x); expect(nodes.n2.x).toEqual(nodes.n5.x); options.cachedLayout = result; options.nodeCache = options.nodeCache.merge(result.nodes); options.edgeCache = options.edgeCache.merge(result.edge); result = NodesLayout.doLayout( nodeSets.singlePortrait6.nodes, nodeSets.singlePortrait6.edges, options ); nodes = result.nodes.toJS(); expect(nodes.n1.x).toBeLessThan(nodes.n2.x); expect(nodes.n1.x).toBeLessThan(nodes.n3.x); expect(nodes.n1.x).toBeLessThan(nodes.n5.x); expect(nodes.n1.x).toBeLessThan(nodes.n6.x); }); it('adds a new node to existing layout in a line', () => { // feature flag window.localStorage.setItem('scope-experimental:layout-dance', true); let result = NodesLayout.doLayout( nodeSets.rank4.nodes, nodeSets.rank4.edges, { noCache: true } ); nodes = result.nodes.toJS(); coords = getNodeCoordinates(result.nodes); options.cachedLayout = result; options.nodeCache = options.nodeCache.merge(result.nodes); options.edgeCache = options.edgeCache.merge(result.edge); expect(NodesLayout.hasNewNodesOfExistingRank( nodeSets.rank6.nodes, nodeSets.rank6.edges, result.nodes)).toBeTruthy(); result = NodesLayout.doLayout( nodeSets.rank6.nodes, nodeSets.rank6.edges, options ); nodes = result.nodes.toJS(); expect(nodes.n5.x).toBeGreaterThan(nodes.n1.x); expect(nodes.n5.y).toEqual(nodes.n1.y); expect(nodes.n6.x).toBeGreaterThan(nodes.n3.x); expect(nodes.n6.y).toEqual(nodes.n3.y); }); });