From c16267ba1f47adbef096c3361242c6ab27372d25 Mon Sep 17 00:00:00 2001 From: Marco Primi Date: Mon, 20 Jul 2026 21:29:37 -0700 Subject: [PATCH 1/7] Add generated FFI files to .gitignore --- .gitignore | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f655372..a41660b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,9 @@ build result* # Ignore IDE-specific directory -.idea \ No newline at end of file +.idea + +# Ignore generated files +ffi/swig +ffi/src/main/resources +ffi/src/main/java/com/antithesis/ffi/internal/FfiWrapperJNI.java From f8e0093213526e3bf9281c688508ada30d9763b5 Mon Sep 17 00:00:00 2001 From: Marco Primi Date: Mon, 20 Jul 2026 21:30:52 -0700 Subject: [PATCH 2/7] Add additional tests of SDK package Only a small surface of the SDK was tested, and those tests were superficial, not verifying actual state. This change covers the SDK surface more thoroughly by verifying the object passed down to the FFI layer. This is done by adding a seam (via reflection) that captures said objects. --- .../sdk/AssertBooleanGuidanceTest.java | 106 +++++++++++++ .../antithesis/sdk/AssertConcurrencyTest.java | 85 +++++++++++ .../com/antithesis/sdk/AssertDedupTest.java | 72 +++++++++ .../antithesis/sdk/AssertEdgeCaseTest.java | 88 +++++++++++ .../com/antithesis/sdk/AssertMatrixTest.java | 97 ++++++++++++ .../sdk/AssertNumericGuidanceTest.java | 141 ++++++++++++++++++ .../com/antithesis/sdk/AssertRawTest.java | 68 +++++++++ .../com/antithesis/sdk/CaptureSupport.java | 141 ++++++++++++++++++ 8 files changed, 798 insertions(+) create mode 100644 sdk/src/test/java/com/antithesis/sdk/AssertBooleanGuidanceTest.java create mode 100644 sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java create mode 100644 sdk/src/test/java/com/antithesis/sdk/AssertDedupTest.java create mode 100644 sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java create mode 100644 sdk/src/test/java/com/antithesis/sdk/AssertMatrixTest.java create mode 100644 sdk/src/test/java/com/antithesis/sdk/AssertNumericGuidanceTest.java create mode 100644 sdk/src/test/java/com/antithesis/sdk/AssertRawTest.java create mode 100644 sdk/src/test/java/com/antithesis/sdk/CaptureSupport.java diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertBooleanGuidanceTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertBooleanGuidanceTest.java new file mode 100644 index 0000000..a5f4575 --- /dev/null +++ b/sdk/src/test/java/com/antithesis/sdk/AssertBooleanGuidanceTest.java @@ -0,0 +1,106 @@ +package com.antithesis.sdk; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Covers {@link Assert#alwaysSome} and {@link Assert#sometimesAll}: their + * boolean condition semantics (OR / AND), the boolean guidance direction, and + * the propositions carried in guidance_data. + */ +public class AssertBooleanGuidanceTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private CaptureSupport capture; + + @BeforeEach + void setUp() { + capture = CaptureSupport.install(); + } + + private ObjectNode details() { + return mapper.createObjectNode(); + } + + private Map map(final Boolean a, final Boolean b) { + Map m = new LinkedHashMap<>(); + m.put("a", a); + m.put("b", b); + return m; + } + + private JsonNode assertion(final String id) { + return capture.assertionsFor(id).get(0); + } + + private JsonNode guidance(final String id) { + return capture.guidanceFor(id).get(0); + } + + @Test + void alwaysSomeIsTrueWhenAnyConditionTrue() { + Assert.alwaysSome(map(false, true), "some-any-true", details()); + assertEquals(true, assertion("some-any-true").get("condition").asBoolean()); + } + + @Test + void alwaysSomeIsFalseWhenAllConditionsFalse() { + Assert.alwaysSome(map(false, false), "some-all-false", details()); + assertEquals(false, assertion("some-all-false").get("condition").asBoolean()); + } + + @Test + void alwaysSomeGuidanceShape() { + Assert.alwaysSome(map(true, false), "some-shape", details()); + JsonNode g = guidance("some-shape"); + assertEquals("boolean", g.get("guidance_type").asText(), "guidance_type"); + assertEquals(false, g.get("maximize").asBoolean(), "alwaysSome uses maximize=false"); + assertEquals(true, g.get("guidance_data").get("a").asBoolean(), "proposition a"); + assertEquals(false, g.get("guidance_data").get("b").asBoolean(), "proposition b"); + } + + @Test + void sometimesAllIsTrueWhenNoConditionFalse() { + Assert.sometimesAll(map(true, true), "all-none-false", details()); + assertEquals(true, assertion("all-none-false").get("condition").asBoolean()); + } + + @Test + void sometimesAllIsFalseWhenAnyConditionFalse() { + Assert.sometimesAll(map(true, false), "all-any-false", details()); + assertEquals(false, assertion("all-any-false").get("condition").asBoolean()); + } + + @Test + void sometimesAllGuidanceShape() { + Assert.sometimesAll(map(true, true), "all-shape", details()); + JsonNode g = guidance("all-shape"); + assertEquals("boolean", g.get("guidance_type").asText(), "guidance_type"); + assertEquals(true, g.get("maximize").asBoolean(), "sometimesAll uses maximize=true"); + } + + // --- empty-map corner cases (identity of OR is false, identity of AND is true) --- + + @Test + void alwaysSomeWithEmptyMapIsFalse() { + Assert.alwaysSome(new LinkedHashMap<>(), "some-empty", details()); + assertEquals(false, assertion("some-empty").get("condition").asBoolean(), + "OR of no conditions is false"); + } + + @Test + void sometimesAllWithEmptyMapIsTrue() { + Assert.sometimesAll(new LinkedHashMap<>(), "all-empty", details()); + assertEquals(true, assertion("all-empty").get("condition").asBoolean(), + "AND of no conditions is true"); + } +} diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java new file mode 100644 index 0000000..204fa55 --- /dev/null +++ b/sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java @@ -0,0 +1,85 @@ +package com.antithesis.sdk; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The de-duplication trackers use ConcurrentHashMap + AtomicInteger. Hitting a + * single assertion id concurrently from many threads must still emit exactly one + * "pass" and one "fail". + */ +public class AssertConcurrencyTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private CaptureSupport capture; + + @BeforeEach + void setUp() { + capture = CaptureSupport.install(); + } + + @Test + void concurrentPassesEmitExactlyOnce() throws InterruptedException { + final int threads = 16; + final int perThread = 100; + final CountDownLatch start = new CountDownLatch(1); + final CountDownLatch done = new CountDownLatch(threads); + + for (int t = 0; t < threads; t++) { + new Thread(() -> { + try { + start.await(); + for (int i = 0; i < perThread; i++) { + Assert.always(true, "concurrent-pass", mapper.createObjectNode()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }).start(); + } + + start.countDown(); + done.await(); + + assertEquals(1, capture.assertionsFor("concurrent-pass").size(), + "a single passing id must be emitted exactly once even under concurrency"); + } + + @Test + void concurrentPassAndFailEmitExactlyOncePerOutcome() throws InterruptedException { + final int threads = 16; + final int perThread = 100; + final CountDownLatch start = new CountDownLatch(1); + final CountDownLatch done = new CountDownLatch(threads); + + for (int t = 0; t < threads; t++) { + final boolean condition = (t % 2 == 0); + new Thread(() -> { + try { + start.await(); + for (int i = 0; i < perThread; i++) { + Assert.always(condition, "concurrent-mixed", mapper.createObjectNode()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }).start(); + } + + start.countDown(); + done.await(); + + assertEquals(2, capture.assertionsFor("concurrent-mixed").size(), + "one emission for the first pass and one for the first fail"); + } +} diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertDedupTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertDedupTest.java new file mode 100644 index 0000000..9c71644 --- /dev/null +++ b/sdk/src/test/java/com/antithesis/sdk/AssertDedupTest.java @@ -0,0 +1,72 @@ +package com.antithesis.sdk; + +import com.antithesis.sdk.Assert.AssertType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Exercises the de-duplication contract implemented in + * {@code Assertion.trackEntry}: for a given id, only the first pass and the + * first fail should be emitted, catalog entries (hit=false) should always be + * emitted, and a pass followed by a fail should produce two emissions. + */ +public class AssertDedupTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private CaptureSupport capture; + + @BeforeEach + void setUp() { + capture = CaptureSupport.install(); + } + + private ObjectNode details() { + return mapper.createObjectNode(); + } + + @Test + void repeatedPassesEmitOnlyOnce() { + for (int i = 0; i < 5; i++) { + Assert.always(true, "dedup-pass", details()); + } + assertEquals(1, capture.assertionsFor("dedup-pass").size(), + "only the first passing hit should be emitted"); + } + + @Test + void repeatedFailsEmitOnlyOnce() { + for (int i = 0; i < 5; i++) { + Assert.always(false, "dedup-fail", details()); + } + assertEquals(1, capture.assertionsFor("dedup-fail").size(), + "only the first failing hit should be emitted"); + } + + @Test + void firstPassAndFirstFailBothEmit() { + Assert.always(true, "dedup-pass-then-fail", details()); + Assert.always(false, "dedup-pass-then-fail", details()); + Assert.always(true, "dedup-pass-then-fail", details()); + Assert.always(false, "dedup-pass-then-fail", details()); + assertEquals(2, capture.assertionsFor("dedup-pass-then-fail").size(), + "one emission for the first pass and one for the first fail"); + } + + @Test + void catalogEntriesAlwaysEmit() { + // hit=false is a catalog entry; per the contract it must emit every time. + for (int i = 0; i < 3; i++) { + Assert.rawAssert(AssertType.Always, "Always", + "com.example.Klass", "fn", "File.java", 1, 2, + "dedup-catalog", true, "dedup-catalog", details(), + /* hit */ false, /* mustHit */ true); + } + assertEquals(3, capture.assertionsFor("dedup-catalog").size(), + "every catalog entry (hit=false) should be emitted"); + } +} diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java new file mode 100644 index 0000000..69683d5 --- /dev/null +++ b/sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java @@ -0,0 +1,88 @@ +package com.antithesis.sdk; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +/** + * Edge-case / contract tests that pin down behaviours the public API leaves + * unspecified. Some of these encode the behaviour a well-behaved API + * should have and will fail against the current implementation; those + * failures are intentional and are reported as bugs rather than fixed here. + */ +public class AssertEdgeCaseTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private CaptureSupport capture; + + @BeforeEach + void setUp() { + capture = CaptureSupport.install(); + } + + private ObjectNode details() { + return mapper.createObjectNode(); + } + + /** + * A helper that merges left/right into the details should not mutate the + * caller-supplied {@code ObjectNode}. Mutating the caller's argument is a + * surprising side effect (and can clobber the caller's own keys). + */ + @Test + void numericHelperShouldNotMutateCallerDetails() { + ObjectNode userDetails = details().put("user", "value"); + Assert.alwaysGreaterThan(5.0, 3.0, "edge-no-mutate", userDetails); + + assertFalse(userDetails.has("left"), + "caller-supplied details should not have 'left' injected into it"); + assertFalse(userDetails.has("right"), + "caller-supplied details should not have 'right' injected into it"); + } + + /** + * Regardless of the mutation question above, the emitted assertion details + * must contain the merged left/right values. + */ + @Test + void numericHelperMergesLeftRightIntoEmittedDetails() { + Assert.alwaysGreaterThan(5.0, 3.0, "edge-merged", details()); + JsonNode emittedDetails = capture.assertionsFor("edge-merged").get(0).get("details"); + assertEquals(5.0, emittedDetails.get("left").asDouble(), 0.0); + assertEquals(3.0, emittedDetails.get("right").asDouble(), 0.0); + } + + @Test + void mixedNumberTypesAreComparedAsDoubles() { + Assert.alwaysGreaterThan(3, 2.5, "edge-mixed", details()); // Integer vs Double + JsonNode g = capture.guidanceFor("edge-mixed").get(0); + assertEquals(3.0, g.get("guidance_data").get("left").asDouble(), 0.0); + assertEquals(2.5, g.get("guidance_data").get("right").asDouble(), 0.0); + assertEquals(true, capture.assertionsFor("edge-mixed").get(0).get("condition").asBoolean()); + } + + /** The plain assertion methods tolerate a null details argument. */ + @Test + void plainAlwaysToleratesNullDetails() { + assertDoesNotThrow(() -> Assert.always(true, "edge-null-plain", null)); + assertEquals(1, capture.assertionsFor("edge-null-plain").size()); + } + + /** + * Documents the current (inconsistent) behaviour: the numeric helpers + * dereference details and therefore throw on null, unlike the plain methods. + */ + @Test + void numericHelperThrowsOnNullDetails_currentBehaviour() { + assertThrows(NullPointerException.class, + () -> Assert.alwaysGreaterThan(1.0, 2.0, "edge-null-numeric", null)); + } +} diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertMatrixTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertMatrixTest.java new file mode 100644 index 0000000..9bb6623 --- /dev/null +++ b/sdk/src/test/java/com/antithesis/sdk/AssertMatrixTest.java @@ -0,0 +1,97 @@ +package com.antithesis.sdk; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the exact JSON that each core {@link Assert} method emits: the + * assert_type (lower-cased), display_type, condition, hit, must_hit, and the + * passthrough of message/id/details. These pin down the semantic matrix that + * the pre-existing smoke tests never checked. + */ +public class AssertMatrixTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private CaptureSupport capture; + + @BeforeEach + void setUp() { + capture = CaptureSupport.install(); + } + + private ObjectNode details() { + return mapper.createObjectNode().put("k", "v"); + } + + private JsonNode singleAssertion(final String id) { + List found = capture.assertionsFor(id); + assertEquals(1, found.size(), "expected exactly one emitted assertion for id=" + id); + return found.get(0); + } + + private void assertCommon(final JsonNode a, final String assertType, final String displayType, + final boolean condition, final boolean hit, final boolean mustHit, + final String message) { + assertEquals(assertType, a.get("assert_type").asText(), "assert_type"); + assertEquals(displayType, a.get("display_type").asText(), "display_type"); + assertEquals(condition, a.get("condition").asBoolean(), "condition"); + assertEquals(hit, a.get("hit").asBoolean(), "hit"); + assertEquals(mustHit, a.get("must_hit").asBoolean(), "must_hit"); + assertEquals(message, a.get("id").asText(), "id"); + assertEquals(message, a.get("message").asText(), "message"); + assertTrue(a.has("location"), "location present"); + assertTrue(a.has("details"), "details present"); + assertEquals("v", a.get("details").get("k").asText(), "details passthrough"); + } + + @Test + void alwaysEmitsExpectedShape() { + Assert.always(true, "matrix-always", details()); + assertCommon(singleAssertion("matrix-always"), + "always", "Always", true, true, true, "matrix-always"); + } + + @Test + void alwaysWithFalseConditionStillCarriesCondition() { + Assert.always(false, "matrix-always-false", details()); + assertCommon(singleAssertion("matrix-always-false"), + "always", "Always", false, true, true, "matrix-always-false"); + } + + @Test + void alwaysOrUnreachableEmitsExpectedShape() { + Assert.alwaysOrUnreachable(true, "matrix-aou", details()); + assertCommon(singleAssertion("matrix-aou"), + "always", "AlwaysOrUnreachable", true, true, false, "matrix-aou"); + } + + @Test + void sometimesEmitsExpectedShape() { + Assert.sometimes(true, "matrix-sometimes", details()); + assertCommon(singleAssertion("matrix-sometimes"), + "sometimes", "Sometimes", true, true, true, "matrix-sometimes"); + } + + @Test + void reachableEmitsExpectedShape() { + Assert.reachable("matrix-reachable", details()); + assertCommon(singleAssertion("matrix-reachable"), + "reachability", "Reachable", true, true, true, "matrix-reachable"); + } + + @Test + void unreachableEmitsExpectedShape() { + Assert.unreachable("matrix-unreachable", details()); + assertCommon(singleAssertion("matrix-unreachable"), + "reachability", "Unreachable", false, true, false, "matrix-unreachable"); + } +} diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertNumericGuidanceTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertNumericGuidanceTest.java new file mode 100644 index 0000000..faf9682 --- /dev/null +++ b/sdk/src/test/java/com/antithesis/sdk/AssertNumericGuidanceTest.java @@ -0,0 +1,141 @@ +package com.antithesis.sdk; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers numeric guidance emitted by the {@code always/sometimes *Than*} helpers: + * the guidance_data payload, the {@code maximize} direction, the "strictly better + * example" gating, and the NaN carve-out. + *

+ * The expected {@code maximize} values are taken from the reference Antithesis + * SDK convention: for a given comparison operator the "sometimes" variant uses + * the opposite direction from the "always" variant. + */ +public class AssertNumericGuidanceTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private CaptureSupport capture; + + @BeforeEach + void setUp() { + capture = CaptureSupport.install(); + } + + private ObjectNode details() { + return mapper.createObjectNode(); + } + + private JsonNode singleGuidance(final String id) { + List found = capture.guidanceFor(id); + assertEquals(1, found.size(), "expected exactly one guidance for id=" + id); + return found.get(0); + } + + @Test + void numericGuidanceCarriesLeftRightAndType() { + Assert.alwaysGreaterThan(7.0, 4.0, "num-data", details()); + JsonNode g = singleGuidance("num-data"); + assertEquals("numeric", g.get("guidance_type").asText(), "guidance_type"); + assertTrue(g.get("hit").asBoolean(), "hit"); + assertEquals("num-data", g.get("id").asText(), "id"); + JsonNode data = g.get("guidance_data"); + assertEquals(7.0, data.get("left").asDouble(), 0.0, "left"); + assertEquals(4.0, data.get("right").asDouble(), 0.0, "right"); + } + + @Test + void guidanceDataIsAlsoMergedIntoAssertionDetails() { + Assert.alwaysGreaterThan(7.0, 4.0, "num-merge", details()); + JsonNode a = capture.assertionsFor("num-merge").get(0); + assertEquals(7.0, a.get("details").get("left").asDouble(), 0.0, "left merged into details"); + assertEquals(4.0, a.get("details").get("right").asDouble(), 0.0, "right merged into details"); + } + + /** + * Reference convention: "sometimes" inverts the maximize direction relative + * to "always" for the same operator. + */ + @ParameterizedTest(name = "{0} -> maximize={1}") + @CsvSource({ + "alwaysGreaterThan,false", + "alwaysGreaterThanOrEqualTo,false", + "alwaysLessThan,true", + "alwaysLessThanOrEqualTo,true", + "sometimesGreaterThan,true", + "sometimesGreaterThanOrEqualTo,true", + "sometimesLessThan,false", + "sometimesLessThanOrEqualTo,false", + }) + void maximizeDirectionMatchesReference(final String method, final boolean expectedMaximize) { + invoke(method, 5.0, 3.0, method); + JsonNode g = singleGuidance(method); + assertEquals(expectedMaximize, g.get("maximize").asBoolean(), + method + " should emit guidance with maximize=" + expectedMaximize); + } + + private void invoke(final String method, final double left, final double right, final String message) { + ObjectNode d = details(); + switch (method) { + case "alwaysGreaterThan": + Assert.alwaysGreaterThan(left, right, message, d); break; + case "alwaysGreaterThanOrEqualTo": + Assert.alwaysGreaterThanOrEqualTo(left, right, message, d); break; + case "alwaysLessThan": + Assert.alwaysLessThan(left, right, message, d); break; + case "alwaysLessThanOrEqualTo": + Assert.alwaysLessThanOrEqualTo(left, right, message, d); break; + case "sometimesGreaterThan": + Assert.sometimesGreaterThan(left, right, message, d); break; + case "sometimesGreaterThanOrEqualTo": + Assert.sometimesGreaterThanOrEqualTo(left, right, message, d); break; + case "sometimesLessThan": + Assert.sometimesLessThan(left, right, message, d); break; + case "sometimesLessThanOrEqualTo": + Assert.sometimesLessThanOrEqualTo(left, right, message, d); break; + default: + throw new IllegalArgumentException("unknown method " + method); + } + } + + /** + * With maximize=false (as alwaysGreaterThan uses), guidance should only be + * re-emitted when a strictly smaller (left-right) is seen. + */ + @Test + void guidanceReEmittedOnlyOnStrictlyBetterExample() { + String id = "num-strictly-better"; + Assert.alwaysGreaterThan(10.0, 0.0, id, details()); // diff 10 -> emit (1) + Assert.alwaysGreaterThan(10.0, 5.0, id, details()); // diff 5 -> emit (2) + Assert.alwaysGreaterThan(10.0, 5.0, id, details()); // diff 5 -> no + Assert.alwaysGreaterThan(10.0, 3.0, id, details()); // diff 7 -> no + Assert.alwaysGreaterThan(10.0, 8.0, id, details()); // diff 2 -> emit (3) + assertEquals(3, capture.guidanceFor(id).size(), + "guidance should re-emit only on a strictly better (smaller) left-right"); + } + + /** + * A NaN (left-right) should be reported but must not corrupt the tracked + * mark: a subsequent non-improving finite value must still be suppressed. + */ + @Test + void nanIsReportedButDoesNotUpdateMark() { + String id = "num-nan"; + Assert.alwaysGreaterThan(10.0, 5.0, id, details()); // diff 5 -> emit (1), mark=5 + Assert.alwaysGreaterThan(Double.NaN, 5.0, id, details()); // diff NaN -> emit (2), mark stays 5 + Assert.alwaysGreaterThan(10.0, 5.0, id, details()); // diff 5 -> no (mark still 5) + assertEquals(2, capture.guidanceFor(id).size(), + "NaN should be reported once but must not move the mark to NaN"); + } +} diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertRawTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertRawTest.java new file mode 100644 index 0000000..434100b --- /dev/null +++ b/sdk/src/test/java/com/antithesis/sdk/AssertRawTest.java @@ -0,0 +1,68 @@ +package com.antithesis.sdk; + +import com.antithesis.sdk.Assert.AssertType; +import com.antithesis.sdk.Assert.GuidanceType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies that the low-level {@link Assert#rawAssert} and + * {@link Assert#rawGuidance} entry points pass the caller-supplied location + * information through to the emitted JSON verbatim. + */ +public class AssertRawTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private CaptureSupport capture; + + @BeforeEach + void setUp() { + capture = CaptureSupport.install(); + } + + private ObjectNode details() { + return mapper.createObjectNode(); + } + + @Test + void rawAssertPassesLocationThrough() { + Assert.rawAssert(AssertType.Sometimes, "Sometimes", + "com.example.MyClass", "myFunction", "MyClass.java", 42, 7, + "raw-assert-id", true, "raw assert message", details(), + /* hit */ true, /* mustHit */ true); + + JsonNode a = capture.assertionsFor("raw-assert-id").get(0); + assertEquals("sometimes", a.get("assert_type").asText()); + assertEquals("Sometimes", a.get("display_type").asText()); + assertEquals("raw assert message", a.get("message").asText()); + JsonNode loc = a.get("location"); + assertEquals("com.example.MyClass", loc.get("class").asText()); + assertEquals("myFunction", loc.get("function").asText()); + assertEquals("MyClass.java", loc.get("file").asText()); + assertEquals(42, loc.get("begin_line").asInt()); + assertEquals(7, loc.get("begin_column").asInt()); + } + + @Test + void rawGuidancePassesLocationAndDataThrough() { + ObjectNode data = mapper.createObjectNode().put("left", 1).put("right", 2); + Assert.rawGuidance(GuidanceType.Numeric, data, true, + "com.example.MyClass", "myFunction", "MyClass.java", 10, 3, + "raw-guidance-id", "raw guidance message", /* hit */ false); + + JsonNode g = capture.guidanceFor("raw-guidance-id").get(0); + assertEquals("numeric", g.get("guidance_type").asText()); + assertEquals(true, g.get("maximize").asBoolean()); + assertEquals("raw guidance message", g.get("message").asText()); + JsonNode loc = g.get("location"); + assertEquals("com.example.MyClass", loc.get("class").asText()); + assertEquals(10, loc.get("begin_line").asInt()); + assertEquals(3, loc.get("begin_column").asInt()); + } +} diff --git a/sdk/src/test/java/com/antithesis/sdk/CaptureSupport.java b/sdk/src/test/java/com/antithesis/sdk/CaptureSupport.java new file mode 100644 index 0000000..e7b9913 --- /dev/null +++ b/sdk/src/test/java/com/antithesis/sdk/CaptureSupport.java @@ -0,0 +1,141 @@ +package com.antithesis.sdk; + +import com.antithesis.ffi.internal.OutputHandler; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Test-only support for observing what the {@link Assert} methods emit. + *

+ * The SDK funnels every assertion/guidance through + * {@code Internal.dispatchOutput -> HandlerFactory.get().output(String)}. In a + * normal unit-test run there is no native library, so the handler resolves to a + * {@code NoOpHandler} that silently discards output, which is why the pre-existing + * tests could not assert on anything. + *

+ * This helper reflectively installs a {@link CaptureSupport} instance as the + * {@code HandlerFactory.HANDLER_INSTANCE} so emitted JSON is captured in memory, + * and reflectively clears the SDK's static de-duplication trackers between tests + * so each test starts from a clean slate. + *

+ * IMPORTANT: this class only reads/replaces internal state via + * reflection. It does not modify any production source, in keeping with the + * "tests only" constraint. + */ +final class CaptureSupport implements OutputHandler { + + private static final ObjectMapper MAPPER = new ObjectMapper() + // The SDK writes Double.NaN / Infinity as bare NaN / Infinity tokens; + // allow the capturing reader to parse them back. + .configure(JsonParser.Feature.ALLOW_NON_NUMERIC_NUMBERS, true); + + private final List emitted = new CopyOnWriteArrayList<>(); + + @Override + public void output(final String value) { + try { + emitted.add(MAPPER.readTree(value)); + } catch (Exception e) { + throw new RuntimeException("Captured output was not valid JSON: " + value, e); + } + } + + @Override + public long random() { + return 0L; + } + + // ---- installation / reset ------------------------------------------------- + + /** + * Installs a fresh capturing handler and clears the static trackers. + * Call from a {@code @BeforeEach}. + */ + static CaptureSupport install() { + CaptureSupport capture = new CaptureSupport(); + setStaticField("com.antithesis.sdk.internal.HandlerFactory", "HANDLER_INSTANCE", capture); + clearTrackers(); + return capture; + } + + static void clearTrackers() { + clearStaticMap("com.antithesis.sdk.internal.Assertion", "TRACKER"); + clearStaticMap("com.antithesis.sdk.internal.Guidance", "NUMERIC_TRACKERS"); + } + + // ---- queries over what was emitted --------------------------------------- + + /** The inner objects under the {@code "antithesis_assert"} wrapper key. */ + List assertions() { + return unwrap("antithesis_assert"); + } + + /** The inner objects under the {@code "antithesis_guidance"} wrapper key. */ + List guidance() { + return unwrap("antithesis_guidance"); + } + + List assertionsFor(final String id) { + return withId(assertions(), id); + } + + List guidanceFor(final String id) { + return withId(guidance(), id); + } + + private List unwrap(final String wrapperKey) { + List out = new ArrayList<>(); + for (JsonNode node : emitted) { + if (node.has(wrapperKey)) { + out.add(node.get(wrapperKey)); + } + } + return out; + } + + private static List withId(final List nodes, final String id) { + List out = new ArrayList<>(); + for (JsonNode node : nodes) { + JsonNode idNode = node.get("id"); + if (idNode != null && id.equals(idNode.asText())) { + out.add(node); + } + } + return out; + } + + // ---- reflection plumbing -------------------------------------------------- + + private static void setStaticField(final String className, final String fieldName, final Object value) { + try { + Class clazz = Class.forName(className); + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(null, value); + } catch (Exception e) { + throw new RuntimeException("Unable to set " + className + "." + fieldName, e); + } + } + + @SuppressWarnings("unchecked") + private static void clearStaticMap(final String className, final String fieldName) { + try { + Class clazz = Class.forName(className); + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + Object value = field.get(null); + if (value instanceof Map) { + ((Map) value).clear(); + } + } catch (Exception e) { + throw new RuntimeException("Unable to clear " + className + "." + fieldName, e); + } + } +} From d4a1127174baf4f243c81757b112f57e7589af25 Mon Sep 17 00:00:00 2001 From: Marco Primi Date: Tue, 21 Jul 2026 12:03:32 -0700 Subject: [PATCH 3/7] Bug: fix numeric guidance Maximize for `sometimes{Less,Greater}Than*` was not consistent with Rust and Go implementations. Bring it in line. --- sdk/src/main/java/com/antithesis/sdk/Assert.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/src/main/java/com/antithesis/sdk/Assert.java b/sdk/src/main/java/com/antithesis/sdk/Assert.java index 76b3463..f079501 100644 --- a/sdk/src/main/java/com/antithesis/sdk/Assert.java +++ b/sdk/src/main/java/com/antithesis/sdk/Assert.java @@ -450,7 +450,7 @@ public static void sometimesGreaterThan(final T left, final T ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); sometimesHelper(leftValue > rightValue, message, detailsExtended); - guidanceHelper(GuidanceType.Numeric, guidanceData, false, message); + guidanceHelper(GuidanceType.Numeric, guidanceData, true, message); } /** @@ -475,7 +475,7 @@ public static void sometimesGreaterThanOrEqualTo(final T left ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); sometimesHelper(leftValue >= rightValue, message, detailsExtended); - guidanceHelper(GuidanceType.Numeric, guidanceData, false, message); + guidanceHelper(GuidanceType.Numeric, guidanceData, true, message); } /** @@ -500,7 +500,7 @@ public static void sometimesLessThan(final T left, final T ri ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); sometimesHelper(leftValue < rightValue, message, detailsExtended); - guidanceHelper(GuidanceType.Numeric, guidanceData, true, message); + guidanceHelper(GuidanceType.Numeric, guidanceData, false, message); } /** @@ -525,7 +525,7 @@ public static void sometimesLessThanOrEqualTo(final T left, f ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); sometimesHelper(leftValue <= rightValue, message, detailsExtended); - guidanceHelper(GuidanceType.Numeric, guidanceData, true, message); + guidanceHelper(GuidanceType.Numeric, guidanceData, false, message); } /** From fe2aed7b06aba06cf4bd2efd5ddef91c8b472fab Mon Sep 17 00:00:00 2001 From: Marco Primi Date: Tue, 21 Jul 2026 12:20:49 -0700 Subject: [PATCH 4/7] Bug: handle null details gracefully (and more) Address the following: - *LessThan and *GreaterThan assertions would throw NPE if the user passed a `null` detail object - Guidance data would modify user's details object in-place, which could lead to undesireable and unexpected side-effects - A new ObjectMapper would be created in each assertion This change switches to a single static object mapper. The user's details object is copied rather than being modified in place Null details are handled without raising NPE. --- .../main/java/com/antithesis/sdk/Assert.java | 53 ++++++++++++------- .../antithesis/sdk/AssertEdgeCaseTest.java | 24 ++++++--- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/sdk/src/main/java/com/antithesis/sdk/Assert.java b/sdk/src/main/java/com/antithesis/sdk/Assert.java index f079501..585b681 100644 --- a/sdk/src/main/java/com/antithesis/sdk/Assert.java +++ b/sdk/src/main/java/com/antithesis/sdk/Assert.java @@ -22,12 +22,25 @@ */ final public class Assert { + private static final ObjectMapper MAPPER = new ObjectMapper(); + /** * Default constructor */ public Assert() { } + /** + * Returns a new details node with {@code guidanceData} merged in, without + * mutating the caller-supplied {@code details}. A {@code null} details + * argument is treated as an empty object. + */ + private static ObjectNode mergeGuidance(final ObjectNode details, final ObjectNode guidanceData) { + ObjectNode merged = (details == null) ? MAPPER.createObjectNode() : details.deepCopy(); + merged.setAll(guidanceData); + return merged; + } + /** * Assert that condition is true every time this function is called, and that it is * called at least once. The corresponding test property will be viewable in the Antithesis SDK: Always group of your triage report. @@ -347,8 +360,8 @@ private static void guidanceHelper( public static void alwaysGreaterThan(final T left, final T right, final String message, final ObjectNode details) { double leftValue = left.doubleValue(); double rightValue = right.doubleValue(); - ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); - ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); + ObjectNode guidanceData = MAPPER.createObjectNode().put("left", leftValue).put("right", rightValue); + ObjectNode detailsExtended = mergeGuidance(details, guidanceData); alwaysHelper(leftValue > rightValue, message, detailsExtended); guidanceHelper(GuidanceType.Numeric, guidanceData, false, message); } @@ -372,8 +385,8 @@ public static void alwaysGreaterThan(final T left, final T ri public static void alwaysGreaterThanOrEqualTo(final T left, final T right, final String message, final ObjectNode details) { double leftValue = left.doubleValue(); double rightValue = right.doubleValue(); - ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); - ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); + ObjectNode guidanceData = MAPPER.createObjectNode().put("left", leftValue).put("right", rightValue); + ObjectNode detailsExtended = mergeGuidance(details, guidanceData); alwaysHelper(leftValue >= rightValue, message, detailsExtended); guidanceHelper(GuidanceType.Numeric, guidanceData, false, message); } @@ -397,8 +410,8 @@ public static void alwaysGreaterThanOrEqualTo(final T left, f public static void alwaysLessThan(final T left, final T right, final String message, final ObjectNode details) { double leftValue = left.doubleValue(); double rightValue = right.doubleValue(); - ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); - ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); + ObjectNode guidanceData = MAPPER.createObjectNode().put("left", leftValue).put("right", rightValue); + ObjectNode detailsExtended = mergeGuidance(details, guidanceData); alwaysHelper(leftValue < rightValue, message, detailsExtended); guidanceHelper(GuidanceType.Numeric, guidanceData, true, message); } @@ -422,8 +435,8 @@ public static void alwaysLessThan(final T left, final T right public static void alwaysLessThanOrEqualTo(final T left, final T right, final String message, final ObjectNode details) { double leftValue = left.doubleValue(); double rightValue = right.doubleValue(); - ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); - ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); + ObjectNode guidanceData = MAPPER.createObjectNode().put("left", leftValue).put("right", rightValue); + ObjectNode detailsExtended = mergeGuidance(details, guidanceData); alwaysHelper(leftValue <= rightValue, message, detailsExtended); guidanceHelper(GuidanceType.Numeric, guidanceData, true, message); } @@ -447,8 +460,8 @@ public static void alwaysLessThanOrEqualTo(final T left, fina public static void sometimesGreaterThan(final T left, final T right, final String message, final ObjectNode details) { double leftValue = left.doubleValue(); double rightValue = right.doubleValue(); - ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); - ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); + ObjectNode guidanceData = MAPPER.createObjectNode().put("left", leftValue).put("right", rightValue); + ObjectNode detailsExtended = mergeGuidance(details, guidanceData); sometimesHelper(leftValue > rightValue, message, detailsExtended); guidanceHelper(GuidanceType.Numeric, guidanceData, true, message); } @@ -472,8 +485,8 @@ public static void sometimesGreaterThan(final T left, final T public static void sometimesGreaterThanOrEqualTo(final T left, final T right, final String message, final ObjectNode details) { double leftValue = left.doubleValue(); double rightValue = right.doubleValue(); - ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); - ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); + ObjectNode guidanceData = MAPPER.createObjectNode().put("left", leftValue).put("right", rightValue); + ObjectNode detailsExtended = mergeGuidance(details, guidanceData); sometimesHelper(leftValue >= rightValue, message, detailsExtended); guidanceHelper(GuidanceType.Numeric, guidanceData, true, message); } @@ -497,8 +510,8 @@ public static void sometimesGreaterThanOrEqualTo(final T left public static void sometimesLessThan(final T left, final T right, final String message, final ObjectNode details) { double leftValue = left.doubleValue(); double rightValue = right.doubleValue(); - ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); - ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); + ObjectNode guidanceData = MAPPER.createObjectNode().put("left", leftValue).put("right", rightValue); + ObjectNode detailsExtended = mergeGuidance(details, guidanceData); sometimesHelper(leftValue < rightValue, message, detailsExtended); guidanceHelper(GuidanceType.Numeric, guidanceData, false, message); } @@ -522,8 +535,8 @@ public static void sometimesLessThan(final T left, final T ri public static void sometimesLessThanOrEqualTo(final T left, final T right, final String message, final ObjectNode details) { double leftValue = left.doubleValue(); double rightValue = right.doubleValue(); - ObjectNode guidanceData = new ObjectMapper().createObjectNode().put("left", leftValue).put("right", rightValue); - ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); + ObjectNode guidanceData = MAPPER.createObjectNode().put("left", leftValue).put("right", rightValue); + ObjectNode detailsExtended = mergeGuidance(details, guidanceData); sometimesHelper(leftValue <= rightValue, message, detailsExtended); guidanceHelper(GuidanceType.Numeric, guidanceData, false, message); } @@ -548,9 +561,9 @@ public static void sometimesLessThanOrEqualTo(final T left, f * @see Assert#always always */ public static void alwaysSome(final Map conditions, final String message, final ObjectNode details) { - ObjectNode guidanceData = new ObjectMapper().createObjectNode(); + ObjectNode guidanceData = MAPPER.createObjectNode(); conditions.forEach(guidanceData::put); - ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); + ObjectNode detailsExtended = mergeGuidance(details, guidanceData); alwaysHelper(conditions.containsValue(true), message, detailsExtended); guidanceHelper(GuidanceType.Boolean, guidanceData, false, message); } @@ -575,9 +588,9 @@ public static void alwaysSome(final Map conditions, final Strin * @see Assert#sometimes sometimes */ public static void sometimesAll(final Map conditions, final String message, final ObjectNode details) { - ObjectNode guidanceData = new ObjectMapper().createObjectNode(); + ObjectNode guidanceData = MAPPER.createObjectNode(); conditions.forEach(guidanceData::put); - ObjectNode detailsExtended = (ObjectNode) details.setAll(guidanceData); + ObjectNode detailsExtended = mergeGuidance(details, guidanceData); sometimesHelper(!conditions.containsValue(false), message, detailsExtended); guidanceHelper(GuidanceType.Boolean, guidanceData, true, message); } diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java index 69683d5..4d71c18 100644 --- a/sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java +++ b/sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java @@ -9,7 +9,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; /** @@ -77,12 +76,25 @@ void plainAlwaysToleratesNullDetails() { } /** - * Documents the current (inconsistent) behaviour: the numeric helpers - * dereference details and therefore throw on null, unlike the plain methods. + * The numeric helpers tolerate a null details argument (like the plain + * methods) and still emit the merged left/right guidance data. */ @Test - void numericHelperThrowsOnNullDetails_currentBehaviour() { - assertThrows(NullPointerException.class, - () -> Assert.alwaysGreaterThan(1.0, 2.0, "edge-null-numeric", null)); + void numericHelperToleratesNullDetails() { + assertDoesNotThrow(() -> Assert.alwaysGreaterThan(1.0, 2.0, "edge-null-numeric", null)); + JsonNode emittedDetails = capture.assertionsFor("edge-null-numeric").get(0).get("details"); + assertEquals(1.0, emittedDetails.get("left").asDouble(), 0.0); + assertEquals(2.0, emittedDetails.get("right").asDouble(), 0.0); + } + + /** The boolean-map helpers also tolerate a null details argument. */ + @Test + void booleanHelpersTolerateNullDetails() { + java.util.Map conditions = new java.util.LinkedHashMap<>(); + conditions.put("a", true); + assertDoesNotThrow(() -> Assert.alwaysSome(conditions, "edge-null-some", null)); + assertDoesNotThrow(() -> Assert.sometimesAll(conditions, "edge-null-all", null)); + assertEquals(1, capture.assertionsFor("edge-null-some").size()); + assertEquals(1, capture.assertionsFor("edge-null-all").size()); } } From 6fc08fb60e4dfb785989ae193786455873e9fa9a Mon Sep 17 00:00:00 2001 From: Marco Primi Date: Mon, 27 Jul 2026 14:01:30 -0700 Subject: [PATCH 5/7] Replace reflection-based test capture with an explicit handler install/uninstall --- .../antithesis/sdk/internal/Assertion.java | 5 ++ .../com/antithesis/sdk/internal/Guidance.java | 5 ++ .../sdk/internal/HandlerFactory.java | 6 ++ .../sdk/AssertBooleanGuidanceTest.java | 8 ++ .../antithesis/sdk/AssertConcurrencyTest.java | 8 ++ .../com/antithesis/sdk/AssertDedupTest.java | 8 ++ .../antithesis/sdk/AssertEdgeCaseTest.java | 8 ++ .../com/antithesis/sdk/AssertMatrixTest.java | 8 ++ .../sdk/AssertNumericGuidanceTest.java | 8 ++ .../com/antithesis/sdk/AssertRawTest.java | 8 ++ .../sdk/{ => internal}/CaptureSupport.java | 78 ++++++------------- 11 files changed, 96 insertions(+), 54 deletions(-) rename sdk/src/test/java/com/antithesis/sdk/{ => internal}/CaptureSupport.java (51%) diff --git a/sdk/src/main/java/com/antithesis/sdk/internal/Assertion.java b/sdk/src/main/java/com/antithesis/sdk/internal/Assertion.java index c744b86..22affe6 100644 --- a/sdk/src/main/java/com/antithesis/sdk/internal/Assertion.java +++ b/sdk/src/main/java/com/antithesis/sdk/internal/Assertion.java @@ -67,6 +67,11 @@ public static LocationInfo getLocationInfo(final String id) { return maybeTrackingInfo.getLocationInfo(); } + // Visible for testing: clears per-assertion tracking so tests start fresh. + static void resetTracking() { + TRACKER.clear(); + } + public void trackEntry() { TrackingInfo trackingInfo = TRACKER.computeIfAbsent(this.id, (key) -> { return new TrackingInfo(this.location); diff --git a/sdk/src/main/java/com/antithesis/sdk/internal/Guidance.java b/sdk/src/main/java/com/antithesis/sdk/internal/Guidance.java index db0f132..637241f 100644 --- a/sdk/src/main/java/com/antithesis/sdk/internal/Guidance.java +++ b/sdk/src/main/java/com/antithesis/sdk/internal/Guidance.java @@ -51,6 +51,11 @@ public void serialize(GuidanceType value, JsonGenerator jsonGen, SerializerProvi @JsonProperty("hit") final private boolean hit; + // Visible for testing: clears per-guidance tracking so tests start fresh. + static void resetTracking() { + NUMERIC_TRACKERS.clear(); + } + public void trackEntry() { if (!this.hit) { this.emit(); diff --git a/sdk/src/main/java/com/antithesis/sdk/internal/HandlerFactory.java b/sdk/src/main/java/com/antithesis/sdk/internal/HandlerFactory.java index ad30899..8a66991 100644 --- a/sdk/src/main/java/com/antithesis/sdk/internal/HandlerFactory.java +++ b/sdk/src/main/java/com/antithesis/sdk/internal/HandlerFactory.java @@ -40,6 +40,12 @@ public static OutputHandler get() { return HANDLER_INSTANCE; } + // Visible for testing: install a specific output handler (e.g. an in-memory + // capture) so tests can observe what the SDK emits. + static void useHandler(final OutputHandler handler) { + HANDLER_INSTANCE = handler; + } + private static synchronized OutputHandler getInternal() { if (HANDLER_INSTANCE == null) { HANDLER_INSTANCE = diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertBooleanGuidanceTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertBooleanGuidanceTest.java index a5f4575..bbeb17d 100644 --- a/sdk/src/test/java/com/antithesis/sdk/AssertBooleanGuidanceTest.java +++ b/sdk/src/test/java/com/antithesis/sdk/AssertBooleanGuidanceTest.java @@ -1,9 +1,12 @@ package com.antithesis.sdk; +import com.antithesis.sdk.internal.CaptureSupport; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -27,6 +30,11 @@ void setUp() { capture = CaptureSupport.install(); } + @AfterEach + void cleanUp() { + CaptureSupport.uninstall(); + } + private ObjectNode details() { return mapper.createObjectNode(); } diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java index 204fa55..1d3da3a 100644 --- a/sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java +++ b/sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java @@ -1,7 +1,10 @@ package com.antithesis.sdk; +import com.antithesis.sdk.internal.CaptureSupport; + import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,6 +27,11 @@ void setUp() { capture = CaptureSupport.install(); } + @AfterEach + void cleanUp() { + CaptureSupport.uninstall(); + } + @Test void concurrentPassesEmitExactlyOnce() throws InterruptedException { final int threads = 16; diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertDedupTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertDedupTest.java index 9c71644..fc30df1 100644 --- a/sdk/src/test/java/com/antithesis/sdk/AssertDedupTest.java +++ b/sdk/src/test/java/com/antithesis/sdk/AssertDedupTest.java @@ -1,9 +1,12 @@ package com.antithesis.sdk; +import com.antithesis.sdk.internal.CaptureSupport; + import com.antithesis.sdk.Assert.AssertType; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -25,6 +28,11 @@ void setUp() { capture = CaptureSupport.install(); } + @AfterEach + void cleanUp() { + CaptureSupport.uninstall(); + } + private ObjectNode details() { return mapper.createObjectNode(); } diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java index 4d71c18..c794d99 100644 --- a/sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java +++ b/sdk/src/test/java/com/antithesis/sdk/AssertEdgeCaseTest.java @@ -1,9 +1,12 @@ package com.antithesis.sdk; +import com.antithesis.sdk.internal.CaptureSupport; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -27,6 +30,11 @@ void setUp() { capture = CaptureSupport.install(); } + @AfterEach + void cleanUp() { + CaptureSupport.uninstall(); + } + private ObjectNode details() { return mapper.createObjectNode(); } diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertMatrixTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertMatrixTest.java index 9bb6623..3fcb285 100644 --- a/sdk/src/test/java/com/antithesis/sdk/AssertMatrixTest.java +++ b/sdk/src/test/java/com/antithesis/sdk/AssertMatrixTest.java @@ -1,9 +1,12 @@ package com.antithesis.sdk; +import com.antithesis.sdk.internal.CaptureSupport; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -28,6 +31,11 @@ void setUp() { capture = CaptureSupport.install(); } + @AfterEach + void cleanUp() { + CaptureSupport.uninstall(); + } + private ObjectNode details() { return mapper.createObjectNode().put("k", "v"); } diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertNumericGuidanceTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertNumericGuidanceTest.java index faf9682..e89295d 100644 --- a/sdk/src/test/java/com/antithesis/sdk/AssertNumericGuidanceTest.java +++ b/sdk/src/test/java/com/antithesis/sdk/AssertNumericGuidanceTest.java @@ -1,9 +1,12 @@ package com.antithesis.sdk; +import com.antithesis.sdk.internal.CaptureSupport; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -33,6 +36,11 @@ void setUp() { capture = CaptureSupport.install(); } + @AfterEach + void cleanUp() { + CaptureSupport.uninstall(); + } + private ObjectNode details() { return mapper.createObjectNode(); } diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertRawTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertRawTest.java index 434100b..265e72f 100644 --- a/sdk/src/test/java/com/antithesis/sdk/AssertRawTest.java +++ b/sdk/src/test/java/com/antithesis/sdk/AssertRawTest.java @@ -1,11 +1,14 @@ package com.antithesis.sdk; +import com.antithesis.sdk.internal.CaptureSupport; + import com.antithesis.sdk.Assert.AssertType; import com.antithesis.sdk.Assert.GuidanceType; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -26,6 +29,11 @@ void setUp() { capture = CaptureSupport.install(); } + @AfterEach + void cleanUp() { + CaptureSupport.uninstall(); + } + private ObjectNode details() { return mapper.createObjectNode(); } diff --git a/sdk/src/test/java/com/antithesis/sdk/CaptureSupport.java b/sdk/src/test/java/com/antithesis/sdk/internal/CaptureSupport.java similarity index 51% rename from sdk/src/test/java/com/antithesis/sdk/CaptureSupport.java rename to sdk/src/test/java/com/antithesis/sdk/internal/CaptureSupport.java index e7b9913..132273e 100644 --- a/sdk/src/test/java/com/antithesis/sdk/CaptureSupport.java +++ b/sdk/src/test/java/com/antithesis/sdk/internal/CaptureSupport.java @@ -1,35 +1,27 @@ -package com.antithesis.sdk; +package com.antithesis.sdk.internal; import com.antithesis.ffi.internal.OutputHandler; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; /** - * Test-only support for observing what the {@link Assert} methods emit. + * Test-only support for observing what the {@code Assert} methods emit. *

* The SDK funnels every assertion/guidance through * {@code Internal.dispatchOutput -> HandlerFactory.get().output(String)}. In a * normal unit-test run there is no native library, so the handler resolves to a - * {@code NoOpHandler} that silently discards output, which is why the pre-existing - * tests could not assert on anything. - *

- * This helper reflectively installs a {@link CaptureSupport} instance as the - * {@code HandlerFactory.HANDLER_INSTANCE} so emitted JSON is captured in memory, - * and reflectively clears the SDK's static de-duplication trackers between tests - * so each test starts from a clean slate. - *

- * IMPORTANT: this class only reads/replaces internal state via - * reflection. It does not modify any production source, in keeping with the - * "tests only" constraint. + * {@code NoOpHandler} that silently discards output. This helper installs an + * in-memory {@link CaptureSupport} instance via the package-private + * {@link HandlerFactory#useHandler} test seam and resets the SDK's static + * de-duplication trackers, so each test starts from a clean slate and can assert + * on the emitted JSON. */ -final class CaptureSupport implements OutputHandler { +public final class CaptureSupport implements OutputHandler { private static final ObjectMapper MAPPER = new ObjectMapper() // The SDK writes Double.NaN / Infinity as bare NaN / Infinity tokens; @@ -58,35 +50,41 @@ public long random() { * Installs a fresh capturing handler and clears the static trackers. * Call from a {@code @BeforeEach}. */ - static CaptureSupport install() { + public static CaptureSupport install() { CaptureSupport capture = new CaptureSupport(); - setStaticField("com.antithesis.sdk.internal.HandlerFactory", "HANDLER_INSTANCE", capture); - clearTrackers(); + HandlerFactory.useHandler(capture); + Assertion.resetTracking(); + Guidance.resetTracking(); return capture; } - static void clearTrackers() { - clearStaticMap("com.antithesis.sdk.internal.Assertion", "TRACKER"); - clearStaticMap("com.antithesis.sdk.internal.Guidance", "NUMERIC_TRACKERS"); + /** + * Uninstalls the capturing handler and clears the static trackers. + * Call from a {@code @BeforeEach}. + */ + public static void uninstall() { + Assertion.resetTracking(); + Guidance.resetTracking(); + HandlerFactory.useHandler(null); } // ---- queries over what was emitted --------------------------------------- /** The inner objects under the {@code "antithesis_assert"} wrapper key. */ - List assertions() { + public List assertions() { return unwrap("antithesis_assert"); } /** The inner objects under the {@code "antithesis_guidance"} wrapper key. */ - List guidance() { + public List guidance() { return unwrap("antithesis_guidance"); } - List assertionsFor(final String id) { + public List assertionsFor(final String id) { return withId(assertions(), id); } - List guidanceFor(final String id) { + public List guidanceFor(final String id) { return withId(guidance(), id); } @@ -110,32 +108,4 @@ private static List withId(final List nodes, final String id } return out; } - - // ---- reflection plumbing -------------------------------------------------- - - private static void setStaticField(final String className, final String fieldName, final Object value) { - try { - Class clazz = Class.forName(className); - Field field = clazz.getDeclaredField(fieldName); - field.setAccessible(true); - field.set(null, value); - } catch (Exception e) { - throw new RuntimeException("Unable to set " + className + "." + fieldName, e); - } - } - - @SuppressWarnings("unchecked") - private static void clearStaticMap(final String className, final String fieldName) { - try { - Class clazz = Class.forName(className); - Field field = clazz.getDeclaredField(fieldName); - field.setAccessible(true); - Object value = field.get(null); - if (value instanceof Map) { - ((Map) value).clear(); - } - } catch (Exception e) { - throw new RuntimeException("Unable to clear " + className + "." + fieldName, e); - } - } } From 01528298b03bed6eedc9e55fc6f59b8fc24a769f Mon Sep 17 00:00:00 2001 From: Marco Primi Date: Mon, 27 Jul 2026 14:16:40 -0700 Subject: [PATCH 6/7] Add note on AssertConcurrencyTest --- sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java b/sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java index 1d3da3a..520dfd2 100644 --- a/sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java +++ b/sdk/src/test/java/com/antithesis/sdk/AssertConcurrencyTest.java @@ -32,6 +32,8 @@ void cleanUp() { CaptureSupport.uninstall(); } + //TODO: port to Hegel and expand this simple test once Hegel is in use for SDK tests + //Ref: https://github.corp.antithesis.com/antithesis/star/pull/2379#discussion_r5355 @Test void concurrentPassesEmitExactlyOnce() throws InterruptedException { final int threads = 16; From d891a2f32b379d4b1a1384aa44a8f4dee6e4d9e5 Mon Sep 17 00:00:00 2001 From: Marco Primi Date: Mon, 27 Jul 2026 14:18:34 -0700 Subject: [PATCH 7/7] Optimize merge guidance node creation - Shallow-copy details when is non-null - Avoid creating an empty node when details is null --- sdk/src/main/java/com/antithesis/sdk/Assert.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sdk/src/main/java/com/antithesis/sdk/Assert.java b/sdk/src/main/java/com/antithesis/sdk/Assert.java index 585b681..3f81aff 100644 --- a/sdk/src/main/java/com/antithesis/sdk/Assert.java +++ b/sdk/src/main/java/com/antithesis/sdk/Assert.java @@ -36,7 +36,13 @@ public Assert() { * argument is treated as an empty object. */ private static ObjectNode mergeGuidance(final ObjectNode details, final ObjectNode guidanceData) { - ObjectNode merged = (details == null) ? MAPPER.createObjectNode() : details.deepCopy(); + if (details == null) { + return guidanceData; + } + // Create a shallow copy of the details + ObjectNode merged = MAPPER.createObjectNode(); + merged.setAll(details); + // Add guidance fields on top merged.setAll(guidanceData); return merged; }