diff --git a/build.gradle.kts b/build.gradle.kts index a8e9235c..14eafcae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -128,6 +128,24 @@ subprojects { ) } } + + // Make every module's test run informative rather than a wall of pass/fail: show per-test + // events plus anything the tests log (through java.lang.System.Logger -> JUL console), with a + // message-only JUL format so narration reads cleanly. Single source of truth for all modules. + tasks.withType().configureEach { + testLogging { + // Gradle side: stream the forked JVM's stdout/stderr live to the console. + showStandardStreams = true + events("passed", "failed", "skipped") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + // JUnit side: also capture each test's stdout/stderr and file it under that test in the + // XML/HTML reports, so narration is attached per-test, not only streamed to console. + systemProperty("junit.platform.output.capture.stdout", "true") + systemProperty("junit.platform.output.capture.stderr", "true") + // Message-only JUL format so System.Logger narration reads cleanly, no timestamp/source noise. + systemProperty("java.util.logging.SimpleFormatter.format", "%5\$s%n") + } } jreleaser { diff --git a/examples/mapstruct-vs-telescope/README.md b/examples/mapstruct-vs-telescope/README.md index 623f7fdb..1aab660d 100644 --- a/examples/mapstruct-vs-telescope/README.md +++ b/examples/mapstruct-vs-telescope/README.md @@ -111,6 +111,57 @@ copy-with-changes, or an optics library) the moment you step past mapping. --- +## Act 3 — telescope explains and traces itself; MapStruct is a black box + +Acts 1 and 2 were about _writing_ the mapping. Act 3 is about **seeing it**. Every telescope mapper answers two +questions MapStruct structurally cannot: its structure lives only in generated `…MapperImpl.java` you go read, and its +runtime behaviour is whatever you hand-instrument. + +**`explain()` — the static structure, as data.** The Act 1 rename isn't a string buried in generated code; it's a row +you can print or assert on: + +```java +TelescopeMappings.CUSTOMER_MAPPER.explain(); +// Mapped: +// ✓ email → contactEmail +// ✓ name → name +``` + +That `✓ email → contactEmail` is the exact override from Act 1, now a first-class correspondence. The test asserts on it +directly — `explain().mapped()` contains `("email", "contactEmail")` — a completeness check MapStruct offers no surface +for. + +**`trace(input)` — the same rows with real values, whole nested graph.** For one `Order`: + +```java +TelescopeMappings.ORDER_MAPPER.trace(order); +// ✓ id "o-1" → id "o-1" +// • customer Customer[name=Ada, email=ada@example.com] → customer CustomerDto[name=Ada, contactEmail=ada@example.com] +// • lines [LineItem[sku=sku-1, …], …] → lines [LineItemDto[sku=sku-1, …], …] +``` + +**Auto-logging — flip a level, no code change.** telescope logs its own `explain()` at `DEBUG` and every conversion's +`trace()` at `TRACE` through `java.lang.System.Logger` (java.base, zero dependency). With the JDK's default backend it +routes through `java.util.logging` (`TRACE` maps to JUL `FINER`); name the type-pair logger and every mapping narrates +itself: + +```properties +# logging.properties — the zero-dependency JDK default +io.github.eschizoid.telescope.mapper.Order.OrderDto.level = FINER +``` + +The same facade reaches any backend through its own level syntax — Logback +(``) or Spring Boot (`logging.level.…=TRACE`). + +MapStruct's generated `OrderMapStructMapperImpl` is opaque: to see what it mapped you read generated source; to see +values at runtime you instrument it by hand. telescope makes both first-class — structure you can assert on, values you +can flip on. + +> This slice's own tests prove the point: every act narrates what it proves through `System.Logger`. Run +> `./gradlew :examples:mapstruct-vs-telescope:test` and read the walkthrough, not just the green ticks. + +--- + ## Where MapStruct is still the right call Being fair is the point of a reproducible comparison: diff --git a/examples/mapstruct-vs-telescope/build.gradle.kts b/examples/mapstruct-vs-telescope/build.gradle.kts index 3cbaa7aa..d47ad50e 100644 --- a/examples/mapstruct-vs-telescope/build.gradle.kts +++ b/examples/mapstruct-vs-telescope/build.gradle.kts @@ -37,4 +37,7 @@ dependencies { tasks.named("test") { useJUnitPlatform() + // The test-output plumbing (showStandardStreams + per-test events + capture + clean JUL format) + // is global — set once for every module in the root build's subprojects { } block. This slice's + // tests add the per-act narration on top of it. } diff --git a/examples/mapstruct-vs-telescope/src/main/java/io/github/eschizoid/telescope/example/mapstruct/telescope/TelescopeMappings.java b/examples/mapstruct-vs-telescope/src/main/java/io/github/eschizoid/telescope/example/mapstruct/telescope/TelescopeMappings.java index 9463efe3..76ed536a 100644 --- a/examples/mapstruct-vs-telescope/src/main/java/io/github/eschizoid/telescope/example/mapstruct/telescope/TelescopeMappings.java +++ b/examples/mapstruct-vs-telescope/src/main/java/io/github/eschizoid/telescope/example/mapstruct/telescope/TelescopeMappings.java @@ -33,6 +33,19 @@ private TelescopeMappings() {} to(Customer::email, CustomerDto::getContactEmail) ); + /** + * Act 3 — the {@code Customer -> CustomerDto} leg on its own, so its {@code explain()} renders + * the Act 1 rename as first-class data: {@code ✓ email → contactEmail}. Same {@code to(...)} + * override as {@code ORDER_MAPPER}'s nested customer hop; here it is the whole mapper, so the + * correspondence is a top-level row you can assert on. MapStruct's equivalent decision lives only + * in generated {@code OrderMapStructMapperImpl.java}. + */ + public static final Mapper CUSTOMER_MAPPER = Telescope.mapper( + Customer.class, + CustomerDto.class, + to(Customer::email, CustomerDto::getContactEmail) + ); + /** * Act 2 — deep immutable update, kept as a reusable path value that mirrors {@code * ORDER_MAPPER}: a path is a thing you store, not a call you re-spell. The same {@code Telescope} diff --git a/examples/mapstruct-vs-telescope/src/test/java/io/github/eschizoid/telescope/example/mapstruct/MapStructVsTelescopeTest.java b/examples/mapstruct-vs-telescope/src/test/java/io/github/eschizoid/telescope/example/mapstruct/MapStructVsTelescopeTest.java index 74f66aeb..a6ce6dc8 100644 --- a/examples/mapstruct-vs-telescope/src/test/java/io/github/eschizoid/telescope/example/mapstruct/MapStructVsTelescopeTest.java +++ b/examples/mapstruct-vs-telescope/src/test/java/io/github/eschizoid/telescope/example/mapstruct/MapStructVsTelescopeTest.java @@ -1,8 +1,10 @@ package io.github.eschizoid.telescope.example.mapstruct; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.eschizoid.telescope.example.mapstruct.domain.Customer; import io.github.eschizoid.telescope.example.mapstruct.domain.LineItem; @@ -10,6 +12,8 @@ import io.github.eschizoid.telescope.example.mapstruct.mapstruct.OrderMapStructMapper; import io.github.eschizoid.telescope.example.mapstruct.mapstruct.SilentDropMapper; import io.github.eschizoid.telescope.example.mapstruct.telescope.TelescopeMappings; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; import java.math.BigDecimal; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -25,6 +29,8 @@ */ class MapStructVsTelescopeTest { + private static final Logger LOG = System.getLogger(MapStructVsTelescopeTest.class.getName()); + private static Order sampleOrder() { return new Order( "o-1", @@ -48,6 +54,10 @@ void bothFrameworksMapToTheSameDto() { "the email -> contactEmail rename landed" ); assertEquals(2, viaTelescope.getLines().size(), "the line-item collection recursed"); + log( + "Act 1 — both frameworks produce the identical OrderDto (no strawman):", + "MapStruct: " + viaMapStruct + "\ntelescope: " + viaTelescope + ); } @Test @@ -56,6 +66,7 @@ void telescopeMapperRoundTrips() { final var order = sampleOrder(); final var roundTripped = TelescopeMappings.ORDER_MAPPER.backward(TelescopeMappings.ORDER_MAPPER.forward(order)); assertEquals(order, roundTripped, "one mapper(...) value gives both directions; MapStruct needs a second method"); + log("Bidirectional for free — backward(forward(order)) == order:", roundTripped); } @Test @@ -68,6 +79,10 @@ void mapStructSilentlyDropsUnmappedTarget() { dto.getRegion(), "default unmappedTargetPolicy=WARN compiles and nulls an unmapped target (a source rename is the separate case — a compile error)" ); + log( + "Unmapped-target footgun — MapStruct's default policy nulls a target with no source:", + "contactEmail = " + dto.getContactEmail() + " | region = " + dto.getRegion() + " (silently null)" + ); } @Test @@ -85,5 +100,40 @@ void deepUpdateRebuildsImmutably() { order.lines().get(0).price(), "the original Order is unchanged — immutable update" ); + log( + "Act 2 — deep immutable update rebuilds the graph, original untouched:", + "before: " + order.lines() + "\nafter: " + taxed.lines() + ); + } + + @Test + @DisplayName("Act 3: the telescope mapper explains and traces itself; MapStruct is a black box") + void introspectionExposesWhatTheMapperDoes() { + final var order = sampleOrder(); + + // Static structure — no input needed. The Act 1 rename is a first-class row here, not a string + // buried in generated OrderMapStructMapperImpl.java. + final var report = TelescopeMappings.CUSTOMER_MAPPER.explain(); + assertFalse(report.isEmpty(), "the mapper describes its own structure"); + assertTrue( + report + .mapped() + .stream() + .anyMatch(m -> m.from().equals("email") && m.to().equals("contactEmail")), + () -> "the email -> contactEmail rename is enumerable data, not opaque generated code:\n" + report + ); + log("Act 3 — CUSTOMER_MAPPER.explain() (structure as data; MapStruct has no equivalent):", report); + + // Per-conversion values — the same rows with the actual Order data filled in, whole graph deep. + final var trace = TelescopeMappings.ORDER_MAPPER.trace(order).toString(); + assertTrue(trace.contains("o-1"), () -> "the trace shows the real values flowing through:\n" + trace); + log("Act 3 — ORDER_MAPPER.trace(order) (per-conversion values, nested graph and all):", trace); + } + + // Narrate what each act proves when the suite runs, through java.lang.System.Logger — the same + // zero-dependency facade telescope logs its own mappings through — so `./gradlew test` reads as a + // head-to-head walkthrough rather than a wall of green ticks. + private static void log(final String heading, final Object body) { + LOG.log(Level.INFO, () -> "\n" + heading + "\n" + body + "\n"); } }