Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 42 additions & 39 deletions core/src/main/java/io/github/eschizoid/telescope/Telescope.java
Original file line number Diff line number Diff line change
Expand Up @@ -1726,17 +1726,38 @@ public ForwardMapper<S, A> asForwardMapper(final Class<S> sourceClass, final Cla
* .toList(company);
* }</pre>
*
* <p>See {@link #toListIndexed} to pair each value with its position.
* <p>See {@link #toListIndexed} to pair each value with its position. The returned list is
* unmodifiable on every path shape.
*/
public List<A> toList(final S source) {
if (optic instanceof final Lens<S, A> lens) {
if (source == null) return List.of();
return Collections.singletonList(lens.get(source));
}
final var out = new ArrayList<A>();
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<S, A> lens) return visitor.test(lens.get(source));
if (optic instanceof final Affine<S, A> 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);
}

/**
Expand All @@ -1751,22 +1772,11 @@ public List<A> toList(final S source) {
* }</pre>
*/
public List<Indexed<A>> 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<S, A> lens) {
if (source == null) return List.of();
return Collections.singletonList(new Indexed<>(0, lens.get(source)));
}
if (optic instanceof final Affine<S, A> affine) {
return affine
.getOption(source)
.map(a -> List.of(new Indexed<>(0, a)))
.orElseGet(List::of);
}
final var out = new ArrayList<Indexed<A>>();
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);
}

Expand All @@ -1782,30 +1792,23 @@ public List<Indexed<A>> toListIndexed(final S source) {
* nonzero (it stops at the first match).
*/
public long count(final S source) {
if (optic instanceof Lens<S, A>) {
return source == null ? 0L : 1L;
}
if (optic instanceof final Affine<S, A> 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];
}

/**
* Whether the telescope resolves to at least one value. The short-circuiting sibling of {@link
* #count}.
*/
public boolean exists(final S source) {
if (optic instanceof Lens<S, A>) {
return source != null;
}
if (optic instanceof final Affine<S, A> 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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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}:
*
* <ul>
* <li>{@code t.count(s) == t.toList(s).size() == t.toListIndexed(s).size()}
* <li>{@code t.exists(s) == (t.count(s) > 0)}
* <li>{@code t.toListIndexed(s)} is exactly {@code t.toList(s)} zipped with 0-based positions
* <li>{@code t.find(s).isPresent()} implies {@code t.exists(s)} (not the converse — {@code find}
* rides {@link Optional}, which collapses a null focus to empty; the documented
* null-collapse)
* </ul>
*/
class ReadTerminalConsistencyTest {

record Address(String city) {}

record User(String name, Address address) {}

record Team(String label, List<User> users) {}

sealed interface Event permits Created, Updated {}

record Created(String id) implements Event {}

record Updated(String id, String diff) implements Event {}

private static <S, A> void assertTerminalsAgree(final Telescope<S, A> 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<String> 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;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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 <S, A> void assertFoldLaws(final Fold<S, A> fold, final S source) {
final var streamed = new ArrayList<A>();
fold.getAll(source).forEach(streamed::add);

final var visited = new ArrayList<A>();
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<A>();
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");
}
}
}
Loading
Loading