From 3fd67805a4ea575d16ce53c95bf0dab57b4e7539 Mon Sep 17 00:00:00 2001 From: mariano Date: Sun, 5 Jul 2026 16:24:15 -0500 Subject: [PATCH 1/4] docs(examples): Act 3 (introspection) for the MapStruct head-to-head + narrated test logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capstones the slice with the differentiator MapStruct structurally lacks: - Act 3: CUSTOMER_MAPPER.explain() renders the Act 1 rename as first-class data (✓ email → contactEmail), asserted via explain().mapped(); ORDER_MAPPER.trace() shows per-conversion values across the nested graph; flip-a-level auto-logging documented. README Act 3 uses the real captured output. - Tests narrate each act through java.lang.System.Logger (no System.out), and the test task is wired with showStandardStreams + events + message-only JUL format so the run reads as a walkthrough rather than pass/fail ticks. --- examples/mapstruct-vs-telescope/README.md | 46 +++++++++++++++++++ .../mapstruct-vs-telescope/build.gradle.kts | 9 ++++ .../telescope/TelescopeMappings.java | 13 ++++++ .../mapstruct/MapStructVsTelescopeTest.java | 42 +++++++++++++++++ 4 files changed, 110 insertions(+) diff --git a/examples/mapstruct-vs-telescope/README.md b/examples/mapstruct-vs-telescope/README.md index 623f7fdb..2b20225c 100644 --- a/examples/mapstruct-vs-telescope/README.md +++ b/examples/mapstruct-vs-telescope/README.md @@ -111,6 +111,52 @@ 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). Name the type-pair logger and every +mapping narrates itself: + +```xml + +``` + +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..7b663744 100644 --- a/examples/mapstruct-vs-telescope/build.gradle.kts +++ b/examples/mapstruct-vs-telescope/build.gradle.kts @@ -37,4 +37,13 @@ dependencies { tasks.named("test") { useJUnitPlatform() + testLogging { + // Surface each test's narrated head-to-head (logged through System.Logger -> JUL console), + // not just a wall of pass/fail ticks. + showStandardStreams = true + events("passed", "failed", "skipped") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + // Message-only JUL formatting so the narration reads cleanly, without timestamp/source noise. + systemProperty("java.util.logging.SimpleFormatter.format", "%5\$s%n") } 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..3c10a643 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 CustomerMapperImpl.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..40804e65 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 @@ -86,4 +97,35 @@ void deepUpdateRebuildsImmutably() { "the original Order is unchanged — immutable update" ); } + + @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 CustomerMapperImpl.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"); + } } From 14159e1e9fdbe7f669f8fc24e5dbf583dc10a41d Mon Sep 17 00:00:00 2001 From: mariano Date: Sun, 5 Jul 2026 16:32:10 -0500 Subject: [PATCH 2/4] build: informative test logging for every module (single source in root) Adds `showStandardStreams` + per-test pass/fail/skip events + a message-only JUL format to `tasks.withType` in the root `subprojects { }` block, so every module's `./gradlew test` reads as a walkthrough (with any System.Logger narration shown cleanly) instead of a silent wall of ticks. Drops the redundant per-module block from the mapstruct-vs-telescope slice now that it is centralized. --- build.gradle.kts | 12 ++++++++++++ examples/mapstruct-vs-telescope/build.gradle.kts | 11 ++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a8e9235c..74a11bb5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -128,6 +128,18 @@ 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 { + showStandardStreams = true + events("passed", "failed", "skipped") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + systemProperty("java.util.logging.SimpleFormatter.format", "%5\$s%n") + } } jreleaser { diff --git a/examples/mapstruct-vs-telescope/build.gradle.kts b/examples/mapstruct-vs-telescope/build.gradle.kts index 7b663744..0d49f043 100644 --- a/examples/mapstruct-vs-telescope/build.gradle.kts +++ b/examples/mapstruct-vs-telescope/build.gradle.kts @@ -37,13 +37,6 @@ dependencies { tasks.named("test") { useJUnitPlatform() - testLogging { - // Surface each test's narrated head-to-head (logged through System.Logger -> JUL console), - // not just a wall of pass/fail ticks. - showStandardStreams = true - events("passed", "failed", "skipped") - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } - // Message-only JUL formatting so the narration reads cleanly, without timestamp/source noise. - systemProperty("java.util.logging.SimpleFormatter.format", "%5\$s%n") + // Informative test logging (showStandardStreams + events + clean JUL format) is configured once + // for every module in the root build's subprojects { } block. } From e5039c409d01513d1650c628ad5e3e2e64146a35 Mon Sep 17 00:00:00 2001 From: mariano Date: Sun, 5 Jul 2026 16:41:21 -0500 Subject: [PATCH 3/4] build: enable JUnit Platform per-test stdout/stderr capture Adds junit.platform.output.capture.stdout/stderr to the root test config so each test's direct stream output is filed under that test in the reports, alongside Gradle's showStandardStreams live console streaming. (System.Logger narration, routed through JUL's stream-caching ConsoleHandler, is still captured at suite level by Gradle rather than per-test.) --- build.gradle.kts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 74a11bb5..14eafcae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -134,10 +134,16 @@ subprojects { // 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") } } From 54dabe660309b13e68bb317e73c539093b308820 Mon Sep 17 00:00:00 2001 From: mariano Date: Sun, 5 Jul 2026 16:59:49 -0500 Subject: [PATCH 4/4] docs(examples): address Copilot review on the Act 3 slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix the generated-type reference: MapStruct's decision lives in OrderMapStructMapperImpl (there is no CustomerMapperImpl) — javadoc + test comment. - Make 'every act narrates' true: add System.Logger narration to the footgun and Act 2 tests, so all five acts log what they prove. - Correct the Act 3 auto-logging config example to the zero-dependency JUL default (logging.properties, TRACE -> FINER), noting the Logback/Spring Boot forms. - Clarify the slice build comment: test-output plumbing is global (root subprojects), this slice adds narration on top. --- examples/mapstruct-vs-telescope/README.md | 13 +++++++++---- examples/mapstruct-vs-telescope/build.gradle.kts | 5 +++-- .../mapstruct/telescope/TelescopeMappings.java | 2 +- .../example/mapstruct/MapStructVsTelescopeTest.java | 10 +++++++++- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/examples/mapstruct-vs-telescope/README.md b/examples/mapstruct-vs-telescope/README.md index 2b20225c..1aab660d 100644 --- a/examples/mapstruct-vs-telescope/README.md +++ b/examples/mapstruct-vs-telescope/README.md @@ -141,13 +141,18 @@ TelescopeMappings.ORDER_MAPPER.trace(order); ``` **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). Name the type-pair logger and every -mapping narrates itself: +`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: -```xml - +```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. diff --git a/examples/mapstruct-vs-telescope/build.gradle.kts b/examples/mapstruct-vs-telescope/build.gradle.kts index 0d49f043..d47ad50e 100644 --- a/examples/mapstruct-vs-telescope/build.gradle.kts +++ b/examples/mapstruct-vs-telescope/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { tasks.named("test") { useJUnitPlatform() - // Informative test logging (showStandardStreams + events + clean JUL format) is configured once - // for every module in the root build's subprojects { } block. + // 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 3c10a643..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 @@ -38,7 +38,7 @@ private TelescopeMappings() {} * 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 CustomerMapperImpl.java}. + * in generated {@code OrderMapStructMapperImpl.java}. */ public static final Mapper CUSTOMER_MAPPER = Telescope.mapper( Customer.class, 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 40804e65..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 @@ -79,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 @@ -96,6 +100,10 @@ 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 @@ -104,7 +112,7 @@ 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 CustomerMapperImpl.java. + // buried in generated OrderMapStructMapperImpl.java. final var report = TelescopeMappings.CUSTOMER_MAPPER.explain(); assertFalse(report.isEmpty(), "the mapper describes its own structure"); assertTrue(