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 super A> 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 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) {
- 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 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 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