diff --git a/core/src/main/java/io/github/eschizoid/telescope/FromMap.java b/core/src/main/java/io/github/eschizoid/telescope/FromMap.java index d00360fd..66260c50 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/FromMap.java +++ b/core/src/main/java/io/github/eschizoid/telescope/FromMap.java @@ -6,9 +6,11 @@ import io.github.eschizoid.telescope.internal.NullDefaults; import io.github.eschizoid.telescope.internal.Records; import io.github.eschizoid.telescope.internal.pairing.PropertyNames; +import io.github.eschizoid.telescope.introspection.OpticNode; import io.github.eschizoid.telescope.mapping.Extract; import io.github.eschizoid.telescope.mapping.MapExtractStep; import java.lang.reflect.RecordComponent; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; @@ -67,7 +69,20 @@ static ForwardMapper, T> build(final Class target, fi final Function, T> forward = target.isRecord() ? recordForward(target, byField) : beanForward(target, byField); - return ForwardMapper.create(forward, (Class>) (Class) Map.class, target); + // The slot alignment above already decided every component's fate — surface those decisions + // as the explain() trail instead of throwing them away: one Transformed row per extract + // (map key → component, through the row's converter), one MISSING_SOURCE skip per defaulted + // slot. The report is derived from the same data the forward path runs on, so it cannot drift. + final var trail = new ArrayList(known.size()); + for (final var comp : known) { + final var row = byField.get(comp); + if (row != null) { + trail.add(new OpticNode.Transformed(row.key(), comp, "map value", "converted")); + } else { + trail.add(new OpticNode.Skipped(comp, OpticNode.Reason.MISSING_SOURCE)); + } + } + return ForwardMapper.create(forward, (Class>) (Class) Map.class, target, trail); } /** diff --git a/core/src/main/java/io/github/eschizoid/telescope/Merge.java b/core/src/main/java/io/github/eschizoid/telescope/Merge.java index 39613564..1d109b3b 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/Merge.java +++ b/core/src/main/java/io/github/eschizoid/telescope/Merge.java @@ -5,6 +5,8 @@ import io.github.eschizoid.telescope.internal.Records; import io.github.eschizoid.telescope.internal.Reflective; import io.github.eschizoid.telescope.internal.optics.Getter; +import io.github.eschizoid.telescope.internal.pairing.PropertyNames; +import io.github.eschizoid.telescope.introspection.OpticNode; import io.github.eschizoid.telescope.mapping.MergeStep; import java.util.ArrayList; import java.util.HashMap; @@ -66,9 +68,22 @@ static Mapper build(final Class target, final MergeStep[] ); }; - // A null patch table signals "patch unsupported" — Mapper.patch then throws the same + // Surface the build-time slot decisions as the explain() trail: one Mapped row per bound + // component (SourceClass.field → target field), one MISSING_SOURCE skip per unbound one. + // Derived from the same plan the forward path runs on, so the report cannot drift. + final var names = targetRefl.names(target); + final var trail = new ArrayList(names.length); + for (final var name : names) { + final var r = planByTgt.get(name); + if (r != null) { + trail.add(new OpticNode.Mapped(r.sourceClass().getSimpleName() + "." + r.srcName(), r.tgtName())); + } else { + trail.add(new OpticNode.Skipped(name, OpticNode.Reason.MISSING_SOURCE)); + } + } + // A null patch table signals "patch unsupported" — Mapper.patch/into then throw the same // UnsupportedOperationException shape backward does, instead of silently no-op-ing. - return Mapper.create(forward, backward, Sources.class, target, null); + return Mapper.create(forward, backward, Sources.class, target, null, trail); } /** @@ -187,7 +202,14 @@ private static void resolveStep( "." ); claimTarget(tgtName, index, claimedTgt); - out.add(new ResolvedStep(srcClass, srcAccessor, tgtName)); + out.add( + new ResolvedStep( + srcClass, + srcAccessor, + PropertyNames.property(LambdaIntrospection.methodNameOf(r.src())), + tgtName + ) + ); return; } throw new IllegalStateException("unreachable: MergeStep is sealed"); @@ -231,7 +253,7 @@ private static void resolveAutoBackfill( ); claimedTgt.add(name); final Getter reader = src -> sourceRefl.read(src, name); - out.add(new ResolvedStep(sourceClass, reader, name)); + out.add(new ResolvedStep(sourceClass, reader, name, name)); } } @@ -273,5 +295,5 @@ private static Getter asGetter(final Telescope.Accessor accessor // ResolvedStep holds the row's per-call dispatch triple. `srcAccessor` is typed as the lattice's // `Getter` rather than a raw `Function` — see CLAUDE.md's "Lattice-first" rule. // `sourceClass` keys into Sources.byClass at forward time. - record ResolvedStep(Class sourceClass, Getter srcAccessor, String tgtName) {} + record ResolvedStep(Class sourceClass, Getter srcAccessor, String srcName, String tgtName) {} } diff --git a/core/src/main/java/io/github/eschizoid/telescope/Telescope.java b/core/src/main/java/io/github/eschizoid/telescope/Telescope.java index 558dbca8..45edbf9f 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/Telescope.java +++ b/core/src/main/java/io/github/eschizoid/telescope/Telescope.java @@ -454,11 +454,21 @@ public static OptionalTelescope asOptional(final Telescope Telescope all(final Edit... edits) { + // The normalizer's explain() trail is the concatenation of its edits' trails, in edit order — + // each edit's path already carries its full hop description; throwing that away left every + // multi-edit product explaining as empty. + final var trail = new ArrayList(); + for (final var e : edits) { + if (e instanceof EditImpl impl) trail.addAll(impl.path().trail); + } + final var joined = Collections.unmodifiableList(trail); final var fused = Fusion.fuse(edits); - if (fused != null) return new Telescope<>(Iso.identity(), RecordFieldOptics.INSTANCE, fused); + if (fused != null) { + return new Telescope<>(Iso.identity(), RecordFieldOptics.INSTANCE, fused, null, joined, null); + } Function fold = Function.identity(); for (final var e : edits) fold = fold.andThen(e::apply); - return new Telescope<>(Iso.identity(), RecordFieldOptics.INSTANCE, fold); + return new Telescope<>(Iso.identity(), RecordFieldOptics.INSTANCE, fold, null, joined, null); } /** diff --git a/core/src/test/java/io/github/eschizoid/telescope/EngineExplainTest.java b/core/src/test/java/io/github/eschizoid/telescope/EngineExplainTest.java new file mode 100644 index 00000000..08829112 --- /dev/null +++ b/core/src/test/java/io/github/eschizoid/telescope/EngineExplainTest.java @@ -0,0 +1,107 @@ +package io.github.eschizoid.telescope; + +import static io.github.eschizoid.telescope.Edit.over; +import static io.github.eschizoid.telescope.mapping.MapExtractStep.extract; +import static io.github.eschizoid.telescope.mapping.MergeStep.auto; +import static io.github.eschizoid.telescope.mapping.MergeStep.from; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.eschizoid.telescope.introspection.OpticNode; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Pins that every engine — not just the deep mapper — surfaces its build-time decisions through + * {@code explain()}. Before this, fromMap, merge, and every multi-edit normalizer explained as + * empty while the deep mapper promised "the report cannot drift from what the mapper does". + */ +class EngineExplainTest { + + record Payment(String id, String currency, int retries) {} + + record Customer(String id, String email) {} + + record Audit(String createdBy, String createdAt) {} + + record Profile(String id, String email, String createdBy, String note) {} + + @Nested + @DisplayName("fromMap explains its slot decisions") + class FromMapExplain { + + @Test + @DisplayName("one Transformed row per extract, one MISSING_SOURCE skip per defaulted slot") + void slotDecisionsSurface() { + final var mapper = Telescope.fromMap( + Payment.class, + extract("payment_id", Payment::id, Object::toString), + extract("ccy", Payment::currency, Object::toString) + ); + + final var report = mapper.explain(); + assertFalse(report.isEmpty(), "fromMap must explain itself"); + assertEquals( + List.of(new OpticNode.Transformed("payment_id", "id", "map value", "converted")), + report + .transformations() + .stream() + .filter(t -> t.to().equals("id")) + .toList() + ); + assertTrue( + report.skipped().contains(new OpticNode.Skipped("retries", OpticNode.Reason.MISSING_SOURCE)), + "the defaulted slot is reported" + ); + } + } + + @Nested + @DisplayName("merge explains its plan") + class MergeExplain { + + @Test + @DisplayName("one Mapped row per bound component (SourceClass.field → target), one skip per unbound") + void planSurfaces() { + final var mapper = Telescope.merge( + Profile.class, + from(Customer::id, Profile::id), + auto(Customer.class), + from(Audit::createdBy, Profile::createdBy) + ); + + final var report = mapper.explain(); + assertFalse(report.isEmpty(), "merge must explain itself"); + assertTrue(report.mapped().contains(new OpticNode.Mapped("Customer.id", "id")), report.mapped().toString()); + assertTrue(report.mapped().contains(new OpticNode.Mapped("Customer.email", "email"))); + assertTrue(report.mapped().contains(new OpticNode.Mapped("Audit.createdBy", "createdBy"))); + assertTrue(report.skipped().contains(new OpticNode.Skipped("note", OpticNode.Reason.MISSING_SOURCE))); + } + } + + @Nested + @DisplayName("multi-edit normalizers explain their edits") + class AllExplain { + + record User(String name, String email) {} + + record Team(String label, List users) {} + + @Test + @DisplayName("the product's trail is the concatenation of the edits' trails, in edit order") + void editsTrailsConcatenate() { + final var names = Telescope.of(Team.class).each(Team::users).field(User::name); + final var label = Telescope.of(Team.class).field(Team::label); + final var product = Telescope.all(over(names, String::toUpperCase), over(label, String::trim)); + + final var expected = new ArrayList(); + expected.addAll(names.explain().nodes()); + expected.addAll(label.explain().nodes()); + assertEquals(expected, product.explain().nodes()); + } + } +}