Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.

Commit 9293b82

Browse files
test(SpectralLayout): add 14 unit tests covering layout, metrics, SVG
SpectralLayout had no dedicated tests despite being a substantial 460-line component. Add SpectralLayoutTest covering: trivial cases (n=0, 1, 2), coordinate bounds, deterministic output for fixed seed/jitter-off, two-community Fiedler separation, qualityMetrics keys + sanity bounds, edge-free graph metrics, SVG node/edge counts and XML escaping, toString, builder chaining, and unmodifiability of returned position maps. 14 tests, all pass.
1 parent bdf1165 commit 9293b82

1 file changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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 &gt;= 0, etc.).</li>
27+
* <li>SVG output contains one {@code &lt;circle&gt;} per vertex and one
28+
* {@code &lt;line&gt;} 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&amp;b&lt;c&gt;"));
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

Comments
 (0)