From 0ce71b727a49dd337cebc79878e03bb46d2d0b3e Mon Sep 17 00:00:00 2001 From: George Date: Sun, 7 Jun 2026 13:31:39 +0100 Subject: [PATCH] Harden simulation profile snapshots --- .../debug/SimulationProfileSnapshot.java | 20 ++++++- .../debug/SimulationProfileSnapshotTest.java | 53 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/livingworld/debug/SimulationProfileSnapshotTest.java diff --git a/src/main/java/com/livingworld/debug/SimulationProfileSnapshot.java b/src/main/java/com/livingworld/debug/SimulationProfileSnapshot.java index ecba8a7..15201d4 100644 --- a/src/main/java/com/livingworld/debug/SimulationProfileSnapshot.java +++ b/src/main/java/com/livingworld/debug/SimulationProfileSnapshot.java @@ -1,7 +1,9 @@ package com.livingworld.debug; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; +import java.util.StringJoiner; /** * Immutable snapshot of one simulation profiling cycle. @@ -24,12 +26,26 @@ public record SimulationProfileSnapshot( if (eventsPublished < 0 || regionsUpdated < 0 || savesPerformed < 0) { throw new IllegalArgumentException("profile counts must not be negative"); } - moduleTimings = Map.copyOf(new LinkedHashMap<>(moduleTimings)); + LinkedHashMap timingCopy = new LinkedHashMap<>(); + moduleTimings.forEach((moduleId, timingNanos) -> { + if (moduleId == null || moduleId.isBlank()) { + throw new IllegalArgumentException("Module IDs must not be blank"); + } + if (timingNanos == null || timingNanos < 0) { + throw new IllegalArgumentException("Module timings must be non-negative"); + } + timingCopy.put(moduleId, timingNanos); + }); + moduleTimings = Collections.unmodifiableMap(timingCopy); } public String toHumanReadableString() { + StringJoiner formattedTimings = new StringJoiner(", ", "{", "}"); + moduleTimings.forEach((moduleId, timingNanos) -> + formattedTimings.add(moduleId + "=" + timingNanos / 1_000_000.0 + "ms")); + return "cycle=" + (totalCycleNanos / 1_000_000.0) + "ms" - + ", modules=" + moduleTimings + + ", modules=" + formattedTimings + ", events=" + eventsPublished + ", regions=" + regionsUpdated + ", saves=" + savesPerformed diff --git a/src/test/java/com/livingworld/debug/SimulationProfileSnapshotTest.java b/src/test/java/com/livingworld/debug/SimulationProfileSnapshotTest.java new file mode 100644 index 0000000..ca0e423 --- /dev/null +++ b/src/test/java/com/livingworld/debug/SimulationProfileSnapshotTest.java @@ -0,0 +1,53 @@ +package com.livingworld.debug; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SimulationProfileSnapshotTest { + + @Test + void defensivelyCopiesAndLocksModuleTimings() { + Map timings = new LinkedHashMap<>(); + timings.put("ecology", 2_000_000L); + + SimulationProfileSnapshot snapshot = + new SimulationProfileSnapshot(3_000_000L, timings, 2, 3, 1, false); + timings.put("settlements", 1_000_000L); + + assertEquals(Map.of("ecology", 2_000_000L), snapshot.moduleTimings()); + assertThrows( + UnsupportedOperationException.class, + () -> snapshot.moduleTimings().put("weather", 500_000L)); + } + + @Test + void rejectsInvalidValues() { + assertThrows( + IllegalArgumentException.class, + () -> new SimulationProfileSnapshot(-1, Map.of(), 0, 0, 0, false)); + assertThrows( + IllegalArgumentException.class, + () -> new SimulationProfileSnapshot(0, Map.of("", 1L), 0, 0, 0, false)); + assertThrows( + IllegalArgumentException.class, + () -> new SimulationProfileSnapshot(0, Map.of("test", -1L), 0, 0, 0, false)); + } + + @Test + void formatsMeasurementsForDebugOutput() { + SimulationProfileSnapshot snapshot = new SimulationProfileSnapshot( + 3_000_000L, Map.of("test", 2_000_000L), 2, 3, 1, true); + + String output = snapshot.toHumanReadableString(); + + assertTrue(output.contains("cycle=3.0ms")); + assertTrue(output.contains("test=2.0ms")); + assertTrue(output.contains("events=2")); + assertTrue(output.contains("budgetExceeded=true")); + } +}