diff --git a/CHANGELOG.md b/CHANGELOG.md index 8081a47ef..495e2602e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -320,6 +320,15 @@ Entries land here as they merge. ### Tests +- Pinned the fail-loud guards on the new value types so a future refactor + cannot silently drop one: `PolygonNodeTest` (fewer than three points, + non-positive / `NaN` / ∞ box, defensive vertex-ring copy), `DocumentColorTest` + (`withOpacity` range + `NaN` rejection, boundary alpha rounding, `rgba` + alpha), `ShapeOutline.Path` cases in `ShapeOutlineTest` (segment-count / + `MoveTo`-first / null guards, defensive copy), and `PathBuilder` dashed-pattern + rejection plus the documented `build()`-snapshot contract. Extended + `PublicApiNoEngineLeakTest` to cover the new public `document.svg` package + (it is engine-clean today; the guard now keeps it that way). - Chart geometry pinned without rendering: `NiceScaleTest` golden tables and `ChartLayoutResolverTest` exact-position assertions on a font-independent text-metrics fake; `ChartLayoutSnapshotTest` layout snapshots + a diff --git a/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java index b38043bcd..e86cbb5d8 100644 --- a/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java @@ -66,6 +66,36 @@ void dashedFlowsThroughToTheNode() { assertThat(node.dashPattern().segments()).containsExactly(4.0, 2.0); } + @Test + void dashedRejectsAnEmptyOrNonPositivePattern() { + // dashed(double...) delegates to DocumentDashPattern.of, which throws + // eagerly at call time — pin both rejection paths. + assertThatThrownBy(() -> new PathBuilder().dashed()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least one segment"); + assertThatThrownBy(() -> new PathBuilder().dashed(-1.0, 2.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("finite and strictly positive"); + } + + @Test + void buildSnapshotsTheSegmentsSoLaterMutationDoesNotLeakBack() { + // The documented build()-reuse contract: each build() copies the + // segments, so the builder may keep accumulating afterwards and the + // earlier node is unaffected. + PathBuilder builder = new PathBuilder() + .size(100, 40) + .moveTo(0.0, 0.5) + .lineTo(1.0, 0.5); + + PathNode first = builder.build(); + builder.lineTo(0.5, 1.0); + PathNode second = builder.build(); + + assertThat(first.segments()).hasSize(2); + assertThat(second.segments()).hasSize(3); + } + @Test void svgBridgeAppendsParsedSegments() { PathNode node = new PathBuilder() diff --git a/src/test/java/com/demcha/compose/document/node/PolygonNodeTest.java b/src/test/java/com/demcha/compose/document/node/PolygonNodeTest.java new file mode 100644 index 000000000..50cb726e8 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/node/PolygonNodeTest.java @@ -0,0 +1,95 @@ +package com.demcha.compose.document.node; + +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.ShapePoint; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Value semantics of {@link PolygonNode}: the fail-loud guards the author + * wrote (fewer than three points, non-positive / non-finite box) and the + * defensive copy of the vertex ring. The only production callers pass valid + * ring data, so without these tests a future refactor could silently drop a + * guard with nothing going red — the {@link PathNode} sibling already pins + * the equivalent contract. + */ +class PolygonNodeTest { + + private static final DocumentStroke STROKE = DocumentStroke.of(DocumentColor.rgb(20, 60, 120), 1.5); + private static final List TRIANGLE = + List.of(new ShapePoint(0, 0), new ShapePoint(1, 0), new ShapePoint(0.5, 1)); + + @Test + void validTriangleBuildsAndReportsItsKind() { + PolygonNode node = node(TRIANGLE); + + assertThat(node.points()).hasSize(3); + assertThat(node.nodeKind()).isEqualTo("Polygon"); + } + + @Test + void fewerThanThreePointsIsRejected() { + assertThatThrownBy(() -> node(List.of(new ShapePoint(0, 0), new ShapePoint(1, 1)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least three points"); + } + + @Test + void nonPositiveOrNonFiniteWidthIsRejected() { + assertThatThrownBy(() -> new PolygonNode("P", 0, 40, TRIANGLE, null, STROKE, + DocumentInsets.zero(), DocumentInsets.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("width must be finite and positive"); + assertThatThrownBy(() -> new PolygonNode("P", Double.NaN, 40, TRIANGLE, null, STROKE, + DocumentInsets.zero(), DocumentInsets.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("width must be finite and positive"); + assertThatThrownBy(() -> new PolygonNode("P", Double.POSITIVE_INFINITY, 40, TRIANGLE, null, STROKE, + DocumentInsets.zero(), DocumentInsets.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("width must be finite and positive"); + } + + @Test + void nonPositiveOrNonFiniteHeightIsRejected() { + assertThatThrownBy(() -> new PolygonNode("P", 40, 0, TRIANGLE, null, STROKE, + DocumentInsets.zero(), DocumentInsets.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("height must be finite and positive"); + assertThatThrownBy(() -> new PolygonNode("P", 40, Double.NaN, TRIANGLE, null, STROKE, + DocumentInsets.zero(), DocumentInsets.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("height must be finite and positive"); + } + + @Test + void nullPointsIsRejected() { + assertThatThrownBy(() -> new PolygonNode("P", 40, 40, null, null, STROKE, + DocumentInsets.zero(), DocumentInsets.zero())) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("points"); + } + + @Test + void vertexRingIsCopyProtectedAndImmutable() { + List source = new ArrayList<>(TRIANGLE); + PolygonNode node = node(source); + source.add(new ShapePoint(0.25, 0.25)); + + assertThat(node.points()).hasSize(3); + assertThatThrownBy(() -> node.points().add(new ShapePoint(0.5, 0.5))) + .isInstanceOf(UnsupportedOperationException.class); + } + + private static PolygonNode node(List points) { + return new PolygonNode("P", 120, 60, points, null, STROKE, + DocumentInsets.zero(), DocumentInsets.zero()); + } +} diff --git a/src/test/java/com/demcha/compose/document/style/DocumentColorTest.java b/src/test/java/com/demcha/compose/document/style/DocumentColorTest.java new file mode 100644 index 000000000..01fe40915 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/style/DocumentColorTest.java @@ -0,0 +1,57 @@ +package com.demcha.compose.document.style; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Value-level contract of {@link DocumentColor}'s translucency API: the + * explicit {@code withOpacity} guard and rounding, and the alpha channel + * carried by {@code rgba}. These are public {@code @since 1.8.0} contracts + * that otherwise had no direct unit test. + */ +class DocumentColorTest { + + @Test + void withOpacityRejectsOutOfRangeAndNaN() { + assertThatThrownBy(() -> DocumentColor.WHITE.withOpacity(-0.1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("opacity must be in [0,1]"); + assertThatThrownBy(() -> DocumentColor.WHITE.withOpacity(1.1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("opacity must be in [0,1]"); + assertThatThrownBy(() -> DocumentColor.WHITE.withOpacity(Double.NaN)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("opacity must be in [0,1]"); + } + + @Test + void withOpacityMapsTheBoundariesToAlphaZeroAndFull() { + assertThat(DocumentColor.BLACK.withOpacity(0.0).color().getAlpha()).isEqualTo(0); + assertThat(DocumentColor.BLACK.withOpacity(1.0).color().getAlpha()).isEqualTo(255); + // 0.5 rounds to 128 (Math.round of 127.5). + assertThat(DocumentColor.BLACK.withOpacity(0.5).color().getAlpha()).isEqualTo(128); + } + + @Test + void withOpacityKeepsTheRgbChannelsAndReturnsAFreshInstance() { + DocumentColor base = DocumentColor.rgb(10, 20, 30); + DocumentColor faded = base.withOpacity(0.5); + + assertThat(faded).isNotSameAs(base); + assertThat(faded.color().getRed()).isEqualTo(10); + assertThat(faded.color().getGreen()).isEqualTo(20); + assertThat(faded.color().getBlue()).isEqualTo(30); + // The source is unchanged (immutable value type). + assertThat(base.color().getAlpha()).isEqualTo(255); + } + + @Test + void rgbaCarriesTheAlphaChannel() { + DocumentColor color = DocumentColor.rgba(10, 20, 30, 128); + + assertThat(color.color().getAlpha()).isEqualTo(128); + assertThat(color.color().getRed()).isEqualTo(10); + } +} diff --git a/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java b/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java index 427b3d91e..685598d53 100644 --- a/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java +++ b/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java @@ -219,4 +219,49 @@ void checkmarkAndArrowStyleOverloadsRejectNullStyle() { assertThatThrownBy(() -> ShapeOutline.arrow(10, 10, ShapeOutline.Direction.RIGHT, null)) .isInstanceOf(NullPointerException.class); } + + @Test + void pathOutlineKeepsItsSizeAndSegments() { + ShapeOutline.Path outline = ShapeOutline.path(40, 20, List.of( + DocumentPathSegment.moveTo(0, 0), + DocumentPathSegment.lineTo(1, 0), + DocumentPathSegment.lineTo(0.5, 1), + DocumentPathSegment.close())); + + assertThat(outline.width()).isEqualTo(40.0, within(EPS)); + assertThat(outline.height()).isEqualTo(20.0, within(EPS)); + assertThat(outline.segments()).hasSize(4); + } + + @Test + void pathOutlineRejectsTooFewSegmentsAndNonMoveToStart() { + assertThatThrownBy(() -> ShapeOutline.path(10, 10, + List.of(DocumentPathSegment.moveTo(0, 0)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least a MoveTo"); + assertThatThrownBy(() -> ShapeOutline.path(10, 10, + List.of(DocumentPathSegment.lineTo(0, 0), DocumentPathSegment.lineTo(1, 1)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must start with a MoveTo"); + } + + @Test + void pathOutlineRejectsNullSegments() { + assertThatThrownBy(() -> ShapeOutline.path(10, 10, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("segments"); + } + + @Test + void pathOutlineCopiesItsSegmentsDefensively() { + List mutable = new ArrayList<>(List.of( + DocumentPathSegment.moveTo(0, 0), + DocumentPathSegment.lineTo(1, 1))); + ShapeOutline.Path outline = ShapeOutline.path(10, 10, mutable); + mutable.clear(); + + assertThat(outline.segments()).hasSize(2); + assertThatThrownBy(() -> outline.segments().add(DocumentPathSegment.close())) + .isInstanceOf(UnsupportedOperationException.class); + } } diff --git a/src/test/java/com/demcha/documentation/PublicApiNoEngineLeakTest.java b/src/test/java/com/demcha/documentation/PublicApiNoEngineLeakTest.java index dd6ca59ec..946c17468 100644 --- a/src/test/java/com/demcha/documentation/PublicApiNoEngineLeakTest.java +++ b/src/test/java/com/demcha/documentation/PublicApiNoEngineLeakTest.java @@ -35,6 +35,7 @@ class PublicApiNoEngineLeakTest { PROJECT_ROOT.resolve("src/main/java/com/demcha/compose/GraphCompose.java"), PROJECT_ROOT.resolve("src/main/java/com/demcha/compose/document/api"), PROJECT_ROOT.resolve("src/main/java/com/demcha/compose/document/chart"), + PROJECT_ROOT.resolve("src/main/java/com/demcha/compose/document/svg"), PROJECT_ROOT.resolve("src/main/java/com/demcha/compose/document/dsl"), PROJECT_ROOT.resolve("src/main/java/com/demcha/compose/document/node"), PROJECT_ROOT.resolve("src/main/java/com/demcha/compose/document/style"),