|
| 1 | +package gvisual; |
| 2 | + |
| 3 | +import edu.uci.ics.jung.graph.Graph; |
| 4 | +import edu.uci.ics.jung.graph.UndirectedSparseGraph; |
| 5 | +import org.junit.Before; |
| 6 | +import org.junit.Test; |
| 7 | + |
| 8 | +import java.util.*; |
| 9 | + |
| 10 | +import static org.junit.Assert.*; |
| 11 | + |
| 12 | +/** |
| 13 | + * Unit tests for {@link SpectralLayout}. |
| 14 | + * |
| 15 | + * <p>The layout is computed by inverse iteration on a shifted Laplacian, so |
| 16 | + * exact eigenvector entries are not stable across runs — but a number of |
| 17 | + * properties <em>are</em>:</p> |
| 18 | + * <ul> |
| 19 | + * <li>All vertices receive coordinates inside [padding, canvas - padding].</li> |
| 20 | + * <li>The result is deterministic for a fixed seed and disabled jitter.</li> |
| 21 | + * <li>Trivial cases (n=0, 1, 2) follow documented fallbacks.</li> |
| 22 | + * <li>For a clearly bipartite or two-community graph, the Fiedler axis |
| 23 | + * separates the two communities (so we can assert their projected |
| 24 | + * centroids are distinct).</li> |
| 25 | + * <li>{@link SpectralLayout#qualityMetrics(Graph)} returns the documented |
| 26 | + * set of keys and self-consistent values (stress >= 0, etc.).</li> |
| 27 | + * <li>SVG output contains one {@code <circle>} per vertex and one |
| 28 | + * {@code <line>} per edge, escapes special characters in labels, |
| 29 | + * and uses the configured canvas size.</li> |
| 30 | + * </ul> |
| 31 | + */ |
| 32 | +public class SpectralLayoutTest { |
| 33 | + |
| 34 | + private Graph<String, Edge> graph; |
| 35 | + |
| 36 | + @Before |
| 37 | + public void setUp() { |
| 38 | + graph = new UndirectedSparseGraph<>(); |
| 39 | + } |
| 40 | + |
| 41 | + private void addEdge(String a, String b) { |
| 42 | + if (!graph.containsVertex(a)) graph.addVertex(a); |
| 43 | + if (!graph.containsVertex(b)) graph.addVertex(b); |
| 44 | + graph.addEdge(new Edge("f", a, b), a, b); |
| 45 | + } |
| 46 | + |
| 47 | + // ── Trivial cases ────────────────────────────────────────────────── |
| 48 | + |
| 49 | + @Test |
| 50 | + public void emptyGraph_producesNoPositions() { |
| 51 | + SpectralLayout layout = new SpectralLayout().compute(graph); |
| 52 | + assertTrue(layout.getXPositions().isEmpty()); |
| 53 | + assertTrue(layout.getYPositions().isEmpty()); |
| 54 | + } |
| 55 | + |
| 56 | + @Test |
| 57 | + public void singleVertex_placedAtCanvasCentre() { |
| 58 | + graph.addVertex("only"); |
| 59 | + SpectralLayout layout = new SpectralLayout() |
| 60 | + .canvasWidth(400).canvasHeight(300) |
| 61 | + .compute(graph); |
| 62 | + |
| 63 | + assertEquals(200.0, layout.getX("only"), 1e-9); |
| 64 | + assertEquals(150.0, layout.getY("only"), 1e-9); |
| 65 | + } |
| 66 | + |
| 67 | + @Test |
| 68 | + public void twoVertices_placedOnHorizontalAxis() { |
| 69 | + graph.addVertex("a"); |
| 70 | + graph.addVertex("b"); |
| 71 | + SpectralLayout layout = new SpectralLayout() |
| 72 | + .canvasWidth(400).canvasHeight(300).padding(20) |
| 73 | + .compute(graph); |
| 74 | + |
| 75 | + // The two-node case is documented to place them at the left/right |
| 76 | + // padding on the horizontal midline. |
| 77 | + assertEquals(20.0, layout.getX("a"), 1e-9); |
| 78 | + assertEquals(380.0, layout.getX("b"), 1e-9); |
| 79 | + assertEquals(150.0, layout.getY("a"), 1e-9); |
| 80 | + assertEquals(150.0, layout.getY("b"), 1e-9); |
| 81 | + } |
| 82 | + |
| 83 | + @Test |
| 84 | + public void unknownVertex_returnsZero() { |
| 85 | + graph.addVertex("only"); |
| 86 | + SpectralLayout layout = new SpectralLayout().compute(graph); |
| 87 | + assertEquals(0.0, layout.getX("ghost"), 0.0); |
| 88 | + assertEquals(0.0, layout.getY("ghost"), 0.0); |
| 89 | + } |
| 90 | + |
| 91 | + // ── Bounds and determinism ───────────────────────────────────────── |
| 92 | + |
| 93 | + @Test |
| 94 | + public void allCoordinatesFallWithinPaddedCanvas() { |
| 95 | + // Path graph of 6 nodes — non-degenerate, exercises full pipeline. |
| 96 | + for (int i = 0; i < 5; i++) addEdge("v" + i, "v" + (i + 1)); |
| 97 | + |
| 98 | + double w = 500, h = 400, pad = 30; |
| 99 | + SpectralLayout layout = new SpectralLayout() |
| 100 | + .canvasWidth(w).canvasHeight(h).padding(pad) |
| 101 | + .jitter(false) |
| 102 | + .compute(graph); |
| 103 | + |
| 104 | + assertEquals(6, layout.getXPositions().size()); |
| 105 | + for (String v : layout.getXPositions().keySet()) { |
| 106 | + double x = layout.getX(v); |
| 107 | + double y = layout.getY(v); |
| 108 | + assertTrue("x out of bounds for " + v + ": " + x, |
| 109 | + x >= pad - 1e-6 && x <= w - pad + 1e-6); |
| 110 | + assertTrue("y out of bounds for " + v + ": " + y, |
| 111 | + y >= pad - 1e-6 && y <= h - pad + 1e-6); |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + @Test |
| 116 | + public void sameSeedAndJitterOff_isDeterministic() { |
| 117 | + // Small cycle to avoid degenerate eigenvalues. |
| 118 | + addEdge("a", "b"); addEdge("b", "c"); addEdge("c", "d"); addEdge("d", "a"); |
| 119 | + |
| 120 | + SpectralLayout l1 = new SpectralLayout().jitter(false).seed(7).compute(graph); |
| 121 | + SpectralLayout l2 = new SpectralLayout().jitter(false).seed(7).compute(graph); |
| 122 | + |
| 123 | + for (String v : l1.getXPositions().keySet()) { |
| 124 | + assertEquals("X mismatch for " + v, l1.getX(v), l2.getX(v), 1e-9); |
| 125 | + assertEquals("Y mismatch for " + v, l1.getY(v), l2.getY(v), 1e-9); |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + // ── Structural: two communities ──────────────────────────────────── |
| 130 | + |
| 131 | + @Test |
| 132 | + public void twoCliquesJoinedByOneEdge_separateAlongFiedlerAxis() { |
| 133 | + // Two K_3 cliques bridged by a single edge. Spectral layout's |
| 134 | + // 2nd eigenvector should put one clique on each side of the bridge. |
| 135 | + addEdge("a", "b"); addEdge("b", "c"); addEdge("a", "c"); |
| 136 | + addEdge("x", "y"); addEdge("y", "z"); addEdge("x", "z"); |
| 137 | + addEdge("c", "x"); // bridge |
| 138 | + |
| 139 | + SpectralLayout layout = new SpectralLayout() |
| 140 | + .canvasWidth(1000).canvasHeight(1000) |
| 141 | + .padding(50) |
| 142 | + .jitter(false) |
| 143 | + .seed(1) |
| 144 | + .compute(graph); |
| 145 | + |
| 146 | + double leftCentroid = (layout.getX("a") + layout.getX("b") + layout.getX("c")) / 3.0; |
| 147 | + double rightCentroid = (layout.getX("x") + layout.getX("y") + layout.getX("z")) / 3.0; |
| 148 | + |
| 149 | + // The two cliques should land in distinguishable positions on the |
| 150 | + // primary axis; we don't care which side is which. |
| 151 | + assertTrue("Centroids should separate the two communities along X, got " |
| 152 | + + leftCentroid + " vs " + rightCentroid, |
| 153 | + Math.abs(leftCentroid - rightCentroid) > 50.0); |
| 154 | + } |
| 155 | + |
| 156 | + // ── Quality metrics ──────────────────────────────────────────────── |
| 157 | + |
| 158 | + @Test |
| 159 | + public void qualityMetrics_returnsExpectedKeysAndSanityValues() { |
| 160 | + for (int i = 0; i < 4; i++) addEdge("v" + i, "v" + (i + 1)); |
| 161 | + |
| 162 | + SpectralLayout layout = new SpectralLayout().jitter(false).compute(graph); |
| 163 | + Map<String, Double> metrics = layout.qualityMetrics(graph); |
| 164 | + |
| 165 | + assertTrue("missing key stress", metrics.containsKey("stress")); |
| 166 | + assertTrue("missing key edgeCount", metrics.containsKey("edgeCount")); |
| 167 | + assertTrue("missing key edgeLengthMean", metrics.containsKey("edgeLengthMean")); |
| 168 | + assertTrue("missing key edgeLengthStdDev", metrics.containsKey("edgeLengthStdDev")); |
| 169 | + assertTrue("missing key edgeLengthUniformity", |
| 170 | + metrics.containsKey("edgeLengthUniformity")); |
| 171 | + |
| 172 | + assertEquals(graph.getEdgeCount(), metrics.get("edgeCount").intValue()); |
| 173 | + assertTrue("stress should be non-negative", metrics.get("stress") >= 0); |
| 174 | + assertTrue("mean edge length should be > 0", metrics.get("edgeLengthMean") > 0); |
| 175 | + assertTrue("stddev should be non-negative", metrics.get("edgeLengthStdDev") >= 0); |
| 176 | + // Uniformity is 1 - cv. For non-zero mean it must be <= 1. |
| 177 | + assertTrue("uniformity must be <= 1", metrics.get("edgeLengthUniformity") <= 1.0 + 1e-9); |
| 178 | + } |
| 179 | + |
| 180 | + @Test |
| 181 | + public void qualityMetrics_onEmptyGraphReportsZeroEdges() { |
| 182 | + // Two isolated vertices — no edges. |
| 183 | + graph.addVertex("a"); graph.addVertex("b"); graph.addVertex("c"); |
| 184 | + SpectralLayout layout = new SpectralLayout().jitter(false).compute(graph); |
| 185 | + Map<String, Double> metrics = layout.qualityMetrics(graph); |
| 186 | + |
| 187 | + assertEquals(0.0, metrics.get("stress"), 1e-12); |
| 188 | + assertEquals(0, metrics.get("edgeCount").intValue()); |
| 189 | + assertFalse("no edge-length stats when there are no edges", |
| 190 | + metrics.containsKey("edgeLengthMean")); |
| 191 | + } |
| 192 | + |
| 193 | + // ── SVG export ───────────────────────────────────────────────────── |
| 194 | + |
| 195 | + @Test |
| 196 | + public void svgExport_containsOneCirclePerNodeAndOneLinePerEdge() { |
| 197 | + addEdge("a", "b"); addEdge("b", "c"); |
| 198 | + |
| 199 | + SpectralLayout layout = new SpectralLayout() |
| 200 | + .canvasWidth(640).canvasHeight(480) |
| 201 | + .jitter(false) |
| 202 | + .compute(graph); |
| 203 | + |
| 204 | + String svg = layout.toSvg(graph); |
| 205 | + assertNotNull(svg); |
| 206 | + assertTrue("missing <svg> root", svg.startsWith("<svg")); |
| 207 | + assertTrue("missing canvas width", svg.contains("width=\"640\"")); |
| 208 | + assertTrue("missing canvas height", svg.contains("height=\"480\"")); |
| 209 | + |
| 210 | + assertEquals("one <circle> per vertex", |
| 211 | + graph.getVertexCount(), countOccurrences(svg, "<circle")); |
| 212 | + assertEquals("one <line> per edge", |
| 213 | + graph.getEdgeCount(), countOccurrences(svg, "<line")); |
| 214 | + assertTrue("missing </svg> close", svg.trim().endsWith("</svg>")); |
| 215 | + } |
| 216 | + |
| 217 | + @Test |
| 218 | + public void svgExport_escapesSpecialCharactersInLabels() { |
| 219 | + graph.addVertex("a&b<c>"); |
| 220 | + graph.addVertex("plain"); |
| 221 | + graph.addEdge(new Edge("f", "a&b<c>", "plain"), "a&b<c>", "plain"); |
| 222 | + |
| 223 | + SpectralLayout layout = new SpectralLayout().jitter(false).compute(graph); |
| 224 | + String svg = layout.toSvg(graph); |
| 225 | + |
| 226 | + assertFalse("raw '<' from label must be escaped", |
| 227 | + svg.contains(">a&b<c><")); |
| 228 | + assertTrue("expected XML-escaped label", |
| 229 | + svg.contains("a&b<c>")); |
| 230 | + } |
| 231 | + |
| 232 | + @Test |
| 233 | + public void toString_listsEveryVertex() { |
| 234 | + addEdge("a", "b"); addEdge("b", "c"); |
| 235 | + SpectralLayout layout = new SpectralLayout().jitter(false).compute(graph); |
| 236 | + String s = layout.toString(); |
| 237 | + assertTrue(s.contains("a")); |
| 238 | + assertTrue(s.contains("b")); |
| 239 | + assertTrue(s.contains("c")); |
| 240 | + assertTrue(s.startsWith("SpectralLayout")); |
| 241 | + } |
| 242 | + |
| 243 | + // ── Builder-style setters round-trip ─────────────────────────────── |
| 244 | + |
| 245 | + @Test |
| 246 | + public void buildersReturnSameInstanceForChaining() { |
| 247 | + SpectralLayout layout = new SpectralLayout(); |
| 248 | + assertSame(layout, layout.canvasWidth(123)); |
| 249 | + assertSame(layout, layout.canvasHeight(456)); |
| 250 | + assertSame(layout, layout.padding(7)); |
| 251 | + assertSame(layout, layout.jitter(false)); |
| 252 | + assertSame(layout, layout.seed(99)); |
| 253 | + } |
| 254 | + |
| 255 | + @Test |
| 256 | + public void positionMapsAreUnmodifiable() { |
| 257 | + graph.addVertex("only"); |
| 258 | + SpectralLayout layout = new SpectralLayout().compute(graph); |
| 259 | + try { |
| 260 | + layout.getXPositions().put("evil", 0.0); |
| 261 | + fail("expected UnsupportedOperationException"); |
| 262 | + } catch (UnsupportedOperationException expected) { /* OK */ } |
| 263 | + try { |
| 264 | + layout.getYPositions().put("evil", 0.0); |
| 265 | + fail("expected UnsupportedOperationException"); |
| 266 | + } catch (UnsupportedOperationException expected) { /* OK */ } |
| 267 | + } |
| 268 | + |
| 269 | + // ── helpers ──────────────────────────────────────────────────────── |
| 270 | + |
| 271 | + private static int countOccurrences(String haystack, String needle) { |
| 272 | + int count = 0, idx = 0; |
| 273 | + while ((idx = haystack.indexOf(needle, idx)) != -1) { |
| 274 | + count++; |
| 275 | + idx += needle.length(); |
| 276 | + } |
| 277 | + return count; |
| 278 | + } |
| 279 | +} |
0 commit comments