Skip to content

Commit fb89f61

Browse files
committed
test(style): native-curve clip coverage — cubic segment + inline visual
Adds a CubicTo clip test (curve survives to the clip payload, not flattened) and a self-contained ShapeClipPathVisualTest that samples the rendered pixels (triangle clip ~50% coverage, base-at-bottom) to guard a dropped/empty clip, wrong scale, or y-flip without a committed pixel baseline.
1 parent 3d1be5a commit fb89f61

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,45 @@ void pathOutlineReachesTheClipPayloadAndRenders() throws Exception {
198198
}
199199
}
200200

201+
@Test
202+
void cubicCurveOutlineKeepsItsCurveSegmentThroughTheClipPipeline() throws Exception {
203+
// Every other path-clip test is line-only. A native cubic Bézier
204+
// (curveTo) must survive to the clip payload as a CubicTo segment —
205+
// NOT flattened to lines — and render without error, since native
206+
// curves are the whole point of ShapeOutline.Path. This asserts the
207+
// clip *payload*; ShapeClipPathVisualTest covers the rendered pixels.
208+
try (DocumentSession session = GraphCompose.document()
209+
.pageSize(200, 160)
210+
.margin(DocumentInsets.of(16))
211+
.create()) {
212+
213+
session.add(new ShapeContainerBuilder()
214+
.name("Petal")
215+
.path(120.0, 90.0, java.util.List.of(
216+
com.demcha.compose.document.style.DocumentPathSegment.moveTo(0.5, 1.0),
217+
com.demcha.compose.document.style.DocumentPathSegment.cubicTo(
218+
1.1, 0.9, 1.0, 0.1, 0.5, 0.0),
219+
com.demcha.compose.document.style.DocumentPathSegment.cubicTo(
220+
0.0, 0.1, -0.1, 0.9, 0.5, 1.0),
221+
com.demcha.compose.document.style.DocumentPathSegment.close()))
222+
.fillColor(BRAND)
223+
.center(spacer("Inside", 40.0, 16.0))
224+
.build());
225+
226+
List<PlacedFragment> fragments = session.layoutGraph().fragments();
227+
int begin = indexOfPayload(fragments, ShapeClipBeginPayload.class);
228+
assertThat(begin).isGreaterThanOrEqualTo(0);
229+
ShapeOutline outline = ((ShapeClipBeginPayload) fragments.get(begin).payload()).outline();
230+
assertThat(outline).isInstanceOf(ShapeOutline.Path.class);
231+
assertThat(((ShapeOutline.Path) outline).segments())
232+
.as("the cubic segment must reach the clip payload, not be flattened to lines")
233+
.anyMatch(s -> s instanceof com.demcha.compose.document.style.DocumentPathSegment.CubicTo);
234+
235+
byte[] pdf = session.toPdfBytes();
236+
assertThat(new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)).isEqualTo("%PDF-");
237+
}
238+
}
239+
201240
@Test
202241
void svgPathBridgeProducesAPathOutline() {
203242
// path(w, h, SvgPath) clips a container to an imported icon silhouette.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.demcha.testing.visual;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.dsl.ShapeBuilder;
6+
import com.demcha.compose.document.dsl.ShapeContainerBuilder;
7+
import com.demcha.compose.document.style.ClipPolicy;
8+
import com.demcha.compose.document.style.DocumentColor;
9+
import com.demcha.compose.document.style.DocumentInsets;
10+
import com.demcha.compose.document.style.DocumentPathSegment;
11+
import com.demcha.compose.testing.visual.PdfVisualRegression;
12+
import org.junit.jupiter.api.Test;
13+
14+
import java.awt.image.BufferedImage;
15+
import java.util.List;
16+
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
19+
/**
20+
* Deterministic visual check that a {@code ShapeOutline.Path} clip actually
21+
* shapes the rendered pixels — the gap the {@code %PDF-} smoke tests cannot
22+
* cover. A full-box red layer clipped to an upward triangle must paint roughly
23+
* half the box (a triangle is half its bounding box) with the wide base at the
24+
* BOTTOM. So a dropped clip (≈100% red), an empty clip (≈0% red), a wrong scale,
25+
* and a y-flip (apex/base swapped) all fail this test.
26+
*
27+
* <p>Self-contained on purpose: it samples the in-memory render instead of
28+
* diffing a committed PNG baseline, so it stays deterministic and
29+
* anti-aliasing-tolerant across platforms without a blessed pixel reference.</p>
30+
*/
31+
class ShapeClipPathVisualTest {
32+
33+
private static final int BOX = 120;
34+
35+
@Test
36+
void pathClipShapesTheRenderedPixelsRightSideUp() throws Exception {
37+
BufferedImage page = PdfVisualRegression.standard()
38+
.renderPages(renderTriangleClip())
39+
.get(0);
40+
41+
int w = page.getWidth();
42+
int h = page.getHeight();
43+
long red = 0;
44+
long redTop = 0;
45+
long redBottom = 0;
46+
for (int y = 0; y < h; y++) {
47+
for (int x = 0; x < w; x++) {
48+
if (isRed(page.getRGB(x, y))) {
49+
red++;
50+
if (y < h / 2) {
51+
redTop++;
52+
} else {
53+
redBottom++;
54+
}
55+
}
56+
}
57+
}
58+
59+
double fraction = (double) red / ((long) w * h);
60+
assertThat(fraction)
61+
.as("clipped red triangle should cover ~half the box "
62+
+ "(clip applied, right scale) — got %.3f", fraction)
63+
.isBetween(0.30, 0.70);
64+
assertThat(redBottom)
65+
.as("wide base at the bottom, narrow apex at the top — guards a y-flip")
66+
.isGreaterThan(redTop * 2);
67+
}
68+
69+
/** A 120x120 container clipping a full-box red layer to an upward triangle. */
70+
private static byte[] renderTriangleClip() throws Exception {
71+
try (DocumentSession session = GraphCompose.document()
72+
.pageSize(BOX, BOX)
73+
.margin(DocumentInsets.zero())
74+
.create()) {
75+
session.add(new ShapeContainerBuilder()
76+
.name("TriangleClip")
77+
.path(BOX, BOX, List.of(
78+
DocumentPathSegment.moveTo(0.5, 1.0), // apex, top (y grows up)
79+
DocumentPathSegment.lineTo(1.0, 0.0), // base, bottom-right
80+
DocumentPathSegment.lineTo(0.0, 0.0), // base, bottom-left
81+
DocumentPathSegment.close()))
82+
.clipPolicy(ClipPolicy.CLIP_PATH)
83+
.layer(new ShapeBuilder()
84+
.size(BOX, BOX)
85+
.fillColor(DocumentColor.rgb(220, 20, 20))
86+
.build())
87+
.build());
88+
return session.toPdfBytes();
89+
}
90+
}
91+
92+
private static boolean isRed(int rgb) {
93+
int r = (rgb >> 16) & 0xFF;
94+
int g = (rgb >> 8) & 0xFF;
95+
int b = rgb & 0xFF;
96+
return r > 140 && g < 110 && b < 110;
97+
}
98+
}

0 commit comments

Comments
 (0)