feat(stammbaum): inference service with BFS + LABEL_MAP (TDD)
RelationToken enum (UP/DOWN/SPOUSE/SIBLING) with reverse(), and RelationshipInferenceService with: - Bidirectional adjacency map: PARENT_OF emits UP and DOWN, SPOUSE_OF and SIBLING_OF both directions. - Virtual SIBLING edges derived from shared parents — no SIBLING_OF row required for siblings to appear. - BFS with MAX_DEPTH=8. - 17-entry LABEL_MAP covering parent, child, spouse, sibling, grand*, great-grand*, uncle/aunt, niece/nephew, great-uncle/aunt, great-niece/ nephew, in-law parent/child, sibling-in-law (both paths), cousin_1. - "distant" fallback for any path not in LABEL_MAP. - Two-sided labels via path reversal. 18 unit tests written first against a stub; all 18 confirmed red, then green after implementation. PersonControllerTest's anonymous DTO updated for the new isFamilyMember() projection. Refs #358. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
/**
|
||||
* Abstract direction tokens emitted by the BFS in {@link RelationshipInferenceService}.
|
||||
* A path is a list of these tokens — e.g. niece-of-me is {@code [SIBLING, DOWN]}.
|
||||
*
|
||||
* <p>Reversing a path swaps {@link #UP} ↔ {@link #DOWN} and leaves the symmetric
|
||||
* tokens ({@link #SPOUSE}, {@link #SIBLING}) untouched.
|
||||
*/
|
||||
public enum RelationToken {
|
||||
UP,
|
||||
DOWN,
|
||||
SPOUSE,
|
||||
SIBLING;
|
||||
|
||||
public RelationToken reverse() {
|
||||
return switch (this) {
|
||||
case UP -> DOWN;
|
||||
case DOWN -> UP;
|
||||
case SPOUSE -> SPOUSE;
|
||||
case SIBLING -> SIBLING;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
|
||||
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Derives indirect family relationships by BFS over the family-graph subset
|
||||
* (PARENT_OF, SPOUSE_OF, SIBLING_OF). Time-ignorant: from_year / to_year are
|
||||
* not consulted. Siblings are also derived from shared parents — no SIBLING_OF
|
||||
* row is required.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RelationshipInferenceService {
|
||||
|
||||
static final int MAX_DEPTH = 8;
|
||||
|
||||
/** "distant" is the catch-all label for paths that do not match the LABEL_MAP. */
|
||||
static final String LABEL_DISTANT = "distant";
|
||||
|
||||
private static final Map<List<RelationToken>, String> LABEL_MAP = buildLabelMap();
|
||||
|
||||
private final PersonRelationshipRepository relationshipRepository;
|
||||
private final PersonRepository personRepository;
|
||||
|
||||
private static Map<List<RelationToken>, String> buildLabelMap() {
|
||||
Map<List<RelationToken>, String> m = new HashMap<>();
|
||||
m.put(List.of(RelationToken.UP), "parent");
|
||||
m.put(List.of(RelationToken.DOWN), "child");
|
||||
m.put(List.of(RelationToken.SPOUSE), "spouse");
|
||||
m.put(List.of(RelationToken.SIBLING), "sibling");
|
||||
m.put(List.of(RelationToken.UP, RelationToken.UP), "grandparent");
|
||||
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN), "grandchild");
|
||||
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.UP), "great_grandparent");
|
||||
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN, RelationToken.DOWN), "great_grandchild");
|
||||
m.put(List.of(RelationToken.UP, RelationToken.SIBLING), "uncle_aunt");
|
||||
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN), "niece_nephew");
|
||||
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.SIBLING), "great_uncle_aunt");
|
||||
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN, RelationToken.DOWN), "great_niece_nephew");
|
||||
m.put(List.of(RelationToken.SPOUSE, RelationToken.UP), "inlaw_parent");
|
||||
m.put(List.of(RelationToken.DOWN, RelationToken.SPOUSE), "inlaw_child");
|
||||
m.put(List.of(RelationToken.SPOUSE, RelationToken.SIBLING), "sibling_inlaw");
|
||||
m.put(List.of(RelationToken.SIBLING, RelationToken.SPOUSE), "sibling_inlaw");
|
||||
m.put(List.of(RelationToken.UP, RelationToken.SIBLING, RelationToken.DOWN), "cousin_1");
|
||||
return Collections.unmodifiableMap(m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortest token path from {@code from} to {@code to}, or empty if unreachable
|
||||
* within {@link #MAX_DEPTH} hops. Package-private to permit direct path
|
||||
* assertions in unit tests.
|
||||
*/
|
||||
Optional<List<RelationToken>> findShortestPath(UUID from, UUID to) {
|
||||
if (from.equals(to)) return Optional.empty();
|
||||
Map<UUID, List<Edge>> adj = buildAdjacency();
|
||||
return bfs(adj, from, to);
|
||||
}
|
||||
|
||||
/** Two-sided label between A and B. {@code labelFromA} reads "B is my <labelFromA>". */
|
||||
public Optional<InferredRelationshipDTO> infer(UUID a, UUID b) {
|
||||
Optional<List<RelationToken>> aToB = findShortestPath(a, b);
|
||||
if (aToB.isEmpty()) return Optional.empty();
|
||||
List<RelationToken> path = aToB.get();
|
||||
return Optional.of(new InferredRelationshipDTO(
|
||||
labelFor(path),
|
||||
labelFor(reversePath(path)),
|
||||
path.size()));
|
||||
}
|
||||
|
||||
/** All persons reachable from {@code personId} within MAX_DEPTH, with their labels. */
|
||||
public List<InferredRelationshipWithPersonDTO> findAllFor(UUID personId) {
|
||||
Map<UUID, List<Edge>> adj = buildAdjacency();
|
||||
Map<UUID, List<RelationToken>> shortestPaths = bfsAll(adj, personId);
|
||||
shortestPaths.remove(personId);
|
||||
if (shortestPaths.isEmpty()) return List.of();
|
||||
|
||||
List<UUID> ids = new ArrayList<>(shortestPaths.keySet());
|
||||
Map<UUID, Person> byId = new HashMap<>();
|
||||
for (Person p : personRepository.findAllById(ids)) {
|
||||
byId.put(p.getId(), p);
|
||||
}
|
||||
|
||||
List<InferredRelationshipWithPersonDTO> out = new ArrayList<>();
|
||||
for (UUID id : ids) {
|
||||
Person p = byId.get(id);
|
||||
if (p == null) continue;
|
||||
List<RelationToken> path = shortestPaths.get(id);
|
||||
PersonNodeDTO node = new PersonNodeDTO(
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember());
|
||||
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
||||
}
|
||||
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
|
||||
.thenComparing(d -> d.person().displayName()));
|
||||
return out;
|
||||
}
|
||||
|
||||
static String labelFor(List<RelationToken> path) {
|
||||
String specific = LABEL_MAP.get(path);
|
||||
return specific != null ? specific : LABEL_DISTANT;
|
||||
}
|
||||
|
||||
private static List<RelationToken> reversePath(List<RelationToken> path) {
|
||||
List<RelationToken> reversed = new ArrayList<>(path.size());
|
||||
for (int i = path.size() - 1; i >= 0; i--) {
|
||||
reversed.add(path.get(i).reverse());
|
||||
}
|
||||
return List.copyOf(reversed);
|
||||
}
|
||||
|
||||
private Map<UUID, List<Edge>> buildAdjacency() {
|
||||
List<PersonRelationship> edges = relationshipRepository.findAllByRelationTypeIn(
|
||||
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
|
||||
Map<UUID, List<Edge>> adj = new HashMap<>();
|
||||
Map<UUID, List<UUID>> parentToChildren = new HashMap<>();
|
||||
|
||||
for (PersonRelationship e : edges) {
|
||||
UUID a = e.getPerson().getId();
|
||||
UUID b = e.getRelatedPerson().getId();
|
||||
switch (e.getRelationType()) {
|
||||
case PARENT_OF -> {
|
||||
addEdge(adj, a, b, RelationToken.DOWN);
|
||||
addEdge(adj, b, a, RelationToken.UP);
|
||||
parentToChildren.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
|
||||
}
|
||||
case SPOUSE_OF -> {
|
||||
addEdge(adj, a, b, RelationToken.SPOUSE);
|
||||
addEdge(adj, b, a, RelationToken.SPOUSE);
|
||||
}
|
||||
case SIBLING_OF -> {
|
||||
addEdge(adj, a, b, RelationToken.SIBLING);
|
||||
addEdge(adj, b, a, RelationToken.SIBLING);
|
||||
}
|
||||
default -> { /* family graph excludes other types */ }
|
||||
}
|
||||
}
|
||||
|
||||
for (List<UUID> children : parentToChildren.values()) {
|
||||
for (int i = 0; i < children.size(); i++) {
|
||||
for (int j = i + 1; j < children.size(); j++) {
|
||||
UUID c1 = children.get(i);
|
||||
UUID c2 = children.get(j);
|
||||
addEdge(adj, c1, c2, RelationToken.SIBLING);
|
||||
addEdge(adj, c2, c1, RelationToken.SIBLING);
|
||||
}
|
||||
}
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
private static void addEdge(Map<UUID, List<Edge>> adj, UUID from, UUID to, RelationToken token) {
|
||||
adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Edge(to, token));
|
||||
}
|
||||
|
||||
private static Optional<List<RelationToken>> bfs(Map<UUID, List<Edge>> adj, UUID from, UUID to) {
|
||||
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
|
||||
shortest.put(from, List.of());
|
||||
Deque<UUID> queue = new ArrayDeque<>();
|
||||
queue.add(from);
|
||||
while (!queue.isEmpty()) {
|
||||
UUID curr = queue.poll();
|
||||
List<RelationToken> currPath = shortest.get(curr);
|
||||
if (currPath.size() >= MAX_DEPTH) continue;
|
||||
for (Edge e : adj.getOrDefault(curr, List.of())) {
|
||||
if (shortest.containsKey(e.target())) continue;
|
||||
List<RelationToken> nextPath = append(currPath, e.token());
|
||||
shortest.put(e.target(), nextPath);
|
||||
if (e.target().equals(to)) return Optional.of(nextPath);
|
||||
queue.add(e.target());
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static Map<UUID, List<RelationToken>> bfsAll(Map<UUID, List<Edge>> adj, UUID from) {
|
||||
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
|
||||
shortest.put(from, List.of());
|
||||
Deque<UUID> queue = new ArrayDeque<>();
|
||||
queue.add(from);
|
||||
while (!queue.isEmpty()) {
|
||||
UUID curr = queue.poll();
|
||||
List<RelationToken> currPath = shortest.get(curr);
|
||||
if (currPath.size() >= MAX_DEPTH) continue;
|
||||
for (Edge e : adj.getOrDefault(curr, List.of())) {
|
||||
if (shortest.containsKey(e.target())) continue;
|
||||
List<RelationToken> nextPath = append(currPath, e.token());
|
||||
shortest.put(e.target(), nextPath);
|
||||
queue.add(e.target());
|
||||
}
|
||||
}
|
||||
return shortest;
|
||||
}
|
||||
|
||||
private static List<RelationToken> append(List<RelationToken> prefix, RelationToken next) {
|
||||
List<RelationToken> out = new ArrayList<>(prefix.size() + 1);
|
||||
out.addAll(prefix);
|
||||
out.add(next);
|
||||
return List.copyOf(out);
|
||||
}
|
||||
|
||||
private record Edge(UUID target, RelationToken token) {}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ class PersonControllerTest {
|
||||
public Integer getBirthYear() { return null; }
|
||||
public Integer getDeathYear() { return null; }
|
||||
public String getNotes() { return null; }
|
||||
public boolean isFamilyMember() { return false; }
|
||||
public long getDocumentCount() { return 0; }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
package org.raddatz.familienarchiv.relationship;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
|
||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.util.Collections.emptyList;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.raddatz.familienarchiv.relationship.RelationToken.*;
|
||||
import static org.raddatz.familienarchiv.relationship.RelationType.*;
|
||||
|
||||
/**
|
||||
* Felix Brandt — TDD red phase for RelationshipInferenceService.
|
||||
* <p>
|
||||
* 18 unit tests, one per LABEL_MAP entry plus one no-path case. Each setup wires
|
||||
* a small graph through the mocked repository and asserts the exact abstract
|
||||
* token sequence emitted by BFS — except {@code distant_label_for_long_chain}
|
||||
* which asserts the fallback label, and {@code returns_empty_when_no_path}
|
||||
* which asserts no result.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RelationshipInferenceServiceTest {
|
||||
|
||||
@Mock PersonRelationshipRepository relationshipRepository;
|
||||
@Mock PersonRepository personRepository;
|
||||
@InjectMocks RelationshipInferenceService service;
|
||||
|
||||
// --- 1: parent ---
|
||||
@Test
|
||||
void parent_path_emits_UP() {
|
||||
Person parent = person();
|
||||
Person child = person();
|
||||
givenEdges(parentOf(parent, child));
|
||||
|
||||
assertThat(service.findShortestPath(child.getId(), parent.getId()))
|
||||
.hasValue(List.of(UP));
|
||||
}
|
||||
|
||||
// --- 2: child ---
|
||||
@Test
|
||||
void child_path_emits_DOWN() {
|
||||
Person parent = person();
|
||||
Person child = person();
|
||||
givenEdges(parentOf(parent, child));
|
||||
|
||||
assertThat(service.findShortestPath(parent.getId(), child.getId()))
|
||||
.hasValue(List.of(DOWN));
|
||||
}
|
||||
|
||||
// --- 3: spouse ---
|
||||
@Test
|
||||
void spouse_path_emits_SPOUSE() {
|
||||
Person a = person();
|
||||
Person b = person();
|
||||
givenEdges(spouseOf(a, b));
|
||||
|
||||
assertThat(service.findShortestPath(a.getId(), b.getId()))
|
||||
.hasValue(List.of(SPOUSE));
|
||||
}
|
||||
|
||||
// --- 4: sibling ---
|
||||
@Test
|
||||
void sibling_path_emits_SIBLING() {
|
||||
Person a = person();
|
||||
Person b = person();
|
||||
givenEdges(siblingOf(a, b));
|
||||
|
||||
assertThat(service.findShortestPath(a.getId(), b.getId()))
|
||||
.hasValue(List.of(SIBLING));
|
||||
}
|
||||
|
||||
// --- 5: grandparent (UP, UP) ---
|
||||
@Test
|
||||
void grandparent_path_emits_UP_UP() {
|
||||
Person grandparent = person();
|
||||
Person parent = person();
|
||||
Person grandchild = person();
|
||||
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
|
||||
|
||||
assertThat(service.findShortestPath(grandchild.getId(), grandparent.getId()))
|
||||
.hasValue(List.of(UP, UP));
|
||||
}
|
||||
|
||||
// --- 6: grandchild (DOWN, DOWN) ---
|
||||
@Test
|
||||
void grandchild_path_emits_DOWN_DOWN() {
|
||||
Person grandparent = person();
|
||||
Person parent = person();
|
||||
Person grandchild = person();
|
||||
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
|
||||
|
||||
assertThat(service.findShortestPath(grandparent.getId(), grandchild.getId()))
|
||||
.hasValue(List.of(DOWN, DOWN));
|
||||
}
|
||||
|
||||
// --- 7: great-grandparent (UP, UP, UP) ---
|
||||
@Test
|
||||
void great_grandparent_path_emits_UP_UP_UP() {
|
||||
Person g = person();
|
||||
Person p = person();
|
||||
Person c = person();
|
||||
Person gc = person();
|
||||
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
|
||||
|
||||
assertThat(service.findShortestPath(gc.getId(), g.getId()))
|
||||
.hasValue(List.of(UP, UP, UP));
|
||||
}
|
||||
|
||||
// --- 8: great-grandchild (DOWN, DOWN, DOWN) ---
|
||||
@Test
|
||||
void great_grandchild_path_emits_DOWN_DOWN_DOWN() {
|
||||
Person g = person();
|
||||
Person p = person();
|
||||
Person c = person();
|
||||
Person gc = person();
|
||||
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
|
||||
|
||||
assertThat(service.findShortestPath(g.getId(), gc.getId()))
|
||||
.hasValue(List.of(DOWN, DOWN, DOWN));
|
||||
}
|
||||
|
||||
// --- 9: uncle/aunt (UP, SIBLING) ---
|
||||
@Test
|
||||
void uncle_aunt_path_emits_UP_SIBLING() {
|
||||
Person grandparent = person();
|
||||
Person parent = person();
|
||||
Person uncle = person();
|
||||
Person me = person();
|
||||
// grandparent has two children: parent and uncle. me is parent's child.
|
||||
givenEdges(
|
||||
parentOf(grandparent, parent),
|
||||
parentOf(grandparent, uncle),
|
||||
parentOf(parent, me));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), uncle.getId()))
|
||||
.hasValue(List.of(UP, SIBLING));
|
||||
}
|
||||
|
||||
// --- 10: niece/nephew (SIBLING, DOWN) ---
|
||||
@Test
|
||||
void niece_nephew_path_emits_SIBLING_DOWN() {
|
||||
Person grandparent = person();
|
||||
Person uncle = person();
|
||||
Person sibling = person();
|
||||
Person niece = person();
|
||||
// grandparent has uncle + sibling; sibling has niece.
|
||||
givenEdges(
|
||||
parentOf(grandparent, uncle),
|
||||
parentOf(grandparent, sibling),
|
||||
parentOf(sibling, niece));
|
||||
|
||||
assertThat(service.findShortestPath(uncle.getId(), niece.getId()))
|
||||
.hasValue(List.of(SIBLING, DOWN));
|
||||
}
|
||||
|
||||
// --- 11: great uncle/aunt (UP, UP, SIBLING) ---
|
||||
@Test
|
||||
void great_uncle_aunt_path_emits_UP_UP_SIBLING() {
|
||||
Person ggp = person();
|
||||
Person grandparent = person();
|
||||
Person greatUncle = person();
|
||||
Person parent = person();
|
||||
Person me = person();
|
||||
givenEdges(
|
||||
parentOf(ggp, grandparent),
|
||||
parentOf(ggp, greatUncle),
|
||||
parentOf(grandparent, parent),
|
||||
parentOf(parent, me));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), greatUncle.getId()))
|
||||
.hasValue(List.of(UP, UP, SIBLING));
|
||||
}
|
||||
|
||||
// --- 12: great niece/nephew (SIBLING, DOWN, DOWN) ---
|
||||
@Test
|
||||
void great_niece_nephew_path_emits_SIBLING_DOWN_DOWN() {
|
||||
Person grandparent = person();
|
||||
Person sibling = person();
|
||||
Person greatUncle = person();
|
||||
Person niece = person();
|
||||
Person greatNiece = person();
|
||||
givenEdges(
|
||||
parentOf(grandparent, sibling),
|
||||
parentOf(grandparent, greatUncle),
|
||||
parentOf(sibling, niece),
|
||||
parentOf(niece, greatNiece));
|
||||
|
||||
assertThat(service.findShortestPath(greatUncle.getId(), greatNiece.getId()))
|
||||
.hasValue(List.of(SIBLING, DOWN, DOWN));
|
||||
}
|
||||
|
||||
// --- 13: parent-in-law (SPOUSE, UP) ---
|
||||
@Test
|
||||
void inlaw_parent_path_emits_SPOUSE_UP() {
|
||||
Person inlaw = person();
|
||||
Person spouse = person();
|
||||
Person me = person();
|
||||
givenEdges(
|
||||
parentOf(inlaw, spouse),
|
||||
spouseOf(me, spouse));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), inlaw.getId()))
|
||||
.hasValue(List.of(SPOUSE, UP));
|
||||
}
|
||||
|
||||
// --- 14: child-in-law (DOWN, SPOUSE) ---
|
||||
@Test
|
||||
void inlaw_child_path_emits_DOWN_SPOUSE() {
|
||||
Person me = person();
|
||||
Person child = person();
|
||||
Person inlawChild = person();
|
||||
givenEdges(
|
||||
parentOf(me, child),
|
||||
spouseOf(child, inlawChild));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), inlawChild.getId()))
|
||||
.hasValue(List.of(DOWN, SPOUSE));
|
||||
}
|
||||
|
||||
// --- 15: sibling-in-law via my spouse's sibling (SPOUSE, SIBLING) ---
|
||||
@Test
|
||||
void sibling_inlaw_via_spouse_emits_SPOUSE_SIBLING() {
|
||||
Person me = person();
|
||||
Person spouse = person();
|
||||
Person spouseSibling = person();
|
||||
givenEdges(
|
||||
spouseOf(me, spouse),
|
||||
siblingOf(spouse, spouseSibling));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), spouseSibling.getId()))
|
||||
.hasValue(List.of(SPOUSE, SIBLING));
|
||||
}
|
||||
|
||||
// --- 16: cousin (UP, SIBLING, DOWN) ---
|
||||
@Test
|
||||
void cousin_1_path_emits_UP_SIBLING_DOWN() {
|
||||
Person ggp = person();
|
||||
Person parentMine = person();
|
||||
Person uncle = person();
|
||||
Person me = person();
|
||||
Person cousin = person();
|
||||
givenEdges(
|
||||
parentOf(ggp, parentMine),
|
||||
parentOf(ggp, uncle),
|
||||
parentOf(parentMine, me),
|
||||
parentOf(uncle, cousin));
|
||||
|
||||
assertThat(service.findShortestPath(me.getId(), cousin.getId()))
|
||||
.hasValue(List.of(UP, SIBLING, DOWN));
|
||||
}
|
||||
|
||||
// --- 17: distant (label fallback for long chains) ---
|
||||
@Test
|
||||
void distant_label_for_long_chain() {
|
||||
// Seven-generation ancestor: chain of seven PARENT_OF edges.
|
||||
Person a0 = person();
|
||||
Person a1 = person();
|
||||
Person a2 = person();
|
||||
Person a3 = person();
|
||||
Person a4 = person();
|
||||
Person a5 = person();
|
||||
Person a6 = person();
|
||||
Person a7 = person();
|
||||
givenEdges(
|
||||
parentOf(a0, a1),
|
||||
parentOf(a1, a2),
|
||||
parentOf(a2, a3),
|
||||
parentOf(a3, a4),
|
||||
parentOf(a4, a5),
|
||||
parentOf(a5, a6),
|
||||
parentOf(a6, a7));
|
||||
|
||||
Optional<InferredRelationshipDTO> inferred = service.infer(a7.getId(), a0.getId());
|
||||
assertThat(inferred).hasValueSatisfying(r -> {
|
||||
assertThat(r.hops()).isEqualTo(7);
|
||||
assertThat(r.labelFromA()).isEqualTo("distant");
|
||||
assertThat(r.labelFromB()).isEqualTo("distant");
|
||||
});
|
||||
}
|
||||
|
||||
// --- 18: no path ---
|
||||
@Test
|
||||
void returns_empty_when_no_path() {
|
||||
Person a = person();
|
||||
Person b = person();
|
||||
// No edges between them.
|
||||
givenEdges(/* none */);
|
||||
|
||||
assertThat(service.findShortestPath(a.getId(), b.getId())).isEmpty();
|
||||
assertThat(service.infer(a.getId(), b.getId())).isEmpty();
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private void givenEdges(PersonRelationship... edges) {
|
||||
when(relationshipRepository.findAllByRelationTypeIn(anyCollection()))
|
||||
.thenReturn(edges.length == 0 ? emptyList() : List.of(edges));
|
||||
}
|
||||
|
||||
private static Person person() {
|
||||
return Person.builder().id(UUID.randomUUID()).lastName("X").familyMember(true).build();
|
||||
}
|
||||
|
||||
private static PersonRelationship parentOf(Person parent, Person child) {
|
||||
return edge(parent, child, PARENT_OF);
|
||||
}
|
||||
|
||||
private static PersonRelationship spouseOf(Person a, Person b) {
|
||||
return edge(a, b, SPOUSE_OF);
|
||||
}
|
||||
|
||||
private static PersonRelationship siblingOf(Person a, Person b) {
|
||||
return edge(a, b, SIBLING_OF);
|
||||
}
|
||||
|
||||
private static PersonRelationship edge(Person a, Person b, RelationType type) {
|
||||
return PersonRelationship.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.person(a)
|
||||
.relatedPerson(b)
|
||||
.relationType(type)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user