|
| 1 | +import { describe, expect, it } from 'vitest'; |
| 2 | +import type { LayoutData } from '../../types.js'; |
| 3 | +import { structuralFallbackVerdict } from './structuralFallback.js'; |
| 4 | + |
| 5 | +/** A graph from an edge list, with every endpoint promoted to a plain node. */ |
| 6 | +function graph(edges: [string, string][], extraNodes: string[] = []): LayoutData { |
| 7 | + const ids = new Set<string>(extraNodes); |
| 8 | + for (const [a, b] of edges) { |
| 9 | + ids.add(a); |
| 10 | + ids.add(b); |
| 11 | + } |
| 12 | + return { |
| 13 | + nodes: [...ids].map((id) => ({ id, isGroup: false })), |
| 14 | + edges: edges.map(([start, end], index) => ({ id: `e${index}`, start, end })), |
| 15 | + config: {}, |
| 16 | + } as unknown as LayoutData; |
| 17 | +} |
| 18 | + |
| 19 | +function withSubgraphs(count: number, edges: [string, string][] = []): LayoutData { |
| 20 | + const data = graph(edges); |
| 21 | + for (let index = 0; index < count; index++) { |
| 22 | + (data.nodes as unknown[]).push({ id: `sub${index}`, isGroup: true }); |
| 23 | + } |
| 24 | + return data; |
| 25 | +} |
| 26 | + |
| 27 | +/** A path, which is planar, low-degree, and has no groups — the happy shape. */ |
| 28 | +function path(length: number): [string, string][] { |
| 29 | + return Array.from({ length }, (_, index) => [`n${index}`, `n${index + 1}`]); |
| 30 | +} |
| 31 | + |
| 32 | +describe('structuralFallbackVerdict', () => { |
| 33 | + describe('only applies to the grid-like layouts', () => { |
| 34 | + // K5 is non-planar, so it would be redirected if the layout were in scope. |
| 35 | + const k5: [string, string][] = [ |
| 36 | + ['a', 'b'], |
| 37 | + ['a', 'c'], |
| 38 | + ['a', 'd'], |
| 39 | + ['a', 'e'], |
| 40 | + ['b', 'c'], |
| 41 | + ['b', 'd'], |
| 42 | + ['b', 'e'], |
| 43 | + ['c', 'd'], |
| 44 | + ['c', 'e'], |
| 45 | + ['d', 'e'], |
| 46 | + ]; |
| 47 | + |
| 48 | + it.each(['hola', 'gipsec'])('considers %s', (layout) => { |
| 49 | + expect(structuralFallbackVerdict(graph(k5), layout).fallback).toBe(true); |
| 50 | + }); |
| 51 | + |
| 52 | + it.each(['dagre', 'elk', 'swimlane', 'domus', 'ipsep-cola', ''])( |
| 53 | + 'leaves %s alone', |
| 54 | + (layout) => { |
| 55 | + expect(structuralFallbackVerdict(graph(k5), layout)).toEqual({ fallback: false }); |
| 56 | + } |
| 57 | + ); |
| 58 | + }); |
| 59 | + |
| 60 | + describe('non-planar', () => { |
| 61 | + it('redirects K5', () => { |
| 62 | + const k5: [string, string][] = []; |
| 63 | + for (const a of ['a', 'b', 'c', 'd', 'e']) { |
| 64 | + for (const b of ['a', 'b', 'c', 'd', 'e']) { |
| 65 | + if (a < b) { |
| 66 | + k5.push([a, b]); |
| 67 | + } |
| 68 | + } |
| 69 | + } |
| 70 | + // Degree 4 throughout and only 5 nodes, so neither other rule can fire. |
| 71 | + const verdict = structuralFallbackVerdict(graph(k5), 'hola'); |
| 72 | + expect(verdict.fallback).toBe(true); |
| 73 | + expect(verdict.reason).toBe('non-planar'); |
| 74 | + }); |
| 75 | + |
| 76 | + it('redirects K3,3', () => { |
| 77 | + const k33: [string, string][] = []; |
| 78 | + for (const a of ['a1', 'a2', 'a3']) { |
| 79 | + for (const b of ['b1', 'b2', 'b3']) { |
| 80 | + k33.push([a, b]); |
| 81 | + } |
| 82 | + } |
| 83 | + const verdict = structuralFallbackVerdict(graph(k33), 'gipsec'); |
| 84 | + expect(verdict.fallback).toBe(true); |
| 85 | + expect(verdict.reason).toBe('non-planar'); |
| 86 | + }); |
| 87 | + |
| 88 | + it('keeps a planar graph', () => { |
| 89 | + expect(structuralFallbackVerdict(graph(path(5)), 'hola')).toEqual({ fallback: false }); |
| 90 | + }); |
| 91 | + }); |
| 92 | + |
| 93 | + describe('subgraph count', () => { |
| 94 | + it('keeps 4', () => { |
| 95 | + expect(structuralFallbackVerdict(withSubgraphs(4, path(3)), 'hola')).toEqual({ |
| 96 | + fallback: false, |
| 97 | + }); |
| 98 | + }); |
| 99 | + |
| 100 | + it('redirects 5', () => { |
| 101 | + const verdict = structuralFallbackVerdict(withSubgraphs(5, path(3)), 'hola'); |
| 102 | + expect(verdict.fallback).toBe(true); |
| 103 | + expect(verdict.reason).toBe('too-many-subgraphs'); |
| 104 | + expect(verdict.detail).toContain('5 subgraphs'); |
| 105 | + }); |
| 106 | + |
| 107 | + it('counts only group nodes, not every node', () => { |
| 108 | + // 12 plain nodes, no groups: the subgraph rule must not fire on them. |
| 109 | + const verdict = structuralFallbackVerdict(graph(path(11)), 'hola'); |
| 110 | + expect(verdict.reason).not.toBe('too-many-subgraphs'); |
| 111 | + }); |
| 112 | + }); |
| 113 | + |
| 114 | + describe('large graph with a high-degree node', () => { |
| 115 | + /** A star of `spokes` leaves around `hub`, padded to `total` nodes by a path. */ |
| 116 | + function hubAndPadding(spokes: number, total: number): LayoutData { |
| 117 | + const edges: [string, string][] = Array.from({ length: spokes }, (_, index) => [ |
| 118 | + 'hub', |
| 119 | + `s${index}`, |
| 120 | + ]); |
| 121 | + const padding = Array.from({ length: total }, (_, index) => `pad${index}`); |
| 122 | + return graph(edges, padding); |
| 123 | + } |
| 124 | + |
| 125 | + it('redirects >10 nodes with a degree-5 node', () => { |
| 126 | + const verdict = structuralFallbackVerdict(hubAndPadding(5, 10), 'hola'); |
| 127 | + expect(verdict.fallback).toBe(true); |
| 128 | + expect(verdict.reason).toBe('large-with-high-degree-node'); |
| 129 | + expect(verdict.detail).toContain('degree 5'); |
| 130 | + }); |
| 131 | + |
| 132 | + it('keeps a degree-5 node in a small graph', () => { |
| 133 | + // 6 nodes: the hub is busy but there is nothing to detour around. |
| 134 | + expect(structuralFallbackVerdict(hubAndPadding(5, 0), 'hola')).toEqual({ fallback: false }); |
| 135 | + }); |
| 136 | + |
| 137 | + it('keeps a large graph whose nodes all stay under degree 5', () => { |
| 138 | + // A 20-node path: every interior node is degree 2. |
| 139 | + expect(structuralFallbackVerdict(graph(path(19)), 'hola')).toEqual({ fallback: false }); |
| 140 | + }); |
| 141 | + |
| 142 | + it('needs more than 10 nodes, not exactly 10', () => { |
| 143 | + // hub + 5 spokes + 4 padding = 10 nodes exactly, so the rule must not fire. |
| 144 | + expect(structuralFallbackVerdict(hubAndPadding(5, 4), 'hola')).toEqual({ fallback: false }); |
| 145 | + }); |
| 146 | + }); |
| 147 | + |
| 148 | + describe('adjacency construction', () => { |
| 149 | + it('ignores self-loops when counting degree', () => { |
| 150 | + const data = graph([ |
| 151 | + ['hub', 's0'], |
| 152 | + ['hub', 's1'], |
| 153 | + ['hub', 's2'], |
| 154 | + ['hub', 's3'], |
| 155 | + ]); |
| 156 | + // Four real spokes plus four self-loops would reach degree 5 only if |
| 157 | + // self-loops counted; they must not. |
| 158 | + for (let index = 0; index < 4; index++) { |
| 159 | + (data.edges as unknown[]).push({ id: `loop${index}`, start: 'hub', end: 'hub' }); |
| 160 | + } |
| 161 | + for (let index = 0; index < 8; index++) { |
| 162 | + (data.nodes as unknown[]).push({ id: `pad${index}`, isGroup: false }); |
| 163 | + } |
| 164 | + expect(structuralFallbackVerdict(data, 'hola')).toEqual({ fallback: false }); |
| 165 | + }); |
| 166 | + |
| 167 | + it('counts parallel edges once', () => { |
| 168 | + const data = graph([['hub', 's0']]); |
| 169 | + for (let index = 0; index < 6; index++) { |
| 170 | + (data.edges as unknown[]).push({ id: `dup${index}`, start: 'hub', end: 's0' }); |
| 171 | + } |
| 172 | + for (let index = 0; index < 12; index++) { |
| 173 | + (data.nodes as unknown[]).push({ id: `pad${index}`, isGroup: false }); |
| 174 | + } |
| 175 | + // Seven edges between the same pair is still degree 1. |
| 176 | + expect(structuralFallbackVerdict(data, 'hola')).toEqual({ fallback: false }); |
| 177 | + }); |
| 178 | + |
| 179 | + it('survives an empty graph', () => { |
| 180 | + expect( |
| 181 | + structuralFallbackVerdict( |
| 182 | + { nodes: [], edges: [], config: {} } as unknown as LayoutData, |
| 183 | + 'hola' |
| 184 | + ) |
| 185 | + ).toEqual({ fallback: false }); |
| 186 | + }); |
| 187 | + |
| 188 | + it('survives missing nodes and edges', () => { |
| 189 | + expect(structuralFallbackVerdict({} as unknown as LayoutData, 'hola')).toEqual({ |
| 190 | + fallback: false, |
| 191 | + }); |
| 192 | + }); |
| 193 | + |
| 194 | + it('skips an edge pointing at an unknown node', () => { |
| 195 | + const data = graph(path(3)); |
| 196 | + (data.edges as unknown[]).push({ id: 'dangling', start: 'n0', end: 'nowhere' }); |
| 197 | + expect(structuralFallbackVerdict(data, 'hola')).toEqual({ fallback: false }); |
| 198 | + }); |
| 199 | + }); |
| 200 | +}); |
0 commit comments