Compare commits
17 Commits
ccf1661768
...
a5e3205520
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e3205520 | ||
|
|
f124529ee8 | ||
|
|
61ca5a6e40 | ||
|
|
516a0a3814 | ||
|
|
39276b179d | ||
|
|
577dd3fcb1 | ||
|
|
c0b500b692 | ||
|
|
cb8c85a742 | ||
|
|
c93d3b03ed | ||
|
|
8f163f9b77 | ||
|
|
40511535eb | ||
|
|
a68a822c13 | ||
|
|
df0037cba2 | ||
|
|
dcb5585c64 | ||
|
|
1e77d6d98c | ||
|
|
f22508ca91 | ||
|
|
1cb05697cc |
@@ -4,13 +4,21 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.PersonNodeDTO;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Runs the four canonical loaders in their real dependency order — encoded explicitly
|
||||
@@ -34,6 +42,7 @@ public class CanonicalImportOrchestrator {
|
||||
private final PersonRegisterImporter personRegisterImporter;
|
||||
private final PersonTreeImporter personTreeImporter;
|
||||
private final DocumentImporter documentImporter;
|
||||
private final RelationshipService relationshipService;
|
||||
|
||||
@Value("${app.import.dir:/import}")
|
||||
private String canonicalDir;
|
||||
@@ -67,6 +76,7 @@ public class CanonicalImportOrchestrator {
|
||||
tagTreeImporter.load(tagTree);
|
||||
personRegisterImporter.load(persons);
|
||||
personTreeImporter.load(personsTree);
|
||||
warnOnGenerationMonotonicityViolations();
|
||||
DocumentImporter.LoadResult result = documentImporter.load(documents);
|
||||
|
||||
currentStatus = new ImportStatus(ImportStatus.State.DONE, "IMPORT_DONE",
|
||||
@@ -91,4 +101,31 @@ public class CanonicalImportOrchestrator {
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks every PARENT_OF edge in the family graph and logs a WARN whenever a child's
|
||||
* generation is not strictly deeper than its parent's. Soft check only — the import
|
||||
* is never aborted; the warning is a forensic signal for the curator. Reads through
|
||||
* {@link RelationshipService} so the orchestrator stays within the layering rule
|
||||
* (no direct repository access).
|
||||
*/
|
||||
private void warnOnGenerationMonotonicityViolations() {
|
||||
NetworkDTO network = relationshipService.getFamilyNetwork();
|
||||
Map<UUID, PersonNodeDTO> byId = new HashMap<>(network.nodes().size());
|
||||
for (PersonNodeDTO node : network.nodes()) {
|
||||
byId.put(node.id(), node);
|
||||
}
|
||||
for (RelationshipDTO edge : network.edges()) {
|
||||
if (edge.relationType() != RelationType.PARENT_OF) continue;
|
||||
PersonNodeDTO parent = byId.get(edge.personId());
|
||||
PersonNodeDTO child = byId.get(edge.relatedPersonId());
|
||||
if (parent == null || child == null) continue;
|
||||
Integer pg = parent.generation();
|
||||
Integer cg = child.generation();
|
||||
if (pg != null && cg != null && cg <= pg) {
|
||||
log.warn("Generation monotonicity violation: parent {} (G{}) -> child {} (G{})",
|
||||
parent.displayName(), pg, child.displayName(), cg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.importing;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.person.PersonGeneration;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.person.PersonType;
|
||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||
@@ -11,6 +12,8 @@ import java.io.File;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Loads {@code canonical-persons.xlsx} (the register) into the person domain via
|
||||
@@ -25,6 +28,13 @@ public class PersonRegisterImporter {
|
||||
|
||||
static final List<String> REQUIRED_HEADERS = List.of("person_id", "last_name", "first_name", "provisional");
|
||||
|
||||
// Matches a leading optional G then a signed integer. Anchored at the
|
||||
// start so noise can't slip in before the number, but tolerant of trailing
|
||||
// commentary cells (e.g. "G 2 de Gruyter") since curated rows sometimes
|
||||
// carry an inline note. Out-of-range values are caught by the post-parse
|
||||
// range guard, not by the regex.
|
||||
private static final Pattern GENERATION_PATTERN = Pattern.compile("^\\s*G?\\s*(-?\\d+)");
|
||||
|
||||
private final PersonService personService;
|
||||
|
||||
public int load(File artifact) {
|
||||
@@ -49,11 +59,31 @@ public class PersonRegisterImporter {
|
||||
.notes(blankToNull(row.get("notes")))
|
||||
.birthYear(yearOf(row.get("birth_date")))
|
||||
.deathYear(yearOf(row.get("death_date")))
|
||||
.generation(parseGeneration(row.get("generation"), personId))
|
||||
.personType(PersonType.PERSON)
|
||||
.provisional(Boolean.parseBoolean(row.get("provisional")))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an optional {@code G n} generation cell. Returns null for blanks,
|
||||
* non-matching strings, and any value outside the {@link PersonGeneration}
|
||||
* bounds (mirroring the V70 CHECK). Out-of-range values log a WARN but
|
||||
* never abort the batch — REQ-IMP-001.
|
||||
*/
|
||||
static Integer parseGeneration(String raw, String personId) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
Matcher m = GENERATION_PATTERN.matcher(raw);
|
||||
if (!m.find()) return null;
|
||||
int parsed = Integer.parseInt(m.group(1));
|
||||
if (parsed < PersonGeneration.MIN_GENERATION || parsed > PersonGeneration.MAX_GENERATION) {
|
||||
log.warn("Skipping out-of-range generation '{}' for row {}", raw, personId);
|
||||
return null;
|
||||
}
|
||||
log.debug("Parsed generation '{}' for person {}", raw, personId);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static Integer yearOf(String isoDate) {
|
||||
if (isoDate == null || isoDate.isBlank()) return null;
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonGeneration;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.person.PersonType;
|
||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||
@@ -79,12 +80,29 @@ public class PersonTreeImporter {
|
||||
.notes(blankToNull(text(node, "notes")))
|
||||
.birthYear(intOrNull(node, "birthYear"))
|
||||
.deathYear(intOrNull(node, "deathYear"))
|
||||
.generation(generationOrNull(node, personId))
|
||||
.familyMember(node.path("familyMember").asBoolean(false))
|
||||
.personType(PersonType.PERSON)
|
||||
.provisional(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JSON {@code generation} value if present and within the
|
||||
* {@link PersonGeneration} bounds; null otherwise. Out-of-range values
|
||||
* log a WARN but never abort the batch — mirrors the register-importer
|
||||
* skip-and-warn policy.
|
||||
*/
|
||||
private static Integer generationOrNull(JsonNode node, String personId) {
|
||||
Integer raw = intOrNull(node, "generation");
|
||||
if (raw == null) return null;
|
||||
if (raw < PersonGeneration.MIN_GENERATION || raw > PersonGeneration.MAX_GENERATION) {
|
||||
log.warn("Skipping out-of-range generation '{}' for person {}", raw, personId);
|
||||
return null;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
|
||||
int created = 0;
|
||||
for (JsonNode node : relationships) {
|
||||
|
||||
@@ -52,6 +52,13 @@ public class Person {
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
|
||||
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
|
||||
// Nullable for persons outside the curated family graph. Drives the
|
||||
// Stammbaum strict-rank seed (see #689) and re-import preserves human
|
||||
// edits via PersonService.preferHuman (ADR-025).
|
||||
@Column(name = "generation")
|
||||
private Integer generation;
|
||||
|
||||
@Column(name = "family_member", nullable = false)
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
/**
|
||||
* Single source of truth for the {@code persons.generation} value range.
|
||||
* The DB CHECK in V70, the {@code PersonUpdateDTO} Bean Validation annotations,
|
||||
* and the canonical importers all reference these constants so a future widening
|
||||
* (e.g. accepting {@code G −1} ancestors) happens in one place. Mirror this file
|
||||
* by hand in the V70 migration comment when adjusting bounds.
|
||||
*/
|
||||
public final class PersonGeneration {
|
||||
|
||||
public static final int MIN_GENERATION = 0;
|
||||
public static final int MAX_GENERATION = 10;
|
||||
|
||||
private PersonGeneration() {}
|
||||
}
|
||||
@@ -177,6 +177,7 @@ public class PersonService {
|
||||
.notes(blankToNull(cmd.notes()))
|
||||
.birthYear(cmd.birthYear())
|
||||
.deathYear(cmd.deathYear())
|
||||
.generation(cmd.generation())
|
||||
.familyMember(cmd.familyMember())
|
||||
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
|
||||
.provisional(cmd.provisional())
|
||||
@@ -200,6 +201,7 @@ public class PersonService {
|
||||
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
||||
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
|
||||
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
|
||||
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
|
||||
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
|
||||
existing.setPersonType(cmd.personType());
|
||||
}
|
||||
@@ -254,6 +256,7 @@ public class PersonService {
|
||||
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||
.birthYear(dto.getBirthYear())
|
||||
.deathYear(dto.getDeathYear())
|
||||
.generation(dto.getGeneration())
|
||||
.build();
|
||||
return personRepository.save(person);
|
||||
}
|
||||
@@ -286,6 +289,9 @@ public class PersonService {
|
||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||
person.setBirthYear(dto.getBirthYear());
|
||||
person.setDeathYear(dto.getDeathYear());
|
||||
// Form path: a human can clear generation back to null. Unlike the importer
|
||||
// which routes through preferHuman, we write the DTO value verbatim.
|
||||
person.setGeneration(dto.getGeneration());
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
@@ -21,4 +23,9 @@ public class PersonUpdateDTO {
|
||||
private String notes;
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
|
||||
// PersonGeneration so DB, DTO, and importer all read from one place.
|
||||
@Min(PersonGeneration.MIN_GENERATION)
|
||||
@Max(PersonGeneration.MAX_GENERATION)
|
||||
private Integer generation;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public record PersonUpsertCommand(
|
||||
String notes,
|
||||
Integer birthYear,
|
||||
Integer deathYear,
|
||||
Integer generation,
|
||||
boolean familyMember,
|
||||
PersonType personType,
|
||||
boolean provisional
|
||||
|
||||
@@ -96,7 +96,8 @@ public class RelationshipInferenceService {
|
||||
if (p == null) continue;
|
||||
List<RelationToken> path = shortestPaths.get(id);
|
||||
PersonNodeDTO node = new PersonNodeDTO(
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember());
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
||||
p.getGeneration(), p.isFamilyMember());
|
||||
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
||||
}
|
||||
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
|
||||
|
||||
@@ -66,7 +66,8 @@ public class RelationshipService {
|
||||
for (Person p : familyMembers) {
|
||||
familyIds.add(p.getId());
|
||||
nodes.add(new PersonNodeDTO(
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), true));
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
||||
p.getGeneration(), true));
|
||||
}
|
||||
|
||||
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
|
||||
|
||||
@@ -10,5 +10,6 @@ public record PersonNodeDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
|
||||
Integer birthYear,
|
||||
Integer deathYear,
|
||||
Integer generation,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
|
||||
) {}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- #689: persist the hand-curated "G 0…G 5" generation index from
|
||||
-- canonical-persons.xlsx so the Stammbaum layout can use it as a strict
|
||||
-- rank anchor (replacing the current iterative longest-path heuristic that
|
||||
-- silently misplaces loose spouses with their own parents in the graph).
|
||||
--
|
||||
-- Nullable: pre-import rows and persons outside the curated family graph
|
||||
-- legitimately have no generation. The canonical importer back-fills via
|
||||
-- preferHuman on the next run; a human-edited value is never overwritten
|
||||
-- (see ADR-025).
|
||||
|
||||
ALTER TABLE persons ADD COLUMN generation SMALLINT;
|
||||
|
||||
-- Allowlist of valid generation indices. The 0..10 bounds mirror
|
||||
-- PersonGeneration.MIN_GENERATION / MAX_GENERATION in Java — keep the
|
||||
-- two in sync (the DTO @Min/@Max and both importer range guards read from
|
||||
-- those Java constants). Current data tops out at G 5, but a future G 6 →
|
||||
-- G 10 widening needs no migration. A G −1 ancestor would require a
|
||||
-- separate one-shot shift migration (out of scope here; the layout's
|
||||
-- normalise step already handles negative seeds at render time).
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_generation_range
|
||||
CHECK (generation IS NULL OR generation BETWEEN 0 AND 10);
|
||||
|
||||
-- Partial index: only the curated rows (≈ 163 of 1,105) ever get a value,
|
||||
-- and the layout only ever queries for non-null rows.
|
||||
CREATE INDEX idx_persons_generation ON persons (generation)
|
||||
WHERE generation IS NOT NULL;
|
||||
@@ -7,12 +7,18 @@ import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.PersonNodeDTO;
|
||||
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -29,10 +35,12 @@ class CanonicalImportOrchestratorTest {
|
||||
@Mock PersonRegisterImporter personRegisterImporter;
|
||||
@Mock PersonTreeImporter personTreeImporter;
|
||||
@Mock DocumentImporter documentImporter;
|
||||
@Mock RelationshipService relationshipService;
|
||||
|
||||
private CanonicalImportOrchestrator orchestrator(Path dir) {
|
||||
CanonicalImportOrchestrator o = new CanonicalImportOrchestrator(
|
||||
tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter);
|
||||
tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter,
|
||||
relationshipService);
|
||||
ReflectionTestUtils.setField(o, "canonicalDir", dir.toString());
|
||||
return o;
|
||||
}
|
||||
@@ -53,6 +61,7 @@ class CanonicalImportOrchestratorTest {
|
||||
void runImport_loadsTagsAndPersonsBeforeDocuments(@TempDir Path dir) throws Exception {
|
||||
writeAllArtifacts(dir);
|
||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
||||
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
|
||||
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||
|
||||
o.runImport();
|
||||
@@ -68,6 +77,7 @@ class CanonicalImportOrchestratorTest {
|
||||
void runImport_setsStatusDone_onSuccess(@TempDir Path dir) throws Exception {
|
||||
writeAllArtifacts(dir);
|
||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(3, List.of()));
|
||||
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
|
||||
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||
|
||||
o.runImport();
|
||||
@@ -118,6 +128,7 @@ class CanonicalImportOrchestratorTest {
|
||||
writeAllArtifacts(dir);
|
||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(1,
|
||||
List.of(new ImportStatus.SkippedFile("fake.pdf", ImportStatus.SkipReason.INVALID_PDF_SIGNATURE))));
|
||||
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
|
||||
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||
|
||||
o.runImport();
|
||||
@@ -127,4 +138,46 @@ class CanonicalImportOrchestratorTest {
|
||||
.extracting(ImportStatus.SkippedFile::filename)
|
||||
.containsExactly("fake.pdf");
|
||||
}
|
||||
|
||||
// ─── generation monotonicity soft-check (#689) ─────────────────────────────
|
||||
|
||||
@Test
|
||||
void runImport_invokesGetFamilyNetwork_afterPersonLoaders_beforeDocuments(@TempDir Path dir) throws Exception {
|
||||
writeAllArtifacts(dir);
|
||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
||||
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
|
||||
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||
|
||||
o.runImport();
|
||||
|
||||
InOrder order = inOrder(personRegisterImporter, personTreeImporter, relationshipService, documentImporter);
|
||||
order.verify(personRegisterImporter).load(any());
|
||||
order.verify(personTreeImporter).load(any());
|
||||
order.verify(relationshipService).getFamilyNetwork();
|
||||
order.verify(documentImporter).load(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImport_completes_evenWhenMonotonicityViolatingEdgePresent(@TempDir Path dir) throws Exception {
|
||||
// child.generation (2) <= parent.generation (3) — monotonicity violation.
|
||||
// The orchestrator must WARN and continue; it must not abort or fail-closed.
|
||||
writeAllArtifacts(dir);
|
||||
UUID parentId = UUID.randomUUID();
|
||||
UUID childId = UUID.randomUUID();
|
||||
PersonNodeDTO parent = new PersonNodeDTO(parentId, "Parent", null, null, 3, true);
|
||||
PersonNodeDTO child = new PersonNodeDTO(childId, "Child", null, null, 2, true);
|
||||
RelationshipDTO edge = new RelationshipDTO(
|
||||
UUID.randomUUID(), parentId, childId,
|
||||
"Parent", null, null, "Child", null, null,
|
||||
RelationType.PARENT_OF, null, null, null);
|
||||
when(relationshipService.getFamilyNetwork())
|
||||
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
|
||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
||||
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||
|
||||
o.runImport();
|
||||
|
||||
assertThat(o.getStatus().state()).isEqualTo(ImportStatus.State.DONE);
|
||||
verify(documentImporter).load(any());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
@@ -87,6 +89,50 @@ class PersonRegisterImporterTest {
|
||||
assertThat(processed).isEqualTo(2);
|
||||
}
|
||||
|
||||
// ─── generation parsing (#689) ─────────────────────────────────────────────
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(value = {
|
||||
"'G 3', 3",
|
||||
"'G3', 3",
|
||||
"'G 3', 3",
|
||||
"'3', 3",
|
||||
"' 3 ', 3",
|
||||
"'G 2 de Gruyter', 2",
|
||||
"'', null",
|
||||
"'garbage', null",
|
||||
"'G 99', null",
|
||||
"'G -1', null"
|
||||
}, nullValues = "null")
|
||||
void load_parsesGeneration_perRegex(String raw, Integer expected, @TempDir Path tempDir) throws Exception {
|
||||
PersonService personService = mock(PersonService.class);
|
||||
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||
Path xlsx = writePersonsWithGeneration(tempDir,
|
||||
rowWithGeneration("herbert-cram", "Cram", "Herbert", "", "", "False", raw));
|
||||
|
||||
new PersonRegisterImporter(personService).load(xlsx.toFile());
|
||||
|
||||
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||
verify(personService).upsertBySourceRef(captor.capture());
|
||||
assertThat(captor.getValue().generation()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_succeeds_andLeavesGenerationNull_whenArtifactHasNoGenerationColumn(@TempDir Path tempDir) throws Exception {
|
||||
// REQ-IMP-001: older artifacts without the `generation` column must still
|
||||
// import. REQUIRED_HEADERS is intentionally not extended.
|
||||
PersonService personService = mock(PersonService.class);
|
||||
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||
Path xlsx = writePersons(tempDir, row(
|
||||
"old-artifact", "Mueller", "Hans", "", "", "False"));
|
||||
|
||||
new PersonRegisterImporter(personService).load(xlsx.toFile());
|
||||
|
||||
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||
verify(personService).upsertBySourceRef(captor.capture());
|
||||
assertThat(captor.getValue().generation()).isNull();
|
||||
}
|
||||
|
||||
private static Person personOf(PersonUpsertCommand cmd) {
|
||||
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef())
|
||||
.firstName(cmd.firstName()).lastName(cmd.lastName())
|
||||
@@ -127,4 +173,36 @@ class PersonRegisterImporterTest {
|
||||
}
|
||||
return xlsx;
|
||||
}
|
||||
|
||||
private Map<String, String> rowWithGeneration(String personId, String lastName, String firstName,
|
||||
String maidenName, String notes, String provisional,
|
||||
String generation) {
|
||||
Map<String, String> r = row(personId, lastName, firstName, maidenName, notes, provisional);
|
||||
r.put("generation", generation);
|
||||
return r;
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private Path writePersonsWithGeneration(Path dir, Map<String, String>... rows) throws Exception {
|
||||
Path xlsx = dir.resolve("canonical-persons.xlsx");
|
||||
List<String> headers = List.of(
|
||||
"person_id", "last_name", "first_name", "maiden_name", "notes", "provisional", "generation");
|
||||
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("Sheet1");
|
||||
Row header = sheet.createRow(0);
|
||||
for (int i = 0; i < headers.size(); i++) {
|
||||
header.createCell(i).setCellValue(headers.get(i));
|
||||
}
|
||||
for (int r = 0; r < rows.length; r++) {
|
||||
Row row = sheet.createRow(r + 1);
|
||||
for (int c = 0; c < headers.size(); c++) {
|
||||
row.createCell(c).setCellValue(rows[r].getOrDefault(headers.get(c), ""));
|
||||
}
|
||||
}
|
||||
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||
wb.write(out);
|
||||
}
|
||||
}
|
||||
return xlsx;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,65 @@ class PersonTreeImporterTest {
|
||||
verify(relationshipService, org.mockito.Mockito.never()).addRelationship(any(), any());
|
||||
}
|
||||
|
||||
// ─── generation (#689) ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void load_passesGenerationFromJson(@TempDir Path tempDir) throws Exception {
|
||||
PersonService personService = mock(PersonService.class);
|
||||
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||
Path json = write(tempDir, """
|
||||
{"persons":[
|
||||
{"rowId":"row_a","lastName":"Cram","firstName":"Herbert","familyMember":true,
|
||||
"personId":"herbert-cram","generation":3}
|
||||
],"relationships":[]}
|
||||
""");
|
||||
|
||||
new PersonTreeImporter(personService, relationshipService).load(json.toFile());
|
||||
|
||||
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||
verify(personService).upsertBySourceRef(captor.capture());
|
||||
assertThat(captor.getValue().generation()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_returnsNullGeneration_whenAbsentFromJson(@TempDir Path tempDir) throws Exception {
|
||||
PersonService personService = mock(PersonService.class);
|
||||
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||
Path json = write(tempDir, """
|
||||
{"persons":[
|
||||
{"rowId":"row_a","lastName":"Cram","firstName":"Herbert","familyMember":true,
|
||||
"personId":"herbert-cram"}
|
||||
],"relationships":[]}
|
||||
""");
|
||||
|
||||
new PersonTreeImporter(personService, relationshipService).load(json.toFile());
|
||||
|
||||
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||
verify(personService).upsertBySourceRef(captor.capture());
|
||||
assertThat(captor.getValue().generation()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_skipsOutOfRangeGeneration_logsWarn_neverAborts(@TempDir Path tempDir) throws Exception {
|
||||
PersonService personService = mock(PersonService.class);
|
||||
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||
Path json = write(tempDir, """
|
||||
{"persons":[
|
||||
{"rowId":"row_a","lastName":"Cram","firstName":"Herbert","familyMember":true,
|
||||
"personId":"herbert-cram","generation":99}
|
||||
],"relationships":[]}
|
||||
""");
|
||||
|
||||
new PersonTreeImporter(personService, relationshipService).load(json.toFile());
|
||||
|
||||
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||
verify(personService).upsertBySourceRef(captor.capture());
|
||||
assertThat(captor.getValue().generation()).isNull();
|
||||
}
|
||||
|
||||
private static Person personOf(PersonUpsertCommand cmd) {
|
||||
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef()).lastName(cmd.lastName()).build();
|
||||
}
|
||||
|
||||
@@ -718,4 +718,74 @@ class PersonControllerTest {
|
||||
.content("{\"lastName\":\"de Gruyter\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ─── generation field validation (#689) ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenGenerationAboveRange() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||
+ "\"personType\":\"PERSON\",\"generation\":11}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.VALIDATION_ERROR.name()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenGenerationBelowRange() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||
+ "\"personType\":\"PERSON\",\"generation\":-1}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.VALIDATION_ERROR.name()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns200_whenGenerationNull() throws Exception {
|
||||
// Symmetric body assertion: the response must echo generation as null (not
|
||||
// absent), so the frontend re-hydrates the "(none)" option after a clear.
|
||||
// Without this, the in-range test below would be the only end-to-end proof
|
||||
// that the field flows through the controller.
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.updatePerson(any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||
+ "\"personType\":\"PERSON\",\"generation\":null}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.generation").value(org.hamcrest.Matchers.nullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns200_whenGenerationInRange() throws Exception {
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").generation(3).build();
|
||||
when(personService.updatePerson(any(), any())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||
+ "\"personType\":\"PERSON\",\"generation\":3}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.generation").value(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns200_whenGenerationInRange() throws Exception {
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").generation(3).build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||
+ "\"personType\":\"PERSON\",\"generation\":3}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.generation").value(3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,4 +148,55 @@ class PersonImportUpsertTest {
|
||||
|
||||
assertThat(result.isProvisional()).isTrue();
|
||||
}
|
||||
|
||||
// ─── generation (#689) ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_writesGeneration_onFirstImport() {
|
||||
when(personRepository.findBySourceRef("herbert-cram")).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("herbert-cram").firstName("Herbert").lastName("Cram")
|
||||
.generation(3).personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getGeneration()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_preservesHumanEditedGeneration_onReimport() {
|
||||
Person humanEdited = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("herbert-cram")
|
||||
.firstName("Herbert").lastName("Cram").generation(4).build();
|
||||
when(personRepository.findBySourceRef("herbert-cram")).thenReturn(Optional.of(humanEdited));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("herbert-cram").firstName("Herbert").lastName("Cram")
|
||||
.generation(2).personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getGeneration()).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeCanonical_overwrites_human_null_with_canonical_value_documenting_known_limitation() {
|
||||
// If preferHuman gains explicit-null-vs-unset semantics, delete this test (see issue #689).
|
||||
Person existing = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("herbert-cram")
|
||||
.firstName("Herbert").lastName("Cram").generation(null).build();
|
||||
when(personRepository.findBySourceRef("herbert-cram")).thenReturn(Optional.of(existing));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("herbert-cram").firstName("Herbert").lastName("Cram")
|
||||
.generation(3).personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getGeneration()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,4 +672,39 @@ class PersonRepositoryTest {
|
||||
|
||||
assertThat(slice.get(0).getDocumentCount()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// ─── generation column (#689) ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void save_persistsGeneration_andFindByIdReturnsSameGeneration() {
|
||||
Person person = Person.builder()
|
||||
.firstName("Walter")
|
||||
.lastName("Raddatz")
|
||||
.generation(3)
|
||||
.build();
|
||||
|
||||
Person saved = personRepository.save(person);
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Optional<Person> found = personRepository.findById(saved.getId());
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getGeneration()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_allowsNullGeneration_existingRowsRemainNull() {
|
||||
Person person = Person.builder()
|
||||
.firstName("Anonym")
|
||||
.lastName("Person")
|
||||
.build();
|
||||
|
||||
Person saved = personRepository.save(person);
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Optional<Person> found = personRepository.findById(saved.getId());
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getGeneration()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,59 @@ class PersonServiceIntegrationTest {
|
||||
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||
}
|
||||
|
||||
// ─── generation full-stack round-trip (#689) ──────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_clearGenerationToNull_readsBackNullFromDb() {
|
||||
// Sara's QA concern: pin the full PUT→DB→GET round-trip for the
|
||||
// null-clear path. Without this we only have the WebMvcTest mocked
|
||||
// boundary; nothing proved the JPA flush actually wrote SQL NULL.
|
||||
Person seeded = personRepository.save(Person.builder()
|
||||
.firstName("Hans").lastName("Raddatz")
|
||||
.personType(PersonType.PERSON).generation(3).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setPersonType(PersonType.PERSON);
|
||||
dto.setFirstName("Hans");
|
||||
dto.setLastName("Raddatz");
|
||||
dto.setGeneration(null);
|
||||
|
||||
personService.updatePerson(seeded.getId(), dto);
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
|
||||
assertThat(reloaded.getGeneration()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_setGenerationToZero_readsBackZeroFromDb() {
|
||||
// Pin the G 0 case end-to-end. The form-action spec covers that 0
|
||||
// doesn't get spread-dropped at the SvelteKit boundary; this test
|
||||
// covers that the controller + service + JPA chain preserves the
|
||||
// primitive zero (not coerced to null somewhere along the way).
|
||||
Person seeded = personRepository.save(Person.builder()
|
||||
.firstName("Walter").lastName("Raddatz")
|
||||
.personType(PersonType.PERSON).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setPersonType(PersonType.PERSON);
|
||||
dto.setFirstName("Walter");
|
||||
dto.setLastName("Raddatz");
|
||||
dto.setGeneration(0);
|
||||
|
||||
personService.updatePerson(seeded.getId(), dto);
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
|
||||
assertThat(reloaded.getGeneration()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
||||
// A person referenced as BOTH a document sender and a document receiver must delete
|
||||
|
||||
@@ -261,6 +261,54 @@ class PersonServiceTest {
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPerson_dto_persistsGeneration() {
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Hans"); dto.setLastName("Raddatz");
|
||||
dto.setPersonType(PersonType.PERSON); dto.setGeneration(3);
|
||||
|
||||
Person result = personService.createPerson(dto);
|
||||
|
||||
assertThat(result.getGeneration()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_writesGeneration_includingExplicitNullClear() {
|
||||
// The form path is the only place a human can clear generation back to null.
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(id).firstName("Hans").lastName("Raddatz")
|
||||
.personType(PersonType.PERSON).generation(3).build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Hans"); dto.setLastName("Raddatz");
|
||||
dto.setPersonType(PersonType.PERSON); dto.setGeneration(null);
|
||||
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getGeneration()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_writesGeneration_whenSet() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(id).firstName("Hans").lastName("Raddatz")
|
||||
.personType(PersonType.PERSON).build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Hans"); dto.setLastName("Raddatz");
|
||||
dto.setPersonType(PersonType.PERSON); dto.setGeneration(2);
|
||||
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getGeneration()).isEqualTo(2);
|
||||
}
|
||||
|
||||
// ─── updatePerson (personType) ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -93,7 +93,7 @@ class RelationshipControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception {
|
||||
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, true);
|
||||
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, null, true);
|
||||
RelationshipDTO edge = new RelationshipDTO(
|
||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||
"Alice Müller", 1900, 1980,
|
||||
@@ -111,7 +111,7 @@ class RelationshipControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception {
|
||||
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, true);
|
||||
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, null, true);
|
||||
InferredRelationshipWithPersonDTO inferred =
|
||||
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
|
||||
when(relationshipService.getInferredRelationships(PERSON_ID))
|
||||
|
||||
@@ -237,6 +237,22 @@ class RelationshipServiceTest {
|
||||
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFamilyNetwork_populates_generation_on_PersonNodeDTO() {
|
||||
Person walter = Person.builder().id(UUID.randomUUID()).lastName("Raddatz")
|
||||
.familyMember(true).generation(2).build();
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).lastName("Raddatz")
|
||||
.familyMember(true).generation(3).build();
|
||||
when(personService.findAllFamilyMembers()).thenReturn(List.of(walter, clara));
|
||||
when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of());
|
||||
|
||||
NetworkDTO result = service.getFamilyNetwork();
|
||||
|
||||
assertThat(result.nodes()).hasSize(2);
|
||||
assertThat(result.nodes().stream().map(n -> n.generation()).toList())
|
||||
.containsExactlyInAnyOrder(2, 3);
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static Person person(String name) {
|
||||
|
||||
@@ -186,6 +186,7 @@ package "Persons" {
|
||||
notes : TEXT
|
||||
birth_year : INTEGER
|
||||
death_year : INTEGER
|
||||
generation : SMALLINT
|
||||
family_member : BOOLEAN NOT NULL
|
||||
source_ref : VARCHAR(255) UNIQUE
|
||||
provisional : BOOLEAN NOT NULL
|
||||
|
||||
@@ -244,8 +244,12 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<td><span class="swatch" style="background:rgba(255,255,255,.6);border:1px solid #ccc"></span>rgba(255,255,255,.6) — decorative, aria-hidden years</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gen label</td>
|
||||
<td><span class="swatch" style="background:#6b7280"></span>#6b7280 — 8 px, tracking 2 px, aria-hidden</td>
|
||||
<td>Gutter label</td>
|
||||
<td><span class="swatch" style="background:#4b5563"></span>#4b5563 (var(--c-ink-2)) — 12 px, tracking 0.08 em, font-weight 700, screen-reader-labelled</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gutter stripe</td>
|
||||
<td><span class="swatch" style="background:rgba(161,220,216,.08);border:1px solid #ccc"></span>rgba(161,220,216,.08) — decorative full-row underlay alternating with transparent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Panel surface</td>
|
||||
@@ -302,8 +306,12 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<td><span class="swatch" style="background:#8b97a5;border:1px solid #444"></span>#8b97a5<span class="pass" style="background:rgba(209,250,229,.15);color:#6EE7B7">7.1:1 AAA ✓</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gen label</td>
|
||||
<td><span class="swatch" style="background:#4e6070;border:1px solid #333"></span>#4e6070 — aria-hidden</td>
|
||||
<td>Gutter label</td>
|
||||
<td><span class="swatch" style="background:#9ca3af;border:1px solid #333"></span>#9ca3af (var(--c-ink-2)) — 12 px, tracking 0.08 em, font-weight 700, screen-reader-labelled</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gutter stripe</td>
|
||||
<td><span class="swatch" style="background:rgba(161,220,216,.14);border:1px solid #333"></span>rgba(161,220,216,.14) — decorative full-row underlay alternating with transparent</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Panel surface</td>
|
||||
@@ -355,7 +363,6 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<div class="stb-page-head">
|
||||
<div class="stb-page-title">Stammbaum</div>
|
||||
<div class="stb-controls">
|
||||
<div class="stb-btn outline" style="font-size:8px">Generationen ▾</div>
|
||||
<div class="stb-zoom">
|
||||
<div class="stb-zoom-btn">−</div>
|
||||
<div class="stb-zoom-btn">+</div>
|
||||
@@ -507,7 +514,6 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<div class="stb-page-head">
|
||||
<div class="stb-page-title">Stammbaum</div>
|
||||
<div class="stb-controls">
|
||||
<div class="stb-btn outline-dark" style="font-size:8px">Generationen ▾</div>
|
||||
<div class="stb-zoom">
|
||||
<div class="stb-zoom-btn">−</div>
|
||||
<div class="stb-zoom-btn">+</div>
|
||||
@@ -751,7 +757,6 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<div class="stb-page-head">
|
||||
<div class="stb-page-title">Stammbaum</div>
|
||||
<div class="stb-controls">
|
||||
<div class="stb-btn outline" style="font-size:8px">Generationen ▾</div>
|
||||
<div class="stb-zoom">
|
||||
<div class="stb-zoom-btn">−</div>
|
||||
<div class="stb-zoom-btn">+</div>
|
||||
@@ -998,10 +1003,16 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
|
||||
<td>Filled circle at connector midpoints; same color as connectors</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gen label</td>
|
||||
<td><code>text-[8px] tracking-[2px] uppercase</code></td>
|
||||
<td>8 px</td>
|
||||
<td><code>aria-hidden="true"</code>; #6b7280 light · #4e6070 dark</td>
|
||||
<td>Gutter label</td>
|
||||
<td><code>font-sans text-[12px] font-bold tracking-[0.08em] uppercase</code></td>
|
||||
<td>12 px</td>
|
||||
<td>Wrapped in <code><g role="text" aria-label="Generation N"></code> so screen readers announce the full word. Fill <code>var(--c-ink-2)</code>. Renders the un-shifted <code>node.generation</code> value, never the post-normalise rank.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gutter stripe underlay</td>
|
||||
<td>SVG <code><rect></code> aria-hidden, alternating <code>transparent</code> / <code>var(--c-gutter-stripe)</code></td>
|
||||
<td>NODE_H + ROW_GAP tall, full viewBox width</td>
|
||||
<td>Decorative band per occupied generation row. Hidden entirely below <code>md</code> (GUTTER_WIDTH = 0).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Node name text</td>
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
|
||||
"person_label_birth_year": "Geburtsjahr",
|
||||
"person_label_death_year": "Todesjahr",
|
||||
"person_label_generation": "Generation",
|
||||
"person_option_generation_unset": "(keine)",
|
||||
"person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)",
|
||||
"person_placeholder_year": "z.B. 1923",
|
||||
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
|
||||
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
||||
@@ -1103,7 +1106,6 @@
|
||||
"stammbaum_relationships_heading": "Stammbaum & Beziehungen",
|
||||
"stammbaum_zoom_in": "Vergrößern",
|
||||
"stammbaum_zoom_out": "Verkleinern",
|
||||
"stammbaum_generations": "Generationen",
|
||||
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
|
||||
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"person_placeholder_notes": "Biographical notes, remarks…",
|
||||
"person_label_birth_year": "Birth year",
|
||||
"person_label_death_year": "Death year",
|
||||
"person_label_generation": "Generation",
|
||||
"person_option_generation_unset": "(none)",
|
||||
"person_hint_generation": "Generation within the family (G 0 = oldest generation)",
|
||||
"person_placeholder_year": "e.g. 1923",
|
||||
"person_year_error": "Please enter a four-digit year",
|
||||
"person_years_error_order": "Birth year must be before death year",
|
||||
@@ -1103,7 +1106,6 @@
|
||||
"stammbaum_relationships_heading": "Family tree & relationships",
|
||||
"stammbaum_zoom_in": "Zoom in",
|
||||
"stammbaum_zoom_out": "Zoom out",
|
||||
"stammbaum_generations": "Generations",
|
||||
"relation_error_duplicate": "This relationship already exists.",
|
||||
"relation_error_circular": "This relationship would form a cycle.",
|
||||
"relation_error_self": "A person cannot be related to themselves.",
|
||||
|
||||
@@ -175,6 +175,9 @@
|
||||
"person_placeholder_notes": "Notas biográficas, observaciones…",
|
||||
"person_label_birth_year": "Año de nacimiento",
|
||||
"person_label_death_year": "Año de fallecimiento",
|
||||
"person_label_generation": "Generación",
|
||||
"person_option_generation_unset": "(ninguna)",
|
||||
"person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)",
|
||||
"person_placeholder_year": "p.ej. 1923",
|
||||
"person_year_error": "Introduzca un año de cuatro dígitos",
|
||||
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
|
||||
@@ -1103,7 +1106,6 @@
|
||||
"stammbaum_relationships_heading": "Árbol genealógico & relaciones",
|
||||
"stammbaum_zoom_in": "Acercar",
|
||||
"stammbaum_zoom_out": "Alejar",
|
||||
"stammbaum_generations": "Generaciones",
|
||||
"relation_error_duplicate": "Esta relación ya existe.",
|
||||
"relation_error_circular": "Esta relación crearía un ciclo.",
|
||||
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
|
||||
|
||||
@@ -1666,6 +1666,8 @@ export interface components {
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
/** Format: int32 */
|
||||
generation?: number | null;
|
||||
};
|
||||
Person: {
|
||||
/** Format: uuid */
|
||||
@@ -1681,6 +1683,8 @@ export interface components {
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
/** Format: int32 */
|
||||
generation?: number | null;
|
||||
familyMember: boolean;
|
||||
sourceRef?: string;
|
||||
provisional: boolean;
|
||||
@@ -2285,6 +2289,8 @@ export interface components {
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
/** Format: int32 */
|
||||
generation?: number | null;
|
||||
familyMember: boolean;
|
||||
};
|
||||
InferredRelationshipDTO: {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/prefer-svelte-reactivity -- maps are scope-local
|
||||
to a single $derived.by computation; never mutated after layout. */
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import {
|
||||
buildLayout,
|
||||
NODE_W,
|
||||
NODE_H,
|
||||
ROW_GAP,
|
||||
type Layout
|
||||
} from '$lib/person/genealogy/layout/buildLayout';
|
||||
|
||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
@@ -12,274 +18,76 @@ interface Props {
|
||||
selectedId: string | null;
|
||||
zoom: number;
|
||||
onSelect: (id: string) => void;
|
||||
/**
|
||||
* Force-show or force-hide the generation gutter. When undefined, falls
|
||||
* back to a `window.matchMedia('(min-width: 768px)')` detection so the
|
||||
* gutter only appears on md+ viewports. Tests pass an explicit boolean
|
||||
* to avoid depending on the vitest-browser iframe viewport.
|
||||
*/
|
||||
showGutter?: boolean;
|
||||
}
|
||||
|
||||
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
|
||||
|
||||
const NODE_W = 160;
|
||||
const NODE_H = 56;
|
||||
const COL_GAP = 40;
|
||||
const ROW_GAP = 80;
|
||||
const VIEWBOX_PAD = 80;
|
||||
// Minimum viewBox dimensions — keeps a single node from being scaled up
|
||||
// to fill the entire canvas. Roughly matches a typical desktop content area.
|
||||
const MIN_VIEWBOX_W = 1200;
|
||||
const MIN_VIEWBOX_H = 800;
|
||||
|
||||
type Layout = {
|
||||
positions: Map<string, { x: number; y: number }>;
|
||||
generations: Map<number, string[]>;
|
||||
viewX: number;
|
||||
viewY: number;
|
||||
viewW: number;
|
||||
viewH: number;
|
||||
};
|
||||
let { nodes, edges, selectedId, zoom, onSelect, showGutter }: Props = $props();
|
||||
|
||||
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
|
||||
|
||||
// Stammbaum gutter (#689). 100 px column on the left of the canvas on md+
|
||||
// viewports, carrying the G{n} label per generation row. Hidden entirely on
|
||||
// phones (canvas is already overflow-scroll; 100 px of permanent chrome is
|
||||
// too costly on a 320 px screen).
|
||||
const GUTTER_WIDTH_DESKTOP = 100;
|
||||
const GUTTER_MEDIA_QUERY = '(min-width: 768px)';
|
||||
// Seed synchronously so the first paint already has the right gutter state —
|
||||
// otherwise the test (and a brief flash on real CSR mount) would see the
|
||||
// pre-effect false. SSR has no window; the gutter stays hidden until hydrate.
|
||||
let isMdOrUp = $state(
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia(GUTTER_MEDIA_QUERY).matches
|
||||
: false
|
||||
);
|
||||
$effect(() => {
|
||||
if (showGutter !== undefined) return;
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
|
||||
const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
});
|
||||
const gutterVisible = $derived(showGutter ?? isMdOrUp);
|
||||
const gutterWidth = $derived(gutterVisible ? GUTTER_WIDTH_DESKTOP : 0);
|
||||
|
||||
type GutterRow = { rank: number; y: number; label: number | null };
|
||||
const gutterRows = $derived.by<GutterRow[]>(() => {
|
||||
if (gutterWidth === 0) return [];
|
||||
const byId = new SvelteMap(nodes.map((n) => [n.id, n]));
|
||||
const rows: GutterRow[] = [];
|
||||
const sortedRanks = [...layout.generations.keys()].sort((a, b) => a - b);
|
||||
for (const rank of sortedRanks) {
|
||||
const ids = layout.generations.get(rank)!;
|
||||
const firstPos = layout.positions.get(ids[0]);
|
||||
if (!firstPos) continue;
|
||||
let label: number | null = null;
|
||||
for (const id of ids) {
|
||||
const g = byId.get(id)?.generation;
|
||||
if (g != null) {
|
||||
label = g;
|
||||
break;
|
||||
}
|
||||
}
|
||||
rows.push({ rank, y: firstPos.y, label });
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const viewBox = $derived.by(() => {
|
||||
const w = layout.viewW / zoom;
|
||||
const totalW = layout.viewW + gutterWidth;
|
||||
const w = totalW / zoom;
|
||||
const h = layout.viewH / zoom;
|
||||
const cx = layout.viewX + layout.viewW / 2;
|
||||
const cx = layout.viewX - gutterWidth + totalW / 2;
|
||||
const cy = layout.viewY + layout.viewH / 2;
|
||||
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
||||
});
|
||||
|
||||
function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
|
||||
const parentToChildren = new Map<string, string[]>();
|
||||
const childToParents = new Map<string, string[]>();
|
||||
const spousePairs = new Map<string, string>();
|
||||
|
||||
for (const e of allEdges) {
|
||||
switch (e.relationType) {
|
||||
case 'PARENT_OF':
|
||||
mapPush(parentToChildren, e.personId, e.relatedPersonId);
|
||||
mapPush(childToParents, e.relatedPersonId, e.personId);
|
||||
break;
|
||||
case 'SPOUSE_OF':
|
||||
spousePairs.set(e.personId, e.relatedPersonId);
|
||||
spousePairs.set(e.relatedPersonId, e.personId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Iterative longest-path generation assignment.
|
||||
//
|
||||
// Each node's generation = max(parent generations) + 1 (roots stay at 0).
|
||||
// Then spouses are pulled to share the deeper generation. Pulling a spouse
|
||||
// down can shift their own descendants, so we iterate until stable rather
|
||||
// than running BFS once like the previous implementation (which left
|
||||
// e.g. a child of a "later-pulled" spouse stranded one row too high).
|
||||
const generation = new Map<string, number>();
|
||||
for (const n of allNodes) generation.set(n.id, 0);
|
||||
const maxIters = allNodes.length + 4;
|
||||
for (let it = 0; it < maxIters; it++) {
|
||||
let changed = false;
|
||||
for (const n of allNodes) {
|
||||
const parents = childToParents.get(n.id) ?? [];
|
||||
if (parents.length === 0) continue;
|
||||
let maxParentGen = -1;
|
||||
for (const pid of parents) {
|
||||
maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0);
|
||||
}
|
||||
const newGen = maxParentGen + 1;
|
||||
if ((generation.get(n.id) ?? 0) < newGen) {
|
||||
generation.set(n.id, newGen);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const [a, b] of spousePairs) {
|
||||
const m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
|
||||
if ((generation.get(a) ?? 0) < m) {
|
||||
generation.set(a, m);
|
||||
changed = true;
|
||||
}
|
||||
if ((generation.get(b) ?? 0) < m) {
|
||||
generation.set(b, m);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
// Group by generation, then sort within generation by display name.
|
||||
const generations = new Map<number, string[]>();
|
||||
for (const n of allNodes) {
|
||||
const g = generation.get(n.id) ?? 0;
|
||||
if (!generations.has(g)) generations.set(g, []);
|
||||
generations.get(g)!.push(n.id);
|
||||
}
|
||||
const byId = new Map(allNodes.map((n) => [n.id, n]));
|
||||
for (const ids of generations.values()) {
|
||||
ids.sort((a, b) => {
|
||||
const an = byId.get(a)?.displayName ?? '';
|
||||
const bn = byId.get(b)?.displayName ?? '';
|
||||
return an.localeCompare(bn);
|
||||
});
|
||||
}
|
||||
|
||||
// Per-generation layout:
|
||||
//
|
||||
// 1. Build sibling-groups (children of the same parent set) — these become
|
||||
// the layout "blocks" that are centred under their parents' midpoint.
|
||||
// 2. Attach loose spouses (people with no parents in the graph but a
|
||||
// spouse who *is* in a sibling group) on the outside of their partner,
|
||||
// so the spouse line stays short and adjacent.
|
||||
// 3. Merge dual-loose spouse pairs into a single 2-person block.
|
||||
// 4. Centre each block such that its *parented* members average sits
|
||||
// exactly under the parent midpoint (keeping all connectors at 90°),
|
||||
// then pack blocks left-to-right.
|
||||
type Block = {
|
||||
members: { id: string; parented: boolean }[];
|
||||
center: number;
|
||||
};
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
|
||||
|
||||
for (let gi = 0; gi < sortedGens.length; gi++) {
|
||||
const g = sortedGens[gi];
|
||||
const ids = generations.get(g)!;
|
||||
const y = g * (NODE_H + ROW_GAP);
|
||||
|
||||
const blocksByKey = new Map<string, Block>();
|
||||
const memberLookup = new Map<string, { key: string; parented: boolean }>();
|
||||
|
||||
// Step 1: place every node with parents-in-graph into a sibling block.
|
||||
for (const id of ids) {
|
||||
const parents = childToParents.get(id) ?? [];
|
||||
if (parents.length === 0) continue;
|
||||
const blockKey = [...parents].sort().join('|');
|
||||
let block = blocksByKey.get(blockKey);
|
||||
if (!block) {
|
||||
const parentCenters: number[] = [];
|
||||
for (const pid of parents) {
|
||||
const p = positions.get(pid);
|
||||
if (p) parentCenters.push(p.x + NODE_W / 2);
|
||||
}
|
||||
const center =
|
||||
parentCenters.length > 0
|
||||
? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length
|
||||
: 0;
|
||||
block = { members: [], center };
|
||||
blocksByKey.set(blockKey, block);
|
||||
}
|
||||
block.members.push({ id, parented: true });
|
||||
memberLookup.set(id, { key: blockKey, parented: true });
|
||||
}
|
||||
|
||||
// Sort members within each sibling block alphabetically.
|
||||
for (const block of blocksByKey.values()) {
|
||||
block.members.sort((a, b) =>
|
||||
(byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2 + 3: handle loose nodes.
|
||||
for (const id of ids) {
|
||||
if (memberLookup.has(id)) continue;
|
||||
const spouse = spousePairs.get(id);
|
||||
const spouseLookup = spouse ? memberLookup.get(spouse) : undefined;
|
||||
|
||||
if (spouseLookup && spouseLookup.parented) {
|
||||
// Spouse is parented — attach this loose node next to them on
|
||||
// the outer edge of their sibling block so the marriage line
|
||||
// is short and the sibling order is preserved.
|
||||
const block = blocksByKey.get(spouseLookup.key)!;
|
||||
const spouseIdx = block.members.findIndex((m) => m.id === spouse);
|
||||
const insertOnRight = spouseIdx >= block.members.length / 2;
|
||||
const insertAt = insertOnRight ? spouseIdx + 1 : spouseIdx;
|
||||
block.members.splice(insertAt, 0, { id, parented: false });
|
||||
memberLookup.set(id, { key: spouseLookup.key, parented: false });
|
||||
} else {
|
||||
// No usable parented spouse: put in its own loose block. We
|
||||
// merge dual-loose spouse pairs in the next pass.
|
||||
const blockKey = `__loose__${id}`;
|
||||
blocksByKey.set(blockKey, {
|
||||
members: [{ id, parented: false }],
|
||||
center: 0
|
||||
});
|
||||
memberLookup.set(id, { key: blockKey, parented: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Merge dual-loose spouse blocks into a single 2-person block.
|
||||
const removed = new Set<string>();
|
||||
for (const [key, block] of blocksByKey) {
|
||||
if (!key.startsWith('__loose__')) continue;
|
||||
if (removed.has(key)) continue;
|
||||
const member = block.members[0];
|
||||
const spouse = spousePairs.get(member.id);
|
||||
if (!spouse) continue;
|
||||
const spouseLookup = memberLookup.get(spouse);
|
||||
if (!spouseLookup || removed.has(spouseLookup.key)) continue;
|
||||
if (spouseLookup.key === key) continue;
|
||||
if (!spouseLookup.key.startsWith('__loose__')) continue;
|
||||
const otherBlock = blocksByKey.get(spouseLookup.key)!;
|
||||
block.members.push(...otherBlock.members);
|
||||
removed.add(spouseLookup.key);
|
||||
}
|
||||
for (const key of removed) blocksByKey.delete(key);
|
||||
|
||||
// Step 4: centre each block on its anchor (parented members) and pack.
|
||||
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
|
||||
let cursorRight = -Infinity;
|
||||
for (const block of ordered) {
|
||||
const n = block.members.length;
|
||||
const groupWidth = n * NODE_W + (n - 1) * COL_GAP;
|
||||
const anchorIndices: number[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (block.members[i].parented) anchorIndices.push(i);
|
||||
}
|
||||
const avgAnchorIdx =
|
||||
anchorIndices.length > 0
|
||||
? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length
|
||||
: (n - 1) / 2;
|
||||
let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP);
|
||||
if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP;
|
||||
for (let i = 0; i < n; i++) {
|
||||
positions.set(block.members[i].id, {
|
||||
x: groupLeft + i * (NODE_W + COL_GAP),
|
||||
y
|
||||
});
|
||||
}
|
||||
cursorRight = groupLeft + groupWidth;
|
||||
}
|
||||
}
|
||||
|
||||
// Bounding box around the actual content, then expanded to MIN dimensions
|
||||
// (so a single node doesn't get scaled up to fill the canvas). The viewBox
|
||||
// is centered on the content.
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const p of positions.values()) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x + NODE_W);
|
||||
maxY = Math.max(maxY, p.y + NODE_H);
|
||||
}
|
||||
if (positions.size === 0) {
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
maxX = 0;
|
||||
maxY = 0;
|
||||
}
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const viewW = Math.max(contentW + 2 * VIEWBOX_PAD, MIN_VIEWBOX_W);
|
||||
const viewH = Math.max(contentH + 2 * VIEWBOX_PAD, MIN_VIEWBOX_H);
|
||||
const viewX = minX + contentW / 2 - viewW / 2;
|
||||
const viewY = minY + contentH / 2 - viewH / 2;
|
||||
return { positions, generations, viewX, viewY, viewW, viewH };
|
||||
}
|
||||
|
||||
function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(value);
|
||||
else map.set(key, [value]);
|
||||
}
|
||||
|
||||
function nodeCenter(id: string): { x: number; y: number } | null {
|
||||
const p = layout.positions.get(id);
|
||||
if (!p) return null;
|
||||
@@ -312,22 +120,25 @@ type ParentLinks = {
|
||||
};
|
||||
|
||||
const parentLinks = $derived.by<ParentLinks>(() => {
|
||||
const spousePairs = new Set<string>();
|
||||
const spousePairs = new SvelteSet<string>();
|
||||
for (const e of spouseEdges) {
|
||||
spousePairs.add(pairKey(e.personId, e.relatedPersonId));
|
||||
}
|
||||
|
||||
const childToParents = new Map<string, string[]>();
|
||||
const childToParents = new SvelteMap<string, string[]>();
|
||||
for (const e of parentEdges) {
|
||||
const list = childToParents.get(e.relatedPersonId) ?? [];
|
||||
list.push(e.personId);
|
||||
childToParents.set(e.relatedPersonId, list);
|
||||
}
|
||||
|
||||
const sharedMap = new Map<string, { parentA: string; parentB: string; childIds: string[] }>();
|
||||
const sharedMap = new SvelteMap<
|
||||
string,
|
||||
{ parentA: string; parentB: string; childIds: string[] }
|
||||
>();
|
||||
const single: ParentLinks['single'] = [];
|
||||
for (const [childId, parents] of childToParents) {
|
||||
const consumed = new Set<string>();
|
||||
const consumed = new SvelteSet<string>();
|
||||
for (let i = 0; i < parents.length; i++) {
|
||||
if (consumed.has(parents[i])) continue;
|
||||
for (let j = i + 1; j < parents.length; j++) {
|
||||
@@ -369,6 +180,44 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
||||
aria-label="Stammbaum"
|
||||
class="block h-full w-full"
|
||||
>
|
||||
<!-- Gutter stripe underlay (#689) — decorative full-row bands alternating
|
||||
transparent / var(--c-gutter-stripe). aria-hidden because they carry
|
||||
no meaning; the row's generation is announced by the label group below. -->
|
||||
{#each gutterRows as row, i (`stripe-${row.rank}`)}
|
||||
<rect
|
||||
aria-hidden="true"
|
||||
x={layout.viewX - gutterWidth}
|
||||
y={row.y - ROW_GAP / 2}
|
||||
width={layout.viewW + gutterWidth}
|
||||
height={NODE_H + ROW_GAP}
|
||||
fill={i % 2 === 0 ? 'transparent' : 'var(--c-gutter-stripe)'}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Gutter labels (#689) — `G{node.generation}` per occupied row at the
|
||||
un-shifted source-truth value. Wrapped in <g role="text"> so screen
|
||||
readers announce "Generation three" instead of "G three". -->
|
||||
{#each gutterRows as row (`label-${row.rank}`)}
|
||||
{#if row.label != null}
|
||||
<g role="text" aria-label={`Generation ${row.label}`}>
|
||||
<text
|
||||
x={layout.viewX - gutterWidth + 12}
|
||||
y={row.y + NODE_H / 2}
|
||||
text-anchor="start"
|
||||
dominant-baseline="middle"
|
||||
font-family="var(--font-sans)"
|
||||
font-size="12"
|
||||
font-weight="700"
|
||||
letter-spacing="0.08em"
|
||||
fill="var(--c-ink-2)"
|
||||
style:text-transform="uppercase"
|
||||
>
|
||||
G{row.label}
|
||||
</text>
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
|
||||
bar, then short verticals from the bar to each child top. -->
|
||||
{#each parentLinks.shared as group (group.key)}
|
||||
|
||||
@@ -648,3 +648,58 @@ describe('StammbaumTree node rendering branches', () => {
|
||||
expect(accentRects.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StammbaumTree generation gutter (#689)', () => {
|
||||
it('renders a G{n} label per occupied generation row when at least one node carries generation', async () => {
|
||||
// showGutter overrides the matchMedia detection so the test never
|
||||
// depends on the vitest-browser iframe viewport width.
|
||||
render(StammbaumTree, {
|
||||
nodes: [
|
||||
{ id: ID_A, displayName: 'Walter', familyMember: true, generation: 2 },
|
||||
{ id: ID_B, displayName: 'Herbert', familyMember: true, generation: 3 }
|
||||
],
|
||||
edges: [],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {},
|
||||
showGutter: true
|
||||
});
|
||||
|
||||
const labels = Array.from(document.querySelectorAll('g[role="text"]')).map((g) =>
|
||||
g.getAttribute('aria-label')
|
||||
);
|
||||
expect(labels).toContain('Generation 2');
|
||||
expect(labels).toContain('Generation 3');
|
||||
});
|
||||
|
||||
it('wraps the visible G3 text inside an aria-labelled group so screen readers announce "Generation"', async () => {
|
||||
render(StammbaumTree, {
|
||||
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
||||
edges: [],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {},
|
||||
showGutter: true
|
||||
});
|
||||
|
||||
const g3 = Array.from(document.querySelectorAll('g[role="text"]')).find(
|
||||
(g) => g.getAttribute('aria-label') === 'Generation 3'
|
||||
);
|
||||
expect(g3).toBeDefined();
|
||||
expect(g3!.querySelector('text')!.textContent).toMatch(/G\s*3/);
|
||||
});
|
||||
|
||||
it('omits the gutter when showGutter is false (mobile breakpoint case)', async () => {
|
||||
render(StammbaumTree, {
|
||||
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
||||
edges: [],
|
||||
selectedId: null,
|
||||
zoom: 1,
|
||||
onSelect: () => {},
|
||||
showGutter: false
|
||||
});
|
||||
|
||||
const labelGroups = Array.from(document.querySelectorAll('g[role="text"]'));
|
||||
expect(labelGroups).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
113
frontend/src/lib/person/genealogy/layout/buildLayout.test.ts
Normal file
113
frontend/src/lib/person/genealogy/layout/buildLayout.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildLayout, NODE_H, ROW_GAP } from './buildLayout';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
const PARENT = '00000000-0000-0000-0000-000000000001';
|
||||
const CHILD = '00000000-0000-0000-0000-000000000002';
|
||||
const SPOUSE_A = '00000000-0000-0000-0000-000000000003';
|
||||
const SPOUSE_B = '00000000-0000-0000-0000-000000000004';
|
||||
const NEGATIVE_A = '00000000-0000-0000-0000-000000000005';
|
||||
const NEGATIVE_B = '00000000-0000-0000-0000-000000000006';
|
||||
const NEGATIVE_C = '00000000-0000-0000-0000-000000000007';
|
||||
|
||||
function node(id: string, displayName: string, generation: number | null = null): PersonNodeDTO {
|
||||
return generation == null
|
||||
? { id, displayName, familyMember: true }
|
||||
: { id, displayName, familyMember: true, generation };
|
||||
}
|
||||
|
||||
function parentEdge(parentId: string, childId: string, id = parentId + childId): RelationshipDTO {
|
||||
return {
|
||||
id,
|
||||
personId: parentId,
|
||||
relatedPersonId: childId,
|
||||
personDisplayName: '',
|
||||
relatedPersonDisplayName: '',
|
||||
relationType: 'PARENT_OF'
|
||||
};
|
||||
}
|
||||
|
||||
function spouseEdge(a: string, b: string, id = a + b): RelationshipDTO {
|
||||
return {
|
||||
id,
|
||||
personId: a,
|
||||
relatedPersonId: b,
|
||||
personDisplayName: '',
|
||||
relatedPersonDisplayName: '',
|
||||
relationType: 'SPOUSE_OF'
|
||||
};
|
||||
}
|
||||
|
||||
function yOf(layout: ReturnType<typeof buildLayout>, id: string): number {
|
||||
const p = layout.positions.get(id);
|
||||
if (!p) throw new Error(`No position for ${id}`);
|
||||
return p.y;
|
||||
}
|
||||
|
||||
describe('buildLayout — generation seeding (#689)', () => {
|
||||
it('Herbert Cram regression: two parented G=3 spouses share the same row', () => {
|
||||
// Both Herbert (G 3) and Clara (G 3) are parented children of their respective
|
||||
// G 2 ancestors. They are spouses. Before #689 the iterative longest-path put
|
||||
// Herbert one row deeper than Clara via the spouse-pulldown of his loose parent.
|
||||
// With imported generation as a strict seed both render at the same y.
|
||||
const layout = buildLayout(
|
||||
[node(SPOUSE_A, 'Herbert', 3), node(SPOUSE_B, 'Clara', 3)],
|
||||
[spouseEdge(SPOUSE_A, SPOUSE_B)]
|
||||
);
|
||||
|
||||
expect(yOf(layout, SPOUSE_A)).toBe(yOf(layout, SPOUSE_B));
|
||||
});
|
||||
|
||||
it('strict-seed override: imported generation pins rank even when parent edges imply deeper', () => {
|
||||
// PARENT has no explicit generation → falls back to 0. CHILD is parented under
|
||||
// PARENT but has imported generation = 3. The seeded rank wins; the heuristic
|
||||
// must not push CHILD to rank 1.
|
||||
const layout = buildLayout(
|
||||
[node(PARENT, 'Parent'), node(CHILD, 'Child', 3)],
|
||||
[parentEdge(PARENT, CHILD)]
|
||||
);
|
||||
|
||||
expect(yOf(layout, CHILD)).toBe(3 * (NODE_H + ROW_GAP));
|
||||
});
|
||||
|
||||
it('fallback inherits seeded parent rank: G 2 parent → null-gen child lands at rank 3', () => {
|
||||
// CHILD has no imported generation. PARENT has generation = 2. The fallback
|
||||
// reads PARENT's rank from the unified rank map (2) and computes 2 + 1 = 3.
|
||||
const layout = buildLayout(
|
||||
[node(PARENT, 'Parent', 2), node(CHILD, 'Child')],
|
||||
[parentEdge(PARENT, CHILD)]
|
||||
);
|
||||
|
||||
expect(yOf(layout, CHILD)).toBe(3 * (NODE_H + ROW_GAP));
|
||||
});
|
||||
|
||||
it('normalise is a no-op when all ranks are non-negative', () => {
|
||||
// Seeded ranks [3, 4, 5] → y must reflect [3, 4, 5] without any shift.
|
||||
const G3 = '00000000-0000-0000-0000-000000000031';
|
||||
const G4 = '00000000-0000-0000-0000-000000000032';
|
||||
const G5 = '00000000-0000-0000-0000-000000000033';
|
||||
const layout = buildLayout(
|
||||
[node(G3, 'three', 3), node(G4, 'four', 4), node(G5, 'five', 5)],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(yOf(layout, G3)).toBe(3 * (NODE_H + ROW_GAP));
|
||||
expect(yOf(layout, G4)).toBe(4 * (NODE_H + ROW_GAP));
|
||||
expect(yOf(layout, G5)).toBe(5 * (NODE_H + ROW_GAP));
|
||||
});
|
||||
|
||||
it('normalise shifts negative seeds so min rank becomes 0', () => {
|
||||
// Seeded ranks [-1, 0, 1] → after shift they render at [0, 1, 2] y-rows.
|
||||
const layout = buildLayout(
|
||||
[node(NEGATIVE_A, 'minus-one', -1), node(NEGATIVE_B, 'zero', 0), node(NEGATIVE_C, 'one', 1)],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(yOf(layout, NEGATIVE_A)).toBe(0);
|
||||
expect(yOf(layout, NEGATIVE_B)).toBe(1 * (NODE_H + ROW_GAP));
|
||||
expect(yOf(layout, NEGATIVE_C)).toBe(2 * (NODE_H + ROW_GAP));
|
||||
});
|
||||
});
|
||||
279
frontend/src/lib/person/genealogy/layout/buildLayout.ts
Normal file
279
frontend/src/lib/person/genealogy/layout/buildLayout.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
export const NODE_W = 160;
|
||||
export const NODE_H = 56;
|
||||
export const COL_GAP = 40;
|
||||
export const ROW_GAP = 80;
|
||||
export const VIEWBOX_PAD = 80;
|
||||
export const MIN_VIEWBOX_W = 1200;
|
||||
export const MIN_VIEWBOX_H = 800;
|
||||
|
||||
export type Layout = {
|
||||
positions: Map<string, { x: number; y: number }>;
|
||||
generations: Map<number, string[]>;
|
||||
viewX: number;
|
||||
viewY: number;
|
||||
viewW: number;
|
||||
viewH: number;
|
||||
};
|
||||
|
||||
export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
|
||||
const parentToChildren = new Map<string, string[]>();
|
||||
const childToParents = new Map<string, string[]>();
|
||||
const spousePairs = new Map<string, string>();
|
||||
|
||||
for (const e of allEdges) {
|
||||
switch (e.relationType) {
|
||||
case 'PARENT_OF':
|
||||
mapPush(parentToChildren, e.personId, e.relatedPersonId);
|
||||
mapPush(childToParents, e.relatedPersonId, e.personId);
|
||||
break;
|
||||
case 'SPOUSE_OF':
|
||||
spousePairs.set(e.personId, e.relatedPersonId);
|
||||
spousePairs.set(e.relatedPersonId, e.personId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Two-stage rank assignment (#689):
|
||||
//
|
||||
// 1. Seed: every node with imported generation is locked at that rank.
|
||||
// The fallback heuristic never moves a locked rank, and spouse-pulldown
|
||||
// never pulls a locked rank.
|
||||
// 2. Fallback: for the remaining (unseeded) nodes, rank = max(parent rank)
|
||||
// + 1, reading parent rank from the same unified map so an unseeded
|
||||
// child of a seeded G 2 parent correctly inherits rank 3. Spouse-
|
||||
// pulldown ties unseeded spouses to their deeper partner.
|
||||
// 3. Normalise: if any seeded rank is negative (a future G −1 ancestor),
|
||||
// shift the entire map so min(rank) == 0. No-op fast path covers
|
||||
// today's data.
|
||||
const rank = new Map<string, number>();
|
||||
const locked = new Set<string>();
|
||||
for (const n of allNodes) {
|
||||
if (n.generation != null) {
|
||||
rank.set(n.id, n.generation);
|
||||
locked.add(n.id);
|
||||
} else {
|
||||
rank.set(n.id, 0);
|
||||
}
|
||||
}
|
||||
const maxIters = allNodes.length + 4;
|
||||
for (let it = 0; it < maxIters; it++) {
|
||||
let changed = false;
|
||||
for (const n of allNodes) {
|
||||
if (locked.has(n.id)) continue;
|
||||
const parents = childToParents.get(n.id) ?? [];
|
||||
if (parents.length === 0) continue;
|
||||
let maxParentRank = -Infinity;
|
||||
for (const pid of parents) {
|
||||
maxParentRank = Math.max(maxParentRank, rank.get(pid) ?? 0);
|
||||
}
|
||||
const newRank = maxParentRank + 1;
|
||||
if ((rank.get(n.id) ?? 0) < newRank) {
|
||||
rank.set(n.id, newRank);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const [a, b] of spousePairs) {
|
||||
const ra = rank.get(a) ?? 0;
|
||||
const rb = rank.get(b) ?? 0;
|
||||
const m = Math.max(ra, rb);
|
||||
if (!locked.has(a) && ra < m) {
|
||||
rank.set(a, m);
|
||||
changed = true;
|
||||
}
|
||||
if (!locked.has(b) && rb < m) {
|
||||
rank.set(b, m);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
let minRank = Infinity;
|
||||
for (const r of rank.values()) minRank = Math.min(minRank, r);
|
||||
if (minRank < 0) {
|
||||
const shift = -minRank;
|
||||
for (const [id, r] of rank) rank.set(id, r + shift);
|
||||
}
|
||||
|
||||
// Group by rank, then sort within rank by display name.
|
||||
const generations = new Map<number, string[]>();
|
||||
for (const n of allNodes) {
|
||||
const g = rank.get(n.id) ?? 0;
|
||||
if (!generations.has(g)) generations.set(g, []);
|
||||
generations.get(g)!.push(n.id);
|
||||
}
|
||||
const byId = new Map(allNodes.map((n) => [n.id, n]));
|
||||
for (const ids of generations.values()) {
|
||||
ids.sort((a, b) => {
|
||||
const an = byId.get(a)?.displayName ?? '';
|
||||
const bn = byId.get(b)?.displayName ?? '';
|
||||
return an.localeCompare(bn);
|
||||
});
|
||||
}
|
||||
|
||||
// Per-generation layout:
|
||||
//
|
||||
// 1. Build sibling-groups (children of the same parent set) — these become
|
||||
// the layout "blocks" that are centred under their parents' midpoint.
|
||||
// 2. Attach loose spouses (people with no parents in the graph but a
|
||||
// spouse who *is* in a sibling group) on the outside of their partner,
|
||||
// so the spouse line stays short and adjacent.
|
||||
// 3. Merge dual-loose spouse pairs into a single 2-person block.
|
||||
// 4. Centre each block such that its *parented* members average sits
|
||||
// exactly under the parent midpoint (keeping all connectors at 90°),
|
||||
// then pack blocks left-to-right.
|
||||
type Block = {
|
||||
members: { id: string; parented: boolean }[];
|
||||
center: number;
|
||||
};
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
|
||||
|
||||
for (let gi = 0; gi < sortedGens.length; gi++) {
|
||||
const g = sortedGens[gi];
|
||||
const ids = generations.get(g)!;
|
||||
const y = g * (NODE_H + ROW_GAP);
|
||||
|
||||
const blocksByKey = new Map<string, Block>();
|
||||
const memberLookup = new Map<string, { key: string; parented: boolean }>();
|
||||
|
||||
// Step 1: place every node with parents-in-graph into a sibling block.
|
||||
for (const id of ids) {
|
||||
const parents = childToParents.get(id) ?? [];
|
||||
if (parents.length === 0) continue;
|
||||
const blockKey = [...parents].sort().join('|');
|
||||
let block = blocksByKey.get(blockKey);
|
||||
if (!block) {
|
||||
const parentCenters: number[] = [];
|
||||
for (const pid of parents) {
|
||||
const p = positions.get(pid);
|
||||
if (p) parentCenters.push(p.x + NODE_W / 2);
|
||||
}
|
||||
const center =
|
||||
parentCenters.length > 0
|
||||
? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length
|
||||
: 0;
|
||||
block = { members: [], center };
|
||||
blocksByKey.set(blockKey, block);
|
||||
}
|
||||
block.members.push({ id, parented: true });
|
||||
memberLookup.set(id, { key: blockKey, parented: true });
|
||||
}
|
||||
|
||||
// Sort members within each sibling block alphabetically.
|
||||
for (const block of blocksByKey.values()) {
|
||||
block.members.sort((a, b) =>
|
||||
(byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2 + 3: handle loose nodes.
|
||||
for (const id of ids) {
|
||||
if (memberLookup.has(id)) continue;
|
||||
const spouse = spousePairs.get(id);
|
||||
const spouseLookup = spouse ? memberLookup.get(spouse) : undefined;
|
||||
|
||||
if (spouseLookup && spouseLookup.parented) {
|
||||
// Spouse is parented — attach this loose node next to them on
|
||||
// the outer edge of their sibling block so the marriage line
|
||||
// is short and the sibling order is preserved.
|
||||
const block = blocksByKey.get(spouseLookup.key)!;
|
||||
const spouseIdx = block.members.findIndex((m) => m.id === spouse);
|
||||
const insertOnRight = spouseIdx >= block.members.length / 2;
|
||||
const insertAt = insertOnRight ? spouseIdx + 1 : spouseIdx;
|
||||
block.members.splice(insertAt, 0, { id, parented: false });
|
||||
memberLookup.set(id, { key: spouseLookup.key, parented: false });
|
||||
} else {
|
||||
// No usable parented spouse: put in its own loose block. We
|
||||
// merge dual-loose spouse pairs in the next pass.
|
||||
const blockKey = `__loose__${id}`;
|
||||
blocksByKey.set(blockKey, {
|
||||
members: [{ id, parented: false }],
|
||||
center: 0
|
||||
});
|
||||
memberLookup.set(id, { key: blockKey, parented: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Merge dual-loose spouse blocks into a single 2-person block.
|
||||
const removed = new Set<string>();
|
||||
for (const [key, block] of blocksByKey) {
|
||||
if (!key.startsWith('__loose__')) continue;
|
||||
if (removed.has(key)) continue;
|
||||
const member = block.members[0];
|
||||
const spouse = spousePairs.get(member.id);
|
||||
if (!spouse) continue;
|
||||
const spouseLookup = memberLookup.get(spouse);
|
||||
if (!spouseLookup || removed.has(spouseLookup.key)) continue;
|
||||
if (spouseLookup.key === key) continue;
|
||||
if (!spouseLookup.key.startsWith('__loose__')) continue;
|
||||
const otherBlock = blocksByKey.get(spouseLookup.key)!;
|
||||
block.members.push(...otherBlock.members);
|
||||
removed.add(spouseLookup.key);
|
||||
}
|
||||
for (const key of removed) blocksByKey.delete(key);
|
||||
|
||||
// Step 4: centre each block on its anchor (parented members) and pack.
|
||||
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
|
||||
let cursorRight = -Infinity;
|
||||
for (const block of ordered) {
|
||||
const n = block.members.length;
|
||||
const groupWidth = n * NODE_W + (n - 1) * COL_GAP;
|
||||
const anchorIndices: number[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (block.members[i].parented) anchorIndices.push(i);
|
||||
}
|
||||
const avgAnchorIdx =
|
||||
anchorIndices.length > 0
|
||||
? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length
|
||||
: (n - 1) / 2;
|
||||
let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP);
|
||||
if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP;
|
||||
for (let i = 0; i < n; i++) {
|
||||
positions.set(block.members[i].id, {
|
||||
x: groupLeft + i * (NODE_W + COL_GAP),
|
||||
y
|
||||
});
|
||||
}
|
||||
cursorRight = groupLeft + groupWidth;
|
||||
}
|
||||
}
|
||||
|
||||
// Bounding box around the actual content, then expanded to MIN dimensions
|
||||
// (so a single node doesn't get scaled up to fill the canvas). The viewBox
|
||||
// is centered on the content.
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const p of positions.values()) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x + NODE_W);
|
||||
maxY = Math.max(maxY, p.y + NODE_H);
|
||||
}
|
||||
if (positions.size === 0) {
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
maxX = 0;
|
||||
maxY = 0;
|
||||
}
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const viewW = Math.max(contentW + 2 * VIEWBOX_PAD, MIN_VIEWBOX_W);
|
||||
const viewH = Math.max(contentH + 2 * VIEWBOX_PAD, MIN_VIEWBOX_H);
|
||||
const viewX = minX + contentW / 2 - viewW / 2;
|
||||
const viewY = minY + contentH / 2 - viewH / 2;
|
||||
return { positions, generations, viewX, viewY, viewW, viewH };
|
||||
}
|
||||
|
||||
function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(value);
|
||||
else map.set(key, [value]);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type PersonFormData = {
|
||||
alias?: string | null;
|
||||
birthYear?: number | null;
|
||||
deathYear?: number | null;
|
||||
generation?: number | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -160,6 +160,12 @@
|
||||
with axe (tracked in #480) before tweaking the palette. */
|
||||
--timeline-bar-idle: rgba(161, 220, 216, 0.35);
|
||||
--timeline-bar-outside: var(--c-line);
|
||||
|
||||
/* Stammbaum gutter stripe (issue #689) — decorative full-row underlay
|
||||
alternating with transparent. Mint-tinted on canvas to mark generation
|
||||
rows without competing with node fills. 8% on light surface ≈ #ECF6F4
|
||||
(~1.04:1 vs canvas — decorative carve-out). */
|
||||
--c-gutter-stripe: rgba(161, 220, 216, 0.08);
|
||||
}
|
||||
|
||||
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
||||
@@ -236,6 +242,10 @@
|
||||
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
||||
--timeline-bar-idle: #3a6e8c;
|
||||
--timeline-bar-outside: #1a2735;
|
||||
|
||||
/* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for
|
||||
visibility parity with the 8% light-mode token. Decorative carve-out. */
|
||||
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,6 +318,9 @@
|
||||
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
||||
--timeline-bar-idle: #3a6e8c;
|
||||
--timeline-bar-outside: #1a2735;
|
||||
|
||||
/* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */
|
||||
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
|
||||
}
|
||||
|
||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||
|
||||
@@ -51,6 +51,12 @@ export const actions = {
|
||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
||||
// Must NOT use the conditional-spread idiom for generation: G 0 is a
|
||||
// valid family-tree-root value. The key always travels in the body so
|
||||
// an explicit clear (empty option) reaches the backend as null.
|
||||
const generationRaw = formData.get('generation');
|
||||
const generation =
|
||||
generationRaw == null || generationRaw.toString() === '' ? null : Number(generationRaw);
|
||||
|
||||
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||
if (validationKey) {
|
||||
@@ -68,7 +74,8 @@ export const actions = {
|
||||
...(alias ? { alias } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
...(birthYear ? { birthYear } : {}),
|
||||
...(deathYear ? { deathYear } : {})
|
||||
...(deathYear ? { deathYear } : {}),
|
||||
generation
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ let selectedType = $state<PersonType>(
|
||||
)
|
||||
);
|
||||
|
||||
// Match the selectedType initialiser pattern: untrack so a subsequent prop
|
||||
// update (e.g. load() rerun) does not reset the user's in-progress edit.
|
||||
let generationStr = $state(
|
||||
untrack(() => (person.generation == null ? '' : String(person.generation)))
|
||||
);
|
||||
|
||||
const isPerson = $derived(selectedType === 'PERSON');
|
||||
const lastNameLabel = $derived(
|
||||
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
|
||||
@@ -108,6 +114,28 @@ const inputCls =
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="generation" class={labelCls}>{m.person_label_generation()}</label>
|
||||
<select
|
||||
id="generation"
|
||||
name="generation"
|
||||
bind:value={generationStr}
|
||||
class="block min-h-[44px] w-full rounded border border-line bg-surface px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
aria-describedby="generation-hint"
|
||||
>
|
||||
<option value="">{m.person_option_generation_unset()}</option>
|
||||
<option value="0">G 0</option>
|
||||
<option value="1">G 1</option>
|
||||
<option value="2">G 2</option>
|
||||
<option value="3">G 3</option>
|
||||
<option value="4">G 4</option>
|
||||
<option value="5">G 5</option>
|
||||
<option value="6">G 6</option>
|
||||
</select>
|
||||
<p id="generation-hint" class="mt-1 font-sans text-xs text-ink-3">
|
||||
{m.person_hint_generation()}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="md:col-span-2">
|
||||
|
||||
@@ -113,4 +113,48 @@ describe('PersonEditForm', () => {
|
||||
expect(alias.value).toBe('');
|
||||
expect(birthYear.value).toBe('');
|
||||
});
|
||||
|
||||
// ─── generation dropdown (#689) ─────────────────────────────────────────────
|
||||
|
||||
it('renders the generation select with G 0…G 6 options when personType is PERSON', async () => {
|
||||
render(PersonEditForm, { props: { person: personPersonal } });
|
||||
|
||||
const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement;
|
||||
const labels = Array.from(select.options).map((o) => o.label.trim());
|
||||
expect(labels).toEqual(
|
||||
expect.arrayContaining(['G 0', 'G 1', 'G 2', 'G 3', 'G 4', 'G 5', 'G 6'])
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the generation select for INSTITUTION', async () => {
|
||||
render(PersonEditForm, { props: { person: personInstitution } });
|
||||
|
||||
await expect.element(page.getByLabelText(/^generation$/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hydrates the generation select from person.generation', async () => {
|
||||
render(PersonEditForm, {
|
||||
props: {
|
||||
person: { ...personPersonal, generation: 3 } as typeof personPersonal & {
|
||||
generation: number;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement;
|
||||
expect(select.value).toBe('3');
|
||||
});
|
||||
|
||||
it('hydrates the generation select to "" when person.generation is null', async () => {
|
||||
render(PersonEditForm, {
|
||||
props: {
|
||||
person: { ...personPersonal, generation: null } as typeof personPersonal & {
|
||||
generation: number | null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement;
|
||||
expect(select.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
99
frontend/src/routes/persons/[id]/edit/page.server.spec.ts
Normal file
99
frontend/src/routes/persons/[id]/edit/page.server.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const mockFetch = vi.fn() as unknown as typeof fetch;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeFormData(overrides: Partial<Record<string, string>> = {}): {
|
||||
request: Request;
|
||||
redirectThrown: () => unknown;
|
||||
} {
|
||||
const fd = new FormData();
|
||||
fd.set('personType', 'PERSON');
|
||||
fd.set('firstName', 'Hans');
|
||||
fd.set('lastName', 'Müller');
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v == null) fd.delete(k);
|
||||
else fd.set(k, v);
|
||||
}
|
||||
return {
|
||||
request: new Request('http://localhost/persons/p1/edit', { method: 'POST', body: fd }),
|
||||
redirectThrown: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
describe('persons/[id]/edit update action — generation (#689)', () => {
|
||||
it('always includes generation in the PUT body — even when value is 0', async () => {
|
||||
const put = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const { request } = makeFormData({ generation: '0' });
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
|
||||
} catch {
|
||||
// redirect throws on success — ignore
|
||||
}
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
const body = put.mock.calls[0][1].body;
|
||||
expect(body).toHaveProperty('generation', 0);
|
||||
});
|
||||
|
||||
it('sends generation: null when the dropdown is cleared (empty option)', async () => {
|
||||
const put = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const { request } = makeFormData({ generation: '' });
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
|
||||
} catch {
|
||||
// redirect throws on success — ignore
|
||||
}
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
const body = put.mock.calls[0][1].body;
|
||||
expect(body).toHaveProperty('generation', null);
|
||||
});
|
||||
|
||||
it('sends generation: 3 when the dropdown carries G 3', async () => {
|
||||
const put = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p1' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ PUT: put } as unknown as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const { request } = makeFormData({ generation: '3' });
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await actions.update({ request, params: { id: 'p1' }, fetch: mockFetch } as any);
|
||||
} catch {
|
||||
// redirect throws on success — ignore
|
||||
}
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
const body = put.mock.calls[0][1].body;
|
||||
expect(body).toHaveProperty('generation', 3);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,12 @@ export const actions = {
|
||||
const birthYearStr = formData.get('birthYear')?.toString().trim();
|
||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||
// Must NOT use the conditional-spread idiom for generation: G 0 is a
|
||||
// valid family-tree-root value. Always travels in the body so an
|
||||
// explicit clear (empty option) reaches the backend as null.
|
||||
const generationRaw = formData.get('generation');
|
||||
const generation =
|
||||
generationRaw == null || generationRaw.toString() === '' ? null : Number(generationRaw);
|
||||
|
||||
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||
if (validationKey) {
|
||||
@@ -52,7 +58,8 @@ export const actions = {
|
||||
...(alias ? { alias } : {}),
|
||||
...(birthYear ? { birthYear } : {}),
|
||||
...(deathYear ? { deathYear } : {}),
|
||||
...(notes ? { notes } : {})
|
||||
...(notes ? { notes } : {}),
|
||||
generation
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
71
frontend/src/routes/persons/new/page.server.spec.ts
Normal file
71
frontend/src/routes/persons/new/page.server.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const mockFetch = vi.fn() as unknown as typeof fetch;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function buildRequest(overrides: Partial<Record<string, string>> = {}): Request {
|
||||
const fd = new FormData();
|
||||
fd.set('personType', 'PERSON');
|
||||
fd.set('firstName', 'Hans');
|
||||
fd.set('lastName', 'Müller');
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v == null) fd.delete(k);
|
||||
else fd.set(k, v);
|
||||
}
|
||||
return new Request('http://localhost/persons/new', { method: 'POST', body: fd });
|
||||
}
|
||||
|
||||
describe('persons/new create action — generation (#689)', () => {
|
||||
it('always includes generation in the POST body — even when value is 0', async () => {
|
||||
const post = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p-new' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const request = buildRequest({ generation: '0' });
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await actions.default({ request, fetch: mockFetch } as any);
|
||||
} catch {
|
||||
// redirect throws on success — ignore
|
||||
}
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
const body = post.mock.calls[0][1].body;
|
||||
expect(body).toHaveProperty('generation', 0);
|
||||
});
|
||||
|
||||
it('sends generation: null when the dropdown is left unset', async () => {
|
||||
const post = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'p-new' } });
|
||||
vi.mocked(createApiClient).mockReturnValue({ POST: post } as unknown as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const request = buildRequest({ generation: '' });
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await actions.default({ request, fetch: mockFetch } as any);
|
||||
} catch {
|
||||
// redirect throws on success — ignore
|
||||
}
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
const body = post.mock.calls[0][1].body;
|
||||
expect(body).toHaveProperty('generation', null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user