diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e956fd5..8081a47ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ Entries land here as they merge. ### Public API +- **`ChartData.Series` rejects non-finite values.** A `NaN` / ±∞ entry now + fails at construction — naming the series and the offending index — + instead of poisoning axis derivation and surfacing as a misleading + "height must be finite" failure deep in the layout pass. `null` entries + are still allowed as gaps. - **Block-level horizontal alignment** (`@since 1.8.0`). Fixed-size flow children (paths, images, SVG icons, barcodes, shape containers) left-align by default — there was no built-in way to centre or right-align one without diff --git a/src/main/java/com/demcha/compose/document/chart/ChartData.java b/src/main/java/com/demcha/compose/document/chart/ChartData.java index 6d441b10e..689785ab5 100644 --- a/src/main/java/com/demcha/compose/document/chart/ChartData.java +++ b/src/main/java/com/demcha/compose/document/chart/ChartData.java @@ -74,18 +74,31 @@ public int categoryCount() { /** * One named value series. {@code null} entries are allowed and mean a * missing point — a gap in a line, a skipped bar — distinct from {@code 0}. + * Non-finite entries ({@code NaN} / ±∞) are rejected at construction: they + * would otherwise poison axis derivation and only surface as a misleading + * "height must be finite" failure deep inside the layout pass. * * @param name legend label for this series * @param values value per category, aligned by index; entries may be null + * (a gap) but must otherwise be finite */ public record Series(String name, List values) { /** - * Normalizes the name and tolerates {@code null} value entries. + * Normalizes the name, tolerates {@code null} value entries (gaps), + * and rejects non-finite values with the offending index named. */ public Series { name = name == null ? "" : name; Objects.requireNonNull(values, "values"); values = java.util.Collections.unmodifiableList(new ArrayList<>(values)); + for (int i = 0; i < values.size(); i++) { + Double v = values.get(i); + if (v != null && !Double.isFinite(v)) { + throw new IllegalArgumentException( + "series '" + name + "' value at index " + i + + " must be finite or null, got " + v); + } + } } /** diff --git a/src/test/java/com/demcha/compose/document/chart/ChartDataTest.java b/src/test/java/com/demcha/compose/document/chart/ChartDataTest.java new file mode 100644 index 000000000..69f1c0997 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/chart/ChartDataTest.java @@ -0,0 +1,64 @@ +package com.demcha.compose.document.chart; + +import com.demcha.compose.document.chart.ChartData.Series; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Value-level contract of {@link ChartData} and its {@link Series}: null + * entries are tolerated as gaps, but non-finite values are rejected at + * authoring time (rather than poisoning axis derivation and surfacing as a + * misleading "height must be finite" failure deep in the layout pass). + */ +class ChartDataTest { + + @Test + void nullValuesAreAllowedAsGaps() { + Series s = new Series("revenue", Arrays.asList(1.0, null, 3.0)); + + assertThat(s.values()).containsExactly(1.0, null, 3.0); + } + + @Test + void nanValueIsRejectedNamingTheSeriesAndIndex() { + assertThatThrownBy(() -> new Series("revenue", Arrays.asList(1.0, Double.NaN, 3.0))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("revenue") + .hasMessageContaining("index 1 must be finite"); + } + + @Test + void infiniteValuesAreRejected() { + assertThatThrownBy(() -> Series.of("a", 1.0, Double.POSITIVE_INFINITY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("finite"); + assertThatThrownBy(() -> Series.of("b", Double.NEGATIVE_INFINITY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("finite"); + } + + @Test + void finiteSeriesBuildsCleanly() { + ChartData data = ChartData.builder() + .categories("Q1", "Q2") + .series("revenue", 10.0, 20.0) + .build(); + + assertThat(data.seriesCount()).isEqualTo(1); + assertThat(data.categoryCount()).isEqualTo(2); + assertThat(data.series().get(0).values()).containsExactly(10.0, 20.0); + } + + @Test + void raggedSeriesIsRejected() { + assertThatThrownBy(() -> new ChartData(List.of("Q1", "Q2"), + List.of(Series.of("revenue", 10.0)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("values but there are"); + } +}