disharmonies = new ArrayList<>();
+
+ private ClassCyclesDTO classCycles;
+}
diff --git a/report/src/main/resources/templates/refactor-first-report.mustache b/report/src/main/resources/templates/refactor-first-report.mustache
new file mode 100644
index 00000000..428beaae
--- /dev/null
+++ b/report/src/main/resources/templates/refactor-first-report.mustache
@@ -0,0 +1,444 @@
+
+
+
+
+ Refactor First Report for {{project.name}} {{project.version}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Classes
+
+
+ {{#packageMap.hasEdges}}
+ Packages
+
+
+ {{/packageMap.hasEdges}}
+ {{#hasDisharmonies}}
+ Disharmonies
+
+ {{#disharmonies}}
+ {{title}}
+ {{/disharmonies}}
+
+
+ {{/hasDisharmonies}}
+ {{#classCycles.hasCycles}}
+ Cycles
+
+
+ {{/classCycles.hasCycles}}
+
+
+
+
+{{#project.analysisFailed}}
+
+ Analysis incomplete: RefactorFirst could not finish scanning this project.
+ The absence of findings below does not mean the project is clean.
+
+{{/project.analysisFailed}}
+
+
+
+Show classGraph
+ 3D Popup
+
+Show classGraph 2D
+ Popup
+
+
+
+
+
+ Red lines represent relationships to remove.
+ Red nodes represent classes to remove.
+ Zoom in / out with your mouse wheel and click/move to drag the image.
+ Number of classes: {{classMap.classCount}} Number of relationships: {{classMap.relationshipCount}}
+
+
+{{#classMap.dotThresholdExceeded}}
+ SVG is too big to render quickly
+{{/classMap.dotThresholdExceeded}}
+{{^classMap.dotThresholdExceeded}}
+
+{{/classMap.dotThresholdExceeded}}
+
+
+
+
+
+
+
+{{#classRelationshipsToRemove.hasRelationships}}
+
+ Refactor Starting with Priority 1
+
+ Current Class Cycle Count: {{classRelationshipsToRemove.cycleCount}}
+ Number of Class Relationships to Remove: {{classRelationshipsToRemove.relationshipsToRemoveCount}}
+ Classes with * should be broken apart
+ Removing class relationships below will eliminate class cycles
+
+
+
+
+
+ Class Relationship
+ Priority
+ In Class Cycles
+ Relationship Strength
+ Also Removes Pkg Cycle Relationship
+ In Package Cycles
+
+
+
+ {{#classRelationshipsToRemove.relationships}}
+
+ {{renderedLabel}}
+ {{priority}}
+ {{cycleCount}}
+ {{effortRank}}
+ {{#alsoRemovesPackageRelationship}}
+ true {{/alsoRemovesPackageRelationship}}{{^alsoRemovesPackageRelationship}}
+ false{{/alsoRemovesPackageRelationship}}
+ {{packageCycleCount}}
+
+ {{/classRelationshipsToRemove.relationships}}
+
+
+
+{{/classRelationshipsToRemove.hasRelationships}}
+
+
+
+
+
+
+
+{{#packageMap.hasEdges}}
+
+ Show
+ packageGraph
+ 3D Popup
+
+ Show
+ packageGraph 2D
+ Popup
+
+
+
+
+
+ Red lines represent relationships to remove.
+ Red nodes represent packages to remove.
+ Zoom in / out with your mouse wheel and click/move to drag the image.
+ Number of packages: {{packageMap.classCount}} Number of relationships: {{packageMap.relationshipCount}}
+
+
+ {{#packageMap.dotThresholdExceeded}}
+ SVG is too big to render quickly
+ {{/packageMap.dotThresholdExceeded}}
+ {{^packageMap.dotThresholdExceeded}}
+
+ {{/packageMap.dotThresholdExceeded}}
+{{/packageMap.hasEdges}}
+
+
+
+
+
+
+
+{{#packageRelationshipsToRemove.hasRelationships}}
+
+ Refactor Starting with Priority 1
+
+ Current Package Cycle Count: {{packageRelationshipsToRemove.cycleCount}}
+ Number of Package Relationships to Remove: {{packageRelationshipsToRemove.relationshipsToRemoveCount}}
+ Packages and classes with * should be broken apart
+ Removing package relationships below will eliminate package cycles
+
+
+
+
+
+ Package Relationship
+ Priority
+ In Pkg Cycles
+ Relationship Strength
+ Class Relationships to Remove To Break Package Relationship
+
+
+
+ {{#packageRelationshipsToRemove.relationships}}
+
+ {{{renderedLabel}}}
+ {{priority}}
+ {{cycleCount}}
+ {{effortRank}}
+ {{#classRelationshipsToBreakPackage}}
+ {{{.}}} {{/classRelationshipsToBreakPackage}}
+
+ {{/packageRelationshipsToRemove.relationships}}
+
+
+
+{{/packageRelationshipsToRemove.hasRelationships}}
+
+
+{{#disharmonies}}
+
+
+
+
+
+
+
+
+ Problem:
+ {{problem}}
+
+
+ Solution:
+ {{{solution}}}
+
+
+
+
+
+
+
+
+
+ {{title}} by the numbers: (Refactor Starting with Priority 1)
+
+
+
+
+ {{#table.headers}}
+ {{.}}
+ {{/table.headers}}
+
+
+
+ {{#table.rows}}
+
+ {{#cells}}
+ {{{content}}}
+ {{/cells}}
+
+ {{/table.rows}}
+
+
+
+{{/disharmonies}}
+
+
+
+
+
+
+
+{{#classCycles.hasCycles}}
+
+ Class Cycles by the numbers:
+
+
+
+
+ Cycle Name
+ Priority
+ Class Count
+ Relationship Count
+
+
+
+ {{#classCycles.summary}}
+
+ {{cycleName}}
+ {{priority}}
+ {{classCount}}
+ {{relationshipCount}}
+
+ {{/classCycles.summary}}
+
+
+
+
+ {{#classCycles.largestCycle.hasCycleMap}}
+
+ Limiting number of cycles displayed to 1 to keep page load time fast
+
+
+ Show {{classCycles.largestCycle.cycleName}} 3D Popup
+
+
+ Show {{classCycles.largestCycle.cycleName}} 2D Popup
+
+
+
+
+
+
+
+
+
+
+
+ Classes
+ Relationships
+
+
+
+ {{#classCycles.largestCycle.breakdown}}
+
+ {{{className}}}
+ {{{edgesHtml}}}
+
+ {{/classCycles.largestCycle.breakdown}}
+
+
+
+ {{/classCycles.largestCycle.hasCycleMap}}
+{{/classCycles.hasCycles}}
+
+
+
+
+
+ Last Published: {{project.scanTimestamp}}
+
+
+
diff --git a/report/src/main/resources/viewer/index.html b/report/src/main/resources/viewer/index.html
new file mode 100644
index 00000000..ef607130
--- /dev/null
+++ b/report/src/main/resources/viewer/index.html
@@ -0,0 +1,526 @@
+
+
+
+
+ RefactorFirst Interactive Report Viewer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading RefactorFirst Report...
+
+
CORS Notice: Opening directly from file:/// prevents automatic loading.
+
Click here and select both refactor-first.json and
+ refactor-first-report.mustache to view:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/report/src/test/java/org/hjug/refactorfirst/report/DotLanguageEscapingTest.java b/report/src/test/java/org/hjug/refactorfirst/report/DotLanguageEscapingTest.java
new file mode 100644
index 00000000..5cde0162
--- /dev/null
+++ b/report/src/test/java/org/hjug/refactorfirst/report/DotLanguageEscapingTest.java
@@ -0,0 +1,113 @@
+package org.hjug.refactorfirst.report;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.hjug.refactorfirst.report.model.GraphVisualDTO;
+import org.hjug.refactorfirst.report.model.RefactorFirstReportDTO;
+import org.junit.jupiter.api.Test;
+
+class DotLanguageEscapingTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ /** Verifies that JSON round-trips DOT quotes and line breaks. */
+ @Test
+ void testDotDigraphWithQuotesAndNewlines() throws Exception {
+ String rawDot = "strict digraph G {\n"
+ + " ActiveTestSuite -> TestSuite [ label = \"6\" weight = \"6\" ];\n"
+ + " ActiveTestSuite -> TestCase [ label = \"2\" weight = \"2\" ];\n"
+ + "}";
+
+ GraphVisualDTO classMap = GraphVisualDTO.builder()
+ .graphId("classGraph")
+ .classCount(3)
+ .relationshipCount(2)
+ .dot(rawDot)
+ .build();
+
+ RefactorFirstReportDTO report =
+ RefactorFirstReportDTO.builder().classMap(classMap).build();
+
+ String json = objectMapper.writeValueAsString(report);
+
+ // Verify JSON escapes newlines and quotes properly
+ assertTrue(json.contains("\\\"6\\\""));
+ assertTrue(json.contains("\\n"));
+
+ // Verify deserialization restores the exact raw DOT string
+ RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class);
+ assertEquals(rawDot, deserialized.getClassMap().getDot());
+ }
+
+ /** Verifies that JSON preserves Java inner-class dollar signs in DOT. */
+ @Test
+ void testDotDigraphWithJavaInnerClassDollarSign() throws Exception {
+ String rawDot = "strict digraph G {\n"
+ + " Outer_Inner [ label=\"Outer\\$Inner\" ];\n"
+ + " Outer_1 [ label=\"Outer\\$1\" color=red style=filled ];\n"
+ + " Outer -> Outer_Inner [ label = \"1\" weight = \"1\" ];\n"
+ + "}";
+
+ GraphVisualDTO classMap =
+ GraphVisualDTO.builder().graphId("classGraph").dot(rawDot).build();
+
+ RefactorFirstReportDTO report =
+ RefactorFirstReportDTO.builder().classMap(classMap).build();
+
+ String json = objectMapper.writeValueAsString(report);
+ // Backslash in raw string is escaped as \\ in JSON
+ assertTrue(json.contains("Outer\\\\$Inner"));
+ assertTrue(json.contains("Outer\\\\$1"));
+
+ RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class);
+ assertEquals(rawDot, deserialized.getClassMap().getDot());
+ }
+
+ /** Verifies that JSON preserves Kotlin anonymous-class labels in DOT. */
+ @Test
+ void testDotDigraphWithKotlinAnonymousLiteral() throws Exception {
+ String rawDot = "strict digraph G {\n"
+ + " DeveloperWASDControl_anonymous [ label=\"DeveloperWASDControl\\$anonymous\" ];\n"
+ + " lt_anonymous_gt [ label=\"\" ];\n"
+ + " DeveloperWASDControl -> DeveloperWASDControl_anonymous [ label = \"1\" weight = \"1\" ];\n"
+ + "}";
+
+ GraphVisualDTO classMap =
+ GraphVisualDTO.builder().graphId("classGraph").dot(rawDot).build();
+
+ RefactorFirstReportDTO report =
+ RefactorFirstReportDTO.builder().classMap(classMap).build();
+
+ String json = objectMapper.writeValueAsString(report);
+ assertTrue(json.contains("DeveloperWASDControl\\\\$anonymous"));
+ assertTrue(json.contains(""));
+
+ RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class);
+ assertEquals(rawDot, deserialized.getClassMap().getDot());
+ }
+
+ /** Verifies that JSON round-trips DOT hyperlink attributes. */
+ @Test
+ void testDotDigraphWithHyperlinkAttributes() throws Exception {
+ String rawDot = "strict digraph G {\n"
+ + " A [URL=\"https://github.com/refactorfirst/RefactorFirst/blob/src/A.java\" target=\"_blank\"];\n"
+ + " B [URL=\"https://github.com/refactorfirst/RefactorFirst/blob/src/B.java\" target=\"_blank\"];\n"
+ + " A -> B [ label = \"2\" weight = \"2\" color = \"red\" ];\n"
+ + "}";
+
+ GraphVisualDTO classMap =
+ GraphVisualDTO.builder().graphId("classGraph").dot(rawDot).build();
+
+ RefactorFirstReportDTO report =
+ RefactorFirstReportDTO.builder().classMap(classMap).build();
+
+ String json = objectMapper.writeValueAsString(report);
+ assertTrue(json.contains("URL=\\\"https://github.com/refactorfirst/RefactorFirst/blob/src/A.java\\\""));
+ assertTrue(json.contains("target=\\\"_blank\\\""));
+
+ RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class);
+ assertEquals(rawDot, deserialized.getClassMap().getDot());
+ }
+}
diff --git a/report/src/test/java/org/hjug/refactorfirst/report/JsonGeneratorTest.java b/report/src/test/java/org/hjug/refactorfirst/report/JsonGeneratorTest.java
new file mode 100644
index 00000000..40976f2a
--- /dev/null
+++ b/report/src/test/java/org/hjug/refactorfirst/report/JsonGeneratorTest.java
@@ -0,0 +1,197 @@
+package org.hjug.refactorfirst.report;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import org.hjug.refactorfirst.report.model.ChartJsBubbleDTO;
+import org.hjug.refactorfirst.report.model.RefactorFirstReportDTO;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class JsonGeneratorTest {
+
+ private Path tempDir;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ /** Creates an isolated project directory for each test. */
+ @BeforeEach
+ void setUp() throws Exception {
+ tempDir = Files.createTempDirectory("jsonGeneratorTest");
+ }
+
+ /** Removes the isolated project directory after each test. */
+ @AfterEach
+ void tearDown() throws Exception {
+ if (tempDir != null && Files.exists(tempDir)) {
+ Files.walk(tempDir)
+ .sorted(Comparator.reverseOrder())
+ .map(Path::toFile)
+ .forEach(File::delete);
+ }
+ }
+
+ /** Verifies that generation creates the report directory and JSON file. */
+ @Test
+ void testDirectoryAndFileCreatedIfNotExist() throws Exception {
+ JsonGenerator generator = new JsonGenerator();
+ File baseDir = tempDir.toFile();
+
+ generator.execute(50, true, false, true, "src/test", "TestProject", "1.0.0", baseDir, null);
+
+ Path dotRefactorFirstDir = tempDir.resolve(".refactorfirst");
+ Path jsonFile = dotRefactorFirstDir.resolve("refactor-first.json");
+
+ assertTrue(Files.exists(dotRefactorFirstDir), ".refactorfirst directory should be created");
+ assertTrue(Files.exists(jsonFile), "refactor-first.json file should be created");
+
+ RefactorFirstReportDTO report = objectMapper.readValue(jsonFile.toFile(), RefactorFirstReportDTO.class);
+ assertNotNull(report);
+ assertEquals("TestProject", report.getProject().getName());
+ assertEquals("1.0.0", report.getProject().getVersion());
+ assertTrue(report.getProject().isAnalysisFailed());
+ }
+
+ /** Verifies that a configured output directory controls where report artifacts are written. */
+ @Test
+ void testConfiguredOutputDirectoryIsHonored() throws Exception {
+ Path outputDir = tempDir.resolve("custom-output");
+
+ new JsonGenerator()
+ .execute(
+ 50,
+ true,
+ false,
+ true,
+ "src/test",
+ "TestProject",
+ "1.0.0",
+ tempDir.toFile(),
+ outputDir.toFile());
+
+ assertTrue(Files.exists(outputDir.resolve(".refactorfirst/refactor-first.json")));
+ assertFalse(Files.exists(tempDir.resolve(".refactorfirst/refactor-first.json")));
+
+ String viewer = Files.readString(outputDir.resolve(".refactorfirst/index.html"));
+ assertTrue(viewer.contains("accept=\".json,.mustache\" multiple"));
+ assertFalse(viewer.contains("getFallbackTemplate"));
+ }
+
+ /** Verifies HTML encoding used for repository-derived text and attribute values. */
+ @Test
+ void testRepositoryTextEncoding() {
+ assertEquals("<script>&", SimpleHtmlReport.escapeHtmlLabel(" → B")
+ .priority(1)
+ .cycleCount(3)
+ .effortRank(2)
+ .alsoRemovesPackageRelationship(true)
+ .packageCycleCount(1)
+ .build();
+
+ ClassRelationshipsToRemoveDTO classRels = ClassRelationshipsToRemoveDTO.builder()
+ .cycleCount(5)
+ .relationshipsToRemoveCount(3)
+ .hasRelationships(true)
+ .relationships(List.of(rel))
+ .build();
+
+ RefactorFirstReportDTO report = RefactorFirstReportDTO.builder()
+ .project(project)
+ .classMap(classMap)
+ .classRelationshipsToRemove(classRels)
+ .packageMap(GraphVisualDTO.builder().hasEdges(false).build())
+ .packageRelationshipsToRemove(PackageRelationshipsToRemoveDTO.builder()
+ .cycleCount(0)
+ .relationshipsToRemoveCount(0)
+ .relationships(List.of())
+ .build())
+ .hasDisharmonies(false)
+ .disharmonies(List.of())
+ .classCycles(ClassCyclesDTO.builder().hasCycles(false).build())
+ .build();
+
+ String rendered = renderTemplate(template, report);
+
+ // Verify table headers
+ assertTrue(rendered.contains("Class Relationship "));
+ assertTrue(rendered.contains("Priority "));
+ assertTrue(rendered.contains("In Class Cycles"));
+ assertTrue(rendered.contains("Relationship Strength"));
+ assertTrue(rendered.contains("Also Removes Pkg Cycle Relationship"));
+ assertTrue(rendered.contains("In Package Cycles"));
+
+ // Verify table data - alsoRemovesPackageRelationship renders true
+ assertTrue(rendered.contains("true "));
+ assertTrue(rendered.contains("A <script>alert(1)</script> → B"));
+ assertFalse(rendered.contains(""));
+ }
+
+ /** Verifies that the template renders disharmony charts and tables. */
+ @Test
+ void testTemplateRendersDisharmonyCanvases() throws Exception {
+ String template = loadTemplate();
+
+ ProjectMetadataDTO project = ProjectMetadataDTO.builder()
+ .name("TestProject")
+ .version("1.0.0")
+ .repoUrl("https://github.com/test/test")
+ .baseDir("/test")
+ .scanTimestamp("9/8/26, 7:34 PM")
+ .hasAnyDisharmony(true)
+ .build();
+
+ GraphVisualDTO classMap = GraphVisualDTO.builder()
+ .graphId("classGraph")
+ .classCount(10)
+ .relationshipCount(20)
+ .dot("digraph G {}")
+ .dotThresholdExceeded(false)
+ .build();
+
+ ChartJsBubbleDTO bubble = ChartJsBubbleDTO.builder()
+ .id("TestClass")
+ .label("TestClass.java")
+ .x(5)
+ .y(10)
+ .r(18)
+ .priority(1)
+ .effortRank(5)
+ .changePronenessRank(10)
+ .color("rgba(235, 64, 52, 0.75)")
+ .borderColor("rgb(235, 64, 52)")
+ .build();
+
+ DisharmonyChartDTO chart = DisharmonyChartDTO.builder()
+ .canvasId("chart_GOD")
+ .xAxisLabel("Effort to refactor")
+ .yAxisLabel("Relative churn")
+ .bubbles(List.of(bubble))
+ .build();
+
+ DisharmonyTableDTO table = DisharmonyTableDTO.builder()
+ .headers(List.of("Class", "Priority"))
+ .rows(List.of(DisharmonyTableRowDTO.builder()
+ .cells(List.of(
+ DisharmonyTableCellDTO.builder()
+ .content("TestClass.java")
+ .align("left")
+ .build(),
+ DisharmonyTableCellDTO.builder()
+ .content("1")
+ .align("right")
+ .build()))
+ .build()))
+ .build();
+
+ DisharmonySectionDTO section = DisharmonySectionDTO.builder()
+ .type("God Class")
+ .anchorId("GOD")
+ .title("God Classes")
+ .methodLevel(false)
+ .problem("God Classes take on too much responsibility")
+ .solution("Extract related functionality")
+ .maxPriority(5)
+ .chart(chart)
+ .table(table)
+ .build();
+
+ RefactorFirstReportDTO report = RefactorFirstReportDTO.builder()
+ .project(project)
+ .classMap(classMap)
+ .classRelationshipsToRemove(ClassRelationshipsToRemoveDTO.builder()
+ .cycleCount(0)
+ .relationshipsToRemoveCount(0)
+ .relationships(List.of())
+ .build())
+ .packageMap(GraphVisualDTO.builder().hasEdges(false).build())
+ .packageRelationshipsToRemove(PackageRelationshipsToRemoveDTO.builder()
+ .cycleCount(0)
+ .relationshipsToRemoveCount(0)
+ .relationships(List.of())
+ .build())
+ .hasDisharmonies(true)
+ .disharmonies(List.of(section))
+ .classCycles(ClassCyclesDTO.builder().hasCycles(false).build())
+ .build();
+
+ String rendered = renderTemplate(template, report);
+
+ // Verify disharmony section
+ assertTrue(rendered.contains("God Classes "));
+ assertTrue(rendered.contains("Problem: "));
+ assertTrue(rendered.contains("God Classes take on too much responsibility"));
+ assertTrue(rendered.contains("Solution: "));
+ assertTrue(rendered.contains("Extract related functionality"));
+
+ // Verify Chart.js canvas
+ assertTrue(rendered.contains(" "));
+
+ // Verify table
+ assertTrue(rendered.contains("Class "));
+ assertTrue(rendered.contains("Priority "));
+ }
+
+ /** Verifies that the template renders cycle maps and breakdown data. */
+ @Test
+ void testTemplateRendersCycleMapAndBreakdown() throws Exception {
+ String template = loadTemplate();
+
+ ProjectMetadataDTO project = ProjectMetadataDTO.builder()
+ .name("TestProject")
+ .version("1.0.0")
+ .repoUrl("https://github.com/test/test")
+ .baseDir("/test")
+ .scanTimestamp("9/8/26, 7:34 PM")
+ .hasAnyDisharmony(true)
+ .build();
+
+ GraphVisualDTO classMap = GraphVisualDTO.builder()
+ .graphId("classGraph")
+ .classCount(10)
+ .relationshipCount(20)
+ .dot("digraph G {}")
+ .dotThresholdExceeded(false)
+ .build();
+
+ CycleSummaryDTO cycleSummary = CycleSummaryDTO.builder()
+ .cycleName("A -> B -> C -> A")
+ .priority(1)
+ .classCount(3)
+ .relationshipCount(3)
+ .build();
+
+ CycleBreakdownRowDTO breakdownRow = CycleBreakdownRowDTO.builder()
+ .className("A * ")
+ .edgesHtml("A → B* ")
+ .build();
+
+ LargestCycleDTO largestCycle = LargestCycleDTO.builder()
+ .hasCycleMap(true)
+ .cycleName("A -> B -> C -> A")
+ .cycleIdentifier("graph_A_B_C_A_abc123")
+ .classCount(3)
+ .relationshipCount(3)
+ .dotThresholdExceeded(false)
+ .dot("digraph G { A -> B; B -> C; C -> A; }")
+ .breakdown(List.of(breakdownRow))
+ .build();
+
+ ClassCyclesDTO classCycles = ClassCyclesDTO.builder()
+ .hasCycles(true)
+ .summary(List.of(cycleSummary))
+ .largestCycle(largestCycle)
+ .build();
+
+ RefactorFirstReportDTO report = RefactorFirstReportDTO.builder()
+ .project(project)
+ .classMap(classMap)
+ .classRelationshipsToRemove(ClassRelationshipsToRemoveDTO.builder()
+ .cycleCount(0)
+ .relationshipsToRemoveCount(0)
+ .relationships(List.of())
+ .build())
+ .packageMap(GraphVisualDTO.builder().hasEdges(false).build())
+ .packageRelationshipsToRemove(PackageRelationshipsToRemoveDTO.builder()
+ .cycleCount(0)
+ .relationshipsToRemoveCount(0)
+ .relationships(List.of())
+ .build())
+ .hasDisharmonies(false)
+ .disharmonies(List.of())
+ .classCycles(classCycles)
+ .build();
+
+ String rendered = renderTemplate(template, report);
+
+ // Verify cycles summary table
+ assertTrue(rendered.contains("Class Cycles "));
+ assertTrue(rendered.contains("Cycle Name "));
+ assertTrue(rendered.contains("Priority "));
+ assertTrue(rendered.contains("Class Count "));
+ assertTrue(rendered.contains("Relationship Count "));
+ // Mustache escapes HTML by default: > becomes >
+ assertTrue(rendered.contains(CYCLE_NAME_ESCAPED));
+
+ // Verify cycle map section
+ assertTrue(rendered.contains("Largest Class Cycle"));
+ assertTrue(rendered.contains("Limiting number of cycles displayed to 1"));
+ assertTrue(rendered.contains("Show " + CYCLE_NAME_ESCAPED + " 3D Popup"));
+ assertTrue(rendered.contains("Show " + CYCLE_NAME_ESCAPED + " 2D Popup"));
+
+ // Verify cycle breakdown table
+ assertTrue(rendered.contains(" Classes "));
+ assertTrue(rendered.contains("Relationships "));
+ assertTrue(rendered.contains("* "));
+ }
+
+ /** Renders a template with the supplied report data. */
+ private String renderTemplate(String template, RefactorFirstReportDTO data) throws Exception {
+ MustacheFactory mf = new DefaultMustacheFactory();
+ Mustache mustache = mf.compile(new StringReader(template), "test");
+ StringWriter writer = new StringWriter();
+ mustache.execute(writer, data).flush();
+ return writer.toString();
+ }
+}
diff --git a/report/src/test/java/org/hjug/refactorfirst/report/ReportDataSerializationTest.java b/report/src/test/java/org/hjug/refactorfirst/report/ReportDataSerializationTest.java
new file mode 100644
index 00000000..f6ded5f4
--- /dev/null
+++ b/report/src/test/java/org/hjug/refactorfirst/report/ReportDataSerializationTest.java
@@ -0,0 +1,167 @@
+package org.hjug.refactorfirst.report;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.List;
+import org.hjug.refactorfirst.report.model.*;
+import org.junit.jupiter.api.Test;
+
+class ReportDataSerializationTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ /** Verifies project metadata JSON serialization and deserialization. */
+ @Test
+ void testProjectMetadataSerialization() throws Exception {
+ ProjectMetadataDTO project = ProjectMetadataDTO.builder()
+ .name("JUnit")
+ .version("4.13.3-SNAPSHOT")
+ .repoUrl("https://github.com/junit-team/junit4/blob/main/")
+ .baseDir("/repo")
+ .scanTimestamp("9/8/26, 7:34 PM")
+ .hasAnyDisharmony(true)
+ .analysisFailed(true)
+ .build();
+
+ RefactorFirstReportDTO report =
+ RefactorFirstReportDTO.builder().project(project).build();
+
+ String json = objectMapper.writeValueAsString(report);
+ assertTrue(json.contains("\"name\":\"JUnit\""));
+ assertTrue(json.contains("\"version\":\"4.13.3-SNAPSHOT\""));
+ assertTrue(json.contains("\"hasAnyDisharmony\":true"));
+ assertTrue(json.contains("\"analysisFailed\":true"));
+
+ RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class);
+ assertEquals("JUnit", deserialized.getProject().getName());
+ assertEquals("4.13.3-SNAPSHOT", deserialized.getProject().getVersion());
+ assertTrue(deserialized.getProject().isHasAnyDisharmony());
+ assertTrue(deserialized.getProject().isAnalysisFailed());
+ }
+
+ /** Verifies disharmony chart JSON serialization and deserialization. */
+ @Test
+ void testDisharmonyBubbleChartSerialization() throws Exception {
+ ChartJsBubbleDTO bubble = ChartJsBubbleDTO.builder()
+ .id("ComparisonCompactor")
+ .label("ComparisonCompactor.java")
+ .x(2)
+ .y(14)
+ .r(18)
+ .priority(1)
+ .effortRank(2)
+ .changePronenessRank(14)
+ .color("rgba(235, 64, 52, 0.75)")
+ .borderColor("rgb(235, 64, 52)")
+ .build();
+
+ DisharmonyChartDTO chart = DisharmonyChartDTO.builder()
+ .canvasId("chart_god")
+ .xAxisLabel("Effort to refactor")
+ .yAxisLabel("Relative churn")
+ .bubbles(List.of(bubble))
+ .build();
+
+ DisharmonySectionDTO section = DisharmonySectionDTO.builder()
+ .type("God Class")
+ .anchorId("GOD")
+ .title("God Classes")
+ .methodLevel(false)
+ .problem("God Classes take on too much responsibility")
+ .solution("Extract related functionality")
+ .maxPriority(5)
+ .chart(chart)
+ .build();
+
+ RefactorFirstReportDTO report =
+ RefactorFirstReportDTO.builder().disharmonies(List.of(section)).build();
+
+ String json = objectMapper.writeValueAsString(report);
+ assertTrue(json.contains("\"anchorId\":\"GOD\""));
+ assertTrue(json.contains("\"bubbles\":["));
+ assertTrue(json.contains("\"priority\":1"));
+ assertTrue(json.contains("\"r\":18"));
+
+ RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class);
+ assertEquals(1, deserialized.getDisharmonies().size());
+ assertEquals(
+ 18,
+ deserialized
+ .getDisharmonies()
+ .get(0)
+ .getChart()
+ .getBubbles()
+ .get(0)
+ .getR());
+ }
+
+ /** Verifies disharmony table JSON serialization and deserialization. */
+ @Test
+ void testTableRowsSerialization() throws Exception {
+ DisharmonyTableCellDTO cell1 = DisharmonyTableCellDTO.builder()
+ .content("Bar.java ")
+ .align("left")
+ .build();
+ DisharmonyTableCellDTO cell2 =
+ DisharmonyTableCellDTO.builder().content("1").align("right").build();
+
+ DisharmonyTableRowDTO row =
+ DisharmonyTableRowDTO.builder().cells(List.of(cell1, cell2)).build();
+
+ DisharmonyTableDTO table = DisharmonyTableDTO.builder()
+ .headers(List.of("Class", "Priority"))
+ .rows(List.of(row))
+ .build();
+
+ DisharmonySectionDTO section = DisharmonySectionDTO.builder()
+ .type("Data Class")
+ .anchorId("DATA_CLASS")
+ .title("Data Classes")
+ .table(table)
+ .build();
+
+ RefactorFirstReportDTO report =
+ RefactorFirstReportDTO.builder().disharmonies(List.of(section)).build();
+
+ String json = objectMapper.writeValueAsString(report);
+ assertTrue(json.contains("Bar.java "));
+
+ RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class);
+ assertEquals(
+ "Bar.java ",
+ deserialized
+ .getDisharmonies()
+ .get(0)
+ .getTable()
+ .getRows()
+ .get(0)
+ .getCells()
+ .get(0)
+ .getContent());
+ }
+
+ /** Verifies JSON serialization for a report with no findings. */
+ @Test
+ void testEmptyReportSerialization() throws Exception {
+ ProjectMetadataDTO project = ProjectMetadataDTO.builder()
+ .name("CleanProject")
+ .version("1.0.0")
+ .repoUrl("https://github.com/clean/clean")
+ .baseDir("/clean")
+ .scanTimestamp("9/8/26, 7:34 PM")
+ .hasAnyDisharmony(false)
+ .build();
+
+ RefactorFirstReportDTO report = RefactorFirstReportDTO.builder()
+ .project(project)
+ .disharmonies(List.of())
+ .build();
+
+ String json = objectMapper.writeValueAsString(report);
+ assertTrue(json.contains("\"hasAnyDisharmony\":false"));
+ RefactorFirstReportDTO deserialized = objectMapper.readValue(json, RefactorFirstReportDTO.class);
+ assertFalse(deserialized.getProject().isHasAnyDisharmony());
+ assertTrue(deserialized.getDisharmonies().isEmpty());
+ }
+}