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 f4d72d5f..558dbca8 100644 --- a/core/src/main/java/io/github/eschizoid/telescope/Telescope.java +++ b/core/src/main/java/io/github/eschizoid/telescope/Telescope.java @@ -1726,17 +1726,38 @@ public ForwardMapper asForwardMapper(final Class sourceClass, final Cla * .toList(company); * } * - *

See {@link #toListIndexed} to pair each value with its position. + *

See {@link #toListIndexed} to pair each value with its position. The returned list is + * unmodifiable on every path shape. */ public List toList(final S source) { - if (optic instanceof final Lens lens) { - if (source == null) return List.of(); - return Collections.singletonList(lens.get(source)); - } + final var out = new ArrayList(); + visitFocuses(source, a -> { + out.add(a); + return true; + }); + return Collections.unmodifiableList(out); + } + + /** + * The single normalization point for the eager read terminals ({@code toList} / {@code + * toListIndexed} / {@code count} / {@code exists}): a null root focuses nothing, a {@link Lens} + * (which includes every Iso-rooted telescope — {@code Iso} IS-A {@code Lens}) has exactly one + * focus whose value may itself be null, an {@link Affine} has zero or one, and a composed + * traversal walks its visitor. Every terminal routing through here CANNOT disagree with its + * siblings — the divergences this replaces (a null root materializing as {@code [Indexed[0, + * null]]} on one terminal and {@code []} on another) were each terminal re-implementing this + * table by hand. {@code read} / {@code find} keep their dedicated fast paths: they pull a lazy + * head, not an eager fold, and their Lens/Affine shortcuts carry real dispatch savings on the + * codegen-holder hot path. + */ + private boolean visitFocuses(final S source, final Predicate visitor) { + if (source == null) return true; // a null root focuses nothing, on every optic shape + if (optic instanceof final Lens lens) return visitor.test(lens.get(source)); if (optic instanceof final Affine affine) { - return affine.getOption(source).map(List::of).orElseGet(List::of); + final var focus = affine.getOption(source); + return focus.isEmpty() || visitor.test(focus.get()); } - return optic.toList(source); + return optic.visitWhile(source, visitor); } /** @@ -1751,22 +1772,11 @@ public List toList(final S source) { * } */ public List> toListIndexed(final S source) { - // Mirror toList's normalization exactly: the two terminals must agree on every input. The - // Lens branch also catches Iso-rooted telescopes (Iso IS-A Lens), whose unguarded identity - // visit would otherwise materialize a null root as [Indexed[0, null]] while toList says []. - if (optic instanceof final Lens lens) { - if (source == null) return List.of(); - return Collections.singletonList(new Indexed<>(0, lens.get(source))); - } - if (optic instanceof final Affine affine) { - return affine - .getOption(source) - .map(a -> List.of(new Indexed<>(0, a))) - .orElseGet(List::of); - } final var out = new ArrayList>(); - final var i = new int[] { 0 }; - optic.forEach(source, a -> out.add(new Indexed<>(i[0]++, a))); + visitFocuses(source, a -> { + out.add(new Indexed<>(out.size(), a)); + return true; + }); return Collections.unmodifiableList(out); } @@ -1782,13 +1792,12 @@ public List> toListIndexed(final S source) { * nonzero (it stops at the first match). */ public long count(final S source) { - if (optic instanceof Lens) { - return source == null ? 0L : 1L; - } - if (optic instanceof final Affine affine) { - return affine.getOption(source).isPresent() ? 1L : 0L; - } - return optic.count(source); + final var c = new long[1]; + visitFocuses(source, a -> { + c[0]++; + return true; + }); + return c[0]; } /** @@ -1796,16 +1805,10 @@ public long count(final S source) { * #count}. */ public boolean exists(final S source) { - if (optic instanceof Lens) { - return source != null; - } - if (optic instanceof final Affine affine) { - return affine.getOption(source).isPresent(); - } - // A single false-returning visit stops at the first focus: visitWhile returns false iff at - // least one element was seen. Unlike Stream.findAny(), this tolerates a null focus (a null - // intermediate hop yields a one-element [null] traversal) instead of NPE-ing on Optional.of. - return !optic.visitWhile(source, a -> false); + // A single false-returning visit stops at the first focus: visitFocuses returns false iff at + // least one focus was seen — and, unlike Stream.findAny(), a null focus counts instead of + // NPE-ing on Optional.of. + return !visitFocuses(source, a -> false); } /** diff --git a/core/src/test/java/io/github/eschizoid/telescope/ReadTerminalConsistencyTest.java b/core/src/test/java/io/github/eschizoid/telescope/ReadTerminalConsistencyTest.java new file mode 100644 index 00000000..60885f54 --- /dev/null +++ b/core/src/test/java/io/github/eschizoid/telescope/ReadTerminalConsistencyTest.java @@ -0,0 +1,200 @@ +package io.github.eschizoid.telescope; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.eschizoid.telescope.internal.pairing.PropertyNames; +import io.github.eschizoid.telescope.introspection.OpticNode; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * The suite that would have caught both prior read-terminal divergences before they shipped: every + * eager terminal must agree with every other on every telescope shape and every input shape. The + * laws, for any telescope {@code t} and source {@code s}: + * + *

+ */ +class ReadTerminalConsistencyTest { + + record Address(String city) {} + + record User(String name, Address address) {} + + record Team(String label, List users) {} + + sealed interface Event permits Created, Updated {} + + record Created(String id) implements Event {} + + record Updated(String id, String diff) implements Event {} + + private static void assertTerminalsAgree(final Telescope t, final S source) { + final var list = t.toList(source); + final var indexed = t.toListIndexed(source); + final var count = t.count(source); + final var exists = t.exists(source); + final var found = t.find(source); + + assertEquals(list.size(), count, "count must equal toList().size()"); + assertEquals(list.size(), indexed.size(), "toListIndexed must have toList's cardinality"); + assertEquals(exists, count > 0, "exists must equal count > 0"); + for (var i = 0; i < list.size(); i++) { + assertEquals(i, indexed.get(i).index(), "indexed positions are 0-based traversal order"); + assertEquals(list.get(i), indexed.get(i).value(), "indexed values mirror toList"); + } + if (found.isPresent()) { + assertTrue(exists, "a present find implies existence"); + assertEquals(list.get(0), found.get(), "find is the head of toList when present"); + } + } + + @Nested + @DisplayName("every telescope shape, every input shape") + class TheMatrix { + + @Test + @DisplayName("Iso root (Telescope.of) — null root, normal root") + void isoRoot() { + final var root = Telescope.of(User.class); + assertTerminalsAgree(root, null); + assertTerminalsAgree(root, new User("Ann", new Address("nyc"))); + } + + @Test + @DisplayName("Lens path — null root, normal, null focus, null intermediate") + void lensPath() { + final var name = Telescope.of(User.class).field(User::name); + assertTerminalsAgree(name, null); + assertTerminalsAgree(name, new User("Ann", null)); + assertTerminalsAgree(name, new User(null, null)); // null focus is still a focus + + final var city = Telescope.of(User.class).field(User::address).field(Address::city); + assertTerminalsAgree(city, new User("Ann", null)); // null intermediate propagates on reads + } + + @Test + @DisplayName("composed traversal — null root, empty container, null container, null elements") + void traversalPath() { + final var names = Telescope.of(Team.class).each(Team::users).field(User::name); + assertTerminalsAgree(names, null); + assertTerminalsAgree(names, new Team("t", List.of())); + assertTerminalsAgree(names, new Team("t", null)); + assertTerminalsAgree(names, new Team("t", List.of(new User(null, null), new User("Bo", null)))); + } + + @Test + @DisplayName("affine path (as / whenPresent) — hit, miss, null root") + void affinePath() { + record Box(Optional nick) {} + final var nick = Telescope.of(Box.class).whenPresent(Box::nick); + assertTerminalsAgree(nick, null); + assertTerminalsAgree(nick, new Box(Optional.of("n"))); + assertTerminalsAgree(nick, new Box(Optional.empty())); + assertTerminalsAgree(nick, new Box(null)); + } + + @Test + @DisplayName("genuine Affine path (.as narrow) — hit, miss, null root, null field") + void narrowPath() { + // whenPresent composes Lens.then(affine) into a Traversal; only .as(...) leaves a genuine + // Affine as the stored optic — this fixture is what exercises visitFocuses' Affine branch. + final var diff = Telescope.of(Event.class).as(Updated.class).field(Updated::diff); + assertTerminalsAgree(diff, null); + assertTerminalsAgree(diff, new Updated("e1", "d")); + assertTerminalsAgree(diff, new Created("e2")); + assertTerminalsAgree(diff, new Updated("e1", null)); + } + + @Test + @DisplayName("filtered path — hit, miss, null root") + void filteredPath() { + final var longNames = Telescope.of(Team.class) + .each(Team::users) + .field(User::name) + .filter(n -> n != null && n.length() > 2); + assertTerminalsAgree(longNames, null); + assertTerminalsAgree(longNames, new Team("t", List.of(new User("Ann", null), new User("Bo", null)))); + assertTerminalsAgree(longNames, new Team("t", List.of(new User("Bo", null)))); + } + + @Test + @DisplayName("split container form — the typed steps agree too") + void splitContainerForm() { + final var names = Telescope.of(Team.class).list(Team::users).each().field(User::name); + assertTerminalsAgree(names, null); + assertTerminalsAgree(names, new Team("t", null)); + assertTerminalsAgree(names, new Team("t", List.of(new User("Ann", null)))); + } + } + + @Nested + @DisplayName("the stored first-hop name never drifts from the trail") + class FirstHopTrailAgreement { + + // firstHopName is stored per path (it survives because bare codegen-holder lenses have a name + // but no trail); this pin makes any skew between the stored value and the trail's first + // Focus/Traverse node a red test instead of a silent misroute. Bean paths store the raw getter + // name while the trail stores the property name — PropertyNames.property normalizes both. + private static void assertFirstHopMatchesTrail(final Telescope t) { + final var trailFirst = t + .explain() + .hops() + .stream() + .flatMap(h -> + h instanceof OpticNode.Focus f + ? Stream.of(f.path()) + : h instanceof OpticNode.Traverse tr + ? Stream.of(tr.path()) + : Stream.empty() + ) + .findFirst(); + trailFirst.ifPresent(expected -> + assertEquals(expected, PropertyNames.property(t.firstHopName()), "stored first hop must match the trail") + ); + } + + @Test + @DisplayName("record paths, container steps, filters, bean paths, and fieldByName all agree") + void allShapesAgree() { + assertFirstHopMatchesTrail(Telescope.ofBean(MutableUser.class).field(MutableUser::getName)); + assertFirstHopMatchesTrail(Telescope.of(User.class).field(User::name)); + assertFirstHopMatchesTrail(Telescope.of(User.class).field(User::address).field(Address::city)); + assertFirstHopMatchesTrail(Telescope.of(Team.class).each(Team::users).field(User::name)); + assertFirstHopMatchesTrail(Telescope.of(Team.class).list(Team::users).each().field(User::name)); + assertFirstHopMatchesTrail( + Telescope.of(Team.class) + .each(Team::users) + .filter(u -> true) + .field(User::name) + ); + assertFirstHopMatchesTrail(Telescope.of(Team.class).fieldByName("label")); + } + + public static class MutableUser { + + private String name; + + public MutableUser() {} + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + } + } +} diff --git a/internal/src/test/java/io/github/eschizoid/telescope/internal/optics/FoldLaws.java b/internal/src/test/java/io/github/eschizoid/telescope/internal/optics/FoldLaws.java new file mode 100644 index 00000000..ba781e12 --- /dev/null +++ b/internal/src/test/java/io/github/eschizoid/telescope/internal/optics/FoldLaws.java @@ -0,0 +1,52 @@ +package io.github.eschizoid.telescope.internal.optics; + +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 java.util.ArrayList; + +/** + * The executable form of {@link Fold}'s documented contract: "both primitives enumerate the same + * focuses in the same order." Apply it to every optic — a future override that lets {@code + * visitWhile} drift from {@code getAll} fails here instead of surfacing as a silent read-terminal + * divergence in the DSL. + */ +final class FoldLaws { + + private FoldLaws() {} + + /** + * Assert the fold laws for {@code fold} against one source: (1) {@code visitWhile} visits exactly + * the focuses {@code getAll} streams, in the same order; (2) a full visit reports completion; (3) + * a first-focus short-circuit stops after exactly one focus and reports the stop — or reports + * completion when there are no focuses at all. + */ + static void assertFoldLaws(final Fold fold, final S source) { + final var streamed = new ArrayList(); + fold.getAll(source).forEach(streamed::add); + + final var visited = new ArrayList(); + final var completed = fold.visitWhile(source, a -> { + visited.add(a); + return true; + }); + + assertEquals(streamed, visited, "visitWhile must enumerate exactly what getAll streams, in order"); + assertTrue(completed, "an all-true visit must report completion"); + + final var seen = new ArrayList(); + final var fullyVisited = fold.visitWhile(source, a -> { + seen.add(a); + return false; // stop at the first focus + }); + if (streamed.isEmpty()) { + assertTrue(fullyVisited, "no focuses: nothing to stop at, the visit completes"); + assertTrue(seen.isEmpty()); + } else { + assertEquals(1, seen.size(), "a false-returning visitor must stop after the first focus"); + assertEquals(streamed.get(0), seen.get(0), "the stopped-at focus is getAll's head"); + assertFalse(fullyVisited, "a short-circuited visit must report the stop"); + } + } +} diff --git a/internal/src/test/java/io/github/eschizoid/telescope/internal/optics/OpticLawsTest.java b/internal/src/test/java/io/github/eschizoid/telescope/internal/optics/OpticLawsTest.java index 39dcff98..15da500e 100644 --- a/internal/src/test/java/io/github/eschizoid/telescope/internal/optics/OpticLawsTest.java +++ b/internal/src/test/java/io/github/eschizoid/telescope/internal/optics/OpticLawsTest.java @@ -9,7 +9,9 @@ import io.github.eschizoid.telescope.internal.optics.collections.Traversals; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -233,6 +235,60 @@ void isoThenLensIsLens() { } } + @Nested + @DisplayName("fold laws hold for every optic (getAll/visitWhile lockstep)") + class FoldLawCoverage { + + record Wrapper(List items) {} + + @Test + @DisplayName("every single-focus optic obeys the fold laws") + void singleFocusOptics() { + FoldLaws.assertFoldLaws(userName, ALICE); // Lens, non-null + FoldLaws.assertFoldLaws(userName, null); // Lens, null source (focuses nothing) + FoldLaws.assertFoldLaws(userIso, ALICE); // Iso — one focus, always; null source omitted: Iso.to(null) is deliberately + // unguarded + final Prism stringCase = Prism.downcast(String.class); + FoldLaws.assertFoldLaws(stringCase, "hit"); // Prism hit + FoldLaws.assertFoldLaws(stringCase, 42); // Prism miss (no focuses) + final Affine, String> head = Affine.of( + l -> l.isEmpty() ? Optional.empty() : Optional.of(l.get(0)), + (l, v) -> l + ); + FoldLaws.assertFoldLaws(head, List.of("a", "b")); // Affine present + FoldLaws.assertFoldLaws(head, List.of()); // Affine absent + } + + @Test + @DisplayName("every container traversal obeys the fold laws, including null and empty sources") + void containerTraversals() { + FoldLaws.assertFoldLaws(Traversals.eachList(), List.of("a", "b", "c")); + FoldLaws.assertFoldLaws(Traversals.eachList(), List.of()); + FoldLaws.assertFoldLaws(Traversals.eachList(), null); + FoldLaws.assertFoldLaws(Traversals.eachSet(), new LinkedHashSet<>(List.of("x", "y"))); + FoldLaws.assertFoldLaws(Traversals.eachSet(), null); + FoldLaws.assertFoldLaws(Traversals.eachMapValue(), Map.of("k", 1)); + FoldLaws.assertFoldLaws(Traversals.eachMapValue(), null); + FoldLaws.assertFoldLaws(Traversals., String>eachIterable(), List.of("a", "b")); + FoldLaws.assertFoldLaws(Traversals., String>eachIterable(), null); + FoldLaws.assertFoldLaws(Traversals.eachOptional(), Optional.of("v")); + FoldLaws.assertFoldLaws(Traversals.eachOptional(), Optional.empty()); + FoldLaws.assertFoldLaws(Traversals.eachOptional(), null); + } + + @Test + @DisplayName("composed and filtered traversals obey the fold laws") + void composedShapes() { + final Traversal items = Focus.>lens(Wrapper::items, (w, l) -> + new Wrapper(l) + ).then(Traversals.eachList()); + FoldLaws.assertFoldLaws(items, new Wrapper(List.of("a", "b", "c"))); + FoldLaws.assertFoldLaws(items, new Wrapper(List.of())); + FoldLaws.assertFoldLaws(items.filter(s -> s.compareTo("a") > 0), new Wrapper(List.of("a", "b", "c"))); + FoldLaws.assertFoldLaws(items.filter(s -> false), new Wrapper(List.of("a", "b"))); + } + } + @Nested @DisplayName("visitWhile enumerates the same focuses as getAll") class VisitWhileEquivalence {