Compare commits
10 Commits
1e77d6d98c
...
39276b179d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39276b179d | ||
|
|
577dd3fcb1 | ||
|
|
c0b500b692 | ||
|
|
cb8c85a742 | ||
|
|
c93d3b03ed | ||
|
|
8f163f9b77 | ||
|
|
40511535eb | ||
|
|
a68a822c13 | ||
|
|
df0037cba2 | ||
|
|
dcb5585c64 |
@@ -4,13 +4,21 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the four canonical loaders in their real dependency order — encoded explicitly
|
* 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 PersonRegisterImporter personRegisterImporter;
|
||||||
private final PersonTreeImporter personTreeImporter;
|
private final PersonTreeImporter personTreeImporter;
|
||||||
private final DocumentImporter documentImporter;
|
private final DocumentImporter documentImporter;
|
||||||
|
private final RelationshipService relationshipService;
|
||||||
|
|
||||||
@Value("${app.import.dir:/import}")
|
@Value("${app.import.dir:/import}")
|
||||||
private String canonicalDir;
|
private String canonicalDir;
|
||||||
@@ -67,6 +76,7 @@ public class CanonicalImportOrchestrator {
|
|||||||
tagTreeImporter.load(tagTree);
|
tagTreeImporter.load(tagTree);
|
||||||
personRegisterImporter.load(persons);
|
personRegisterImporter.load(persons);
|
||||||
personTreeImporter.load(personsTree);
|
personTreeImporter.load(personsTree);
|
||||||
|
warnOnGenerationMonotonicityViolations();
|
||||||
DocumentImporter.LoadResult result = documentImporter.load(documents);
|
DocumentImporter.LoadResult result = documentImporter.load(documents);
|
||||||
|
|
||||||
currentStatus = new ImportStatus(ImportStatus.State.DONE, "IMPORT_DONE",
|
currentStatus = new ImportStatus(ImportStatus.State.DONE, "IMPORT_DONE",
|
||||||
@@ -91,4 +101,31 @@ public class CanonicalImportOrchestrator {
|
|||||||
}
|
}
|
||||||
return artifact;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import java.io.File;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
import java.util.List;
|
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
|
* Loads {@code canonical-persons.xlsx} (the register) into the person domain via
|
||||||
@@ -25,6 +27,15 @@ public class PersonRegisterImporter {
|
|||||||
|
|
||||||
static final List<String> REQUIRED_HEADERS = List.of("person_id", "last_name", "first_name", "provisional");
|
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 static final int GENERATION_MIN = 0;
|
||||||
|
private static final int GENERATION_MAX = 10;
|
||||||
|
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
|
|
||||||
public int load(File artifact) {
|
public int load(File artifact) {
|
||||||
@@ -49,11 +60,31 @@ public class PersonRegisterImporter {
|
|||||||
.notes(blankToNull(row.get("notes")))
|
.notes(blankToNull(row.get("notes")))
|
||||||
.birthYear(yearOf(row.get("birth_date")))
|
.birthYear(yearOf(row.get("birth_date")))
|
||||||
.deathYear(yearOf(row.get("death_date")))
|
.deathYear(yearOf(row.get("death_date")))
|
||||||
|
.generation(parseGeneration(row.get("generation"), personId))
|
||||||
.personType(PersonType.PERSON)
|
.personType(PersonType.PERSON)
|
||||||
.provisional(Boolean.parseBoolean(row.get("provisional")))
|
.provisional(Boolean.parseBoolean(row.get("provisional")))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an optional {@code G n} generation cell. Returns null for blanks,
|
||||||
|
* non-matching strings, and any value outside {@value #GENERATION_MIN}..{@value #GENERATION_MAX}
|
||||||
|
* (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 < GENERATION_MIN || parsed > GENERATION_MAX) {
|
||||||
|
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) {
|
private static Integer yearOf(String isoDate) {
|
||||||
if (isoDate == null || isoDate.isBlank()) return null;
|
if (isoDate == null || isoDate.isBlank()) return null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -79,12 +79,32 @@ public class PersonTreeImporter {
|
|||||||
.notes(blankToNull(text(node, "notes")))
|
.notes(blankToNull(text(node, "notes")))
|
||||||
.birthYear(intOrNull(node, "birthYear"))
|
.birthYear(intOrNull(node, "birthYear"))
|
||||||
.deathYear(intOrNull(node, "deathYear"))
|
.deathYear(intOrNull(node, "deathYear"))
|
||||||
|
.generation(generationOrNull(node, personId))
|
||||||
.familyMember(node.path("familyMember").asBoolean(false))
|
.familyMember(node.path("familyMember").asBoolean(false))
|
||||||
.personType(PersonType.PERSON)
|
.personType(PersonType.PERSON)
|
||||||
.provisional(false)
|
.provisional(false)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the JSON {@code generation} value if present and in
|
||||||
|
* {@value #GENERATION_MIN}..{@value #GENERATION_MAX}; 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 < GENERATION_MIN || raw > GENERATION_MAX) {
|
||||||
|
log.warn("Skipping out-of-range generation '{}' for person {}", raw, personId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int GENERATION_MIN = 0;
|
||||||
|
private static final int GENERATION_MAX = 10;
|
||||||
|
|
||||||
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
|
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
|
||||||
int created = 0;
|
int created = 0;
|
||||||
for (JsonNode node : relationships) {
|
for (JsonNode node : relationships) {
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ public class PersonService {
|
|||||||
.notes(blankToNull(cmd.notes()))
|
.notes(blankToNull(cmd.notes()))
|
||||||
.birthYear(cmd.birthYear())
|
.birthYear(cmd.birthYear())
|
||||||
.deathYear(cmd.deathYear())
|
.deathYear(cmd.deathYear())
|
||||||
|
.generation(cmd.generation())
|
||||||
.familyMember(cmd.familyMember())
|
.familyMember(cmd.familyMember())
|
||||||
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
|
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
|
||||||
.provisional(cmd.provisional())
|
.provisional(cmd.provisional())
|
||||||
@@ -200,6 +201,7 @@ public class PersonService {
|
|||||||
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
||||||
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
|
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
|
||||||
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
|
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
|
||||||
|
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
|
||||||
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
|
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
|
||||||
existing.setPersonType(cmd.personType());
|
existing.setPersonType(cmd.personType());
|
||||||
}
|
}
|
||||||
@@ -254,6 +256,7 @@ public class PersonService {
|
|||||||
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||||
.birthYear(dto.getBirthYear())
|
.birthYear(dto.getBirthYear())
|
||||||
.deathYear(dto.getDeathYear())
|
.deathYear(dto.getDeathYear())
|
||||||
|
.generation(dto.getGeneration())
|
||||||
.build();
|
.build();
|
||||||
return personRepository.save(person);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
@@ -286,6 +289,9 @@ public class PersonService {
|
|||||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||||
person.setBirthYear(dto.getBirthYear());
|
person.setBirthYear(dto.getBirthYear());
|
||||||
person.setDeathYear(dto.getDeathYear());
|
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);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ public class RelationshipInferenceService {
|
|||||||
if (p == null) continue;
|
if (p == null) continue;
|
||||||
List<RelationToken> path = shortestPaths.get(id);
|
List<RelationToken> path = shortestPaths.get(id);
|
||||||
PersonNodeDTO node = new PersonNodeDTO(
|
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.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
||||||
}
|
}
|
||||||
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
|
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ public class RelationshipService {
|
|||||||
for (Person p : familyMembers) {
|
for (Person p : familyMembers) {
|
||||||
familyIds.add(p.getId());
|
familyIds.add(p.getId());
|
||||||
nodes.add(new PersonNodeDTO(
|
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(
|
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ public record PersonNodeDTO(
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
|
||||||
Integer birthYear,
|
Integer birthYear,
|
||||||
Integer deathYear,
|
Integer deathYear,
|
||||||
|
Integer generation,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ import org.mockito.InOrder;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
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 org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
@@ -29,10 +35,12 @@ class CanonicalImportOrchestratorTest {
|
|||||||
@Mock PersonRegisterImporter personRegisterImporter;
|
@Mock PersonRegisterImporter personRegisterImporter;
|
||||||
@Mock PersonTreeImporter personTreeImporter;
|
@Mock PersonTreeImporter personTreeImporter;
|
||||||
@Mock DocumentImporter documentImporter;
|
@Mock DocumentImporter documentImporter;
|
||||||
|
@Mock RelationshipService relationshipService;
|
||||||
|
|
||||||
private CanonicalImportOrchestrator orchestrator(Path dir) {
|
private CanonicalImportOrchestrator orchestrator(Path dir) {
|
||||||
CanonicalImportOrchestrator o = new CanonicalImportOrchestrator(
|
CanonicalImportOrchestrator o = new CanonicalImportOrchestrator(
|
||||||
tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter);
|
tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter,
|
||||||
|
relationshipService);
|
||||||
ReflectionTestUtils.setField(o, "canonicalDir", dir.toString());
|
ReflectionTestUtils.setField(o, "canonicalDir", dir.toString());
|
||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
@@ -53,6 +61,7 @@ class CanonicalImportOrchestratorTest {
|
|||||||
void runImport_loadsTagsAndPersonsBeforeDocuments(@TempDir Path dir) throws Exception {
|
void runImport_loadsTagsAndPersonsBeforeDocuments(@TempDir Path dir) throws Exception {
|
||||||
writeAllArtifacts(dir);
|
writeAllArtifacts(dir);
|
||||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
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);
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
o.runImport();
|
o.runImport();
|
||||||
@@ -68,6 +77,7 @@ class CanonicalImportOrchestratorTest {
|
|||||||
void runImport_setsStatusDone_onSuccess(@TempDir Path dir) throws Exception {
|
void runImport_setsStatusDone_onSuccess(@TempDir Path dir) throws Exception {
|
||||||
writeAllArtifacts(dir);
|
writeAllArtifacts(dir);
|
||||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(3, List.of()));
|
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);
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
o.runImport();
|
o.runImport();
|
||||||
@@ -118,6 +128,7 @@ class CanonicalImportOrchestratorTest {
|
|||||||
writeAllArtifacts(dir);
|
writeAllArtifacts(dir);
|
||||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(1,
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(1,
|
||||||
List.of(new ImportStatus.SkippedFile("fake.pdf", ImportStatus.SkipReason.INVALID_PDF_SIGNATURE))));
|
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);
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
o.runImport();
|
o.runImport();
|
||||||
@@ -127,4 +138,46 @@ class CanonicalImportOrchestratorTest {
|
|||||||
.extracting(ImportStatus.SkippedFile::filename)
|
.extracting(ImportStatus.SkippedFile::filename)
|
||||||
.containsExactly("fake.pdf");
|
.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.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
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.ArgumentCaptor;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
@@ -87,6 +89,50 @@ class PersonRegisterImporterTest {
|
|||||||
assertThat(processed).isEqualTo(2);
|
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) {
|
private static Person personOf(PersonUpsertCommand cmd) {
|
||||||
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef())
|
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef())
|
||||||
.firstName(cmd.firstName()).lastName(cmd.lastName())
|
.firstName(cmd.firstName()).lastName(cmd.lastName())
|
||||||
@@ -127,4 +173,36 @@ class PersonRegisterImporterTest {
|
|||||||
}
|
}
|
||||||
return xlsx;
|
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());
|
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) {
|
private static Person personOf(PersonUpsertCommand cmd) {
|
||||||
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef()).lastName(cmd.lastName()).build();
|
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef()).lastName(cmd.lastName()).build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,4 +148,55 @@ class PersonImportUpsertTest {
|
|||||||
|
|
||||||
assertThat(result.isProvisional()).isTrue();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,6 +261,54 @@ class PersonServiceTest {
|
|||||||
.isEqualTo(400);
|
.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) ───────────────────────────────────────────
|
// ─── updatePerson (personType) ───────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class RelationshipControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception {
|
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(
|
RelationshipDTO edge = new RelationshipDTO(
|
||||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
"Alice Müller", 1900, 1980,
|
"Alice Müller", 1900, 1980,
|
||||||
@@ -111,7 +111,7 @@ class RelationshipControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception {
|
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 =
|
InferredRelationshipWithPersonDTO inferred =
|
||||||
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
|
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
|
||||||
when(relationshipService.getInferredRelationships(PERSON_ID))
|
when(relationshipService.getInferredRelationships(PERSON_ID))
|
||||||
|
|||||||
@@ -237,6 +237,22 @@ class RelationshipServiceTest {
|
|||||||
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
|
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 ---
|
// --- helpers ---
|
||||||
|
|
||||||
private static Person person(String name) {
|
private static Person person(String name) {
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ package "Persons" {
|
|||||||
notes : TEXT
|
notes : TEXT
|
||||||
birth_year : INTEGER
|
birth_year : INTEGER
|
||||||
death_year : INTEGER
|
death_year : INTEGER
|
||||||
|
generation : SMALLINT
|
||||||
family_member : BOOLEAN NOT NULL
|
family_member : BOOLEAN NOT NULL
|
||||||
source_ref : VARCHAR(255) UNIQUE
|
source_ref : VARCHAR(255) UNIQUE
|
||||||
provisional : BOOLEAN NOT NULL
|
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>
|
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Gen label</td>
|
<td>Gutter label</td>
|
||||||
<td><span class="swatch" style="background:#6b7280"></span>#6b7280 — 8 px, tracking 2 px, aria-hidden</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>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Panel surface</td>
|
<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>
|
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Gen label</td>
|
<td>Gutter label</td>
|
||||||
<td><span class="swatch" style="background:#4e6070;border:1px solid #333"></span>#4e6070 — aria-hidden</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>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Panel surface</td>
|
<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-head">
|
||||||
<div class="stb-page-title">Stammbaum</div>
|
<div class="stb-page-title">Stammbaum</div>
|
||||||
<div class="stb-controls">
|
<div class="stb-controls">
|
||||||
<div class="stb-btn outline" style="font-size:8px">Generationen ▾</div>
|
|
||||||
<div class="stb-zoom">
|
<div class="stb-zoom">
|
||||||
<div class="stb-zoom-btn">−</div>
|
<div class="stb-zoom-btn">−</div>
|
||||||
<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-head">
|
||||||
<div class="stb-page-title">Stammbaum</div>
|
<div class="stb-page-title">Stammbaum</div>
|
||||||
<div class="stb-controls">
|
<div class="stb-controls">
|
||||||
<div class="stb-btn outline-dark" style="font-size:8px">Generationen ▾</div>
|
|
||||||
<div class="stb-zoom">
|
<div class="stb-zoom">
|
||||||
<div class="stb-zoom-btn">−</div>
|
<div class="stb-zoom-btn">−</div>
|
||||||
<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-head">
|
||||||
<div class="stb-page-title">Stammbaum</div>
|
<div class="stb-page-title">Stammbaum</div>
|
||||||
<div class="stb-controls">
|
<div class="stb-controls">
|
||||||
<div class="stb-btn outline" style="font-size:8px">Generationen ▾</div>
|
|
||||||
<div class="stb-zoom">
|
<div class="stb-zoom">
|
||||||
<div class="stb-zoom-btn">−</div>
|
<div class="stb-zoom-btn">−</div>
|
||||||
<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>
|
<td>Filled circle at connector midpoints; same color as connectors</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Gen label</td>
|
<td>Gutter label</td>
|
||||||
<td><code>text-[8px] tracking-[2px] uppercase</code></td>
|
<td><code>font-sans text-[12px] font-bold tracking-[0.08em] uppercase</code></td>
|
||||||
<td>8 px</td>
|
<td>12 px</td>
|
||||||
<td><code>aria-hidden="true"</code>; #6b7280 light · #4e6070 dark</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>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Node name text</td>
|
<td>Node name text</td>
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
|
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
|
||||||
"person_label_birth_year": "Geburtsjahr",
|
"person_label_birth_year": "Geburtsjahr",
|
||||||
"person_label_death_year": "Todesjahr",
|
"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_placeholder_year": "z.B. 1923",
|
||||||
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
|
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
|
||||||
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
||||||
@@ -1103,7 +1106,6 @@
|
|||||||
"stammbaum_relationships_heading": "Stammbaum & Beziehungen",
|
"stammbaum_relationships_heading": "Stammbaum & Beziehungen",
|
||||||
"stammbaum_zoom_in": "Vergrößern",
|
"stammbaum_zoom_in": "Vergrößern",
|
||||||
"stammbaum_zoom_out": "Verkleinern",
|
"stammbaum_zoom_out": "Verkleinern",
|
||||||
"stammbaum_generations": "Generationen",
|
|
||||||
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
|
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
|
||||||
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
|
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||||
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
|
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"person_placeholder_notes": "Biographical notes, remarks…",
|
"person_placeholder_notes": "Biographical notes, remarks…",
|
||||||
"person_label_birth_year": "Birth year",
|
"person_label_birth_year": "Birth year",
|
||||||
"person_label_death_year": "Death 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_placeholder_year": "e.g. 1923",
|
||||||
"person_year_error": "Please enter a four-digit year",
|
"person_year_error": "Please enter a four-digit year",
|
||||||
"person_years_error_order": "Birth year must be before death year",
|
"person_years_error_order": "Birth year must be before death year",
|
||||||
@@ -1103,7 +1106,6 @@
|
|||||||
"stammbaum_relationships_heading": "Family tree & relationships",
|
"stammbaum_relationships_heading": "Family tree & relationships",
|
||||||
"stammbaum_zoom_in": "Zoom in",
|
"stammbaum_zoom_in": "Zoom in",
|
||||||
"stammbaum_zoom_out": "Zoom out",
|
"stammbaum_zoom_out": "Zoom out",
|
||||||
"stammbaum_generations": "Generations",
|
|
||||||
"relation_error_duplicate": "This relationship already exists.",
|
"relation_error_duplicate": "This relationship already exists.",
|
||||||
"relation_error_circular": "This relationship would form a cycle.",
|
"relation_error_circular": "This relationship would form a cycle.",
|
||||||
"relation_error_self": "A person cannot be related to themselves.",
|
"relation_error_self": "A person cannot be related to themselves.",
|
||||||
|
|||||||
@@ -175,6 +175,9 @@
|
|||||||
"person_placeholder_notes": "Notas biográficas, observaciones…",
|
"person_placeholder_notes": "Notas biográficas, observaciones…",
|
||||||
"person_label_birth_year": "Año de nacimiento",
|
"person_label_birth_year": "Año de nacimiento",
|
||||||
"person_label_death_year": "Año de fallecimiento",
|
"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_placeholder_year": "p.ej. 1923",
|
||||||
"person_year_error": "Introduzca un año de cuatro dígitos",
|
"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",
|
"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_relationships_heading": "Árbol genealógico & relaciones",
|
||||||
"stammbaum_zoom_in": "Acercar",
|
"stammbaum_zoom_in": "Acercar",
|
||||||
"stammbaum_zoom_out": "Alejar",
|
"stammbaum_zoom_out": "Alejar",
|
||||||
"stammbaum_generations": "Generaciones",
|
|
||||||
"relation_error_duplicate": "Esta relación ya existe.",
|
"relation_error_duplicate": "Esta relación ya existe.",
|
||||||
"relation_error_circular": "Esta relación crearía un ciclo.",
|
"relation_error_circular": "Esta relación crearía un ciclo.",
|
||||||
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
|
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
|
||||||
|
|||||||
@@ -1666,6 +1666,8 @@ export interface components {
|
|||||||
birthYear?: number;
|
birthYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
generation?: number | null;
|
||||||
};
|
};
|
||||||
Person: {
|
Person: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1681,6 +1683,8 @@ export interface components {
|
|||||||
birthYear?: number;
|
birthYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
generation?: number | null;
|
||||||
familyMember: boolean;
|
familyMember: boolean;
|
||||||
sourceRef?: string;
|
sourceRef?: string;
|
||||||
provisional: boolean;
|
provisional: boolean;
|
||||||
@@ -2285,6 +2289,8 @@ export interface components {
|
|||||||
birthYear?: number;
|
birthYear?: number;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
generation?: number | null;
|
||||||
familyMember: boolean;
|
familyMember: boolean;
|
||||||
};
|
};
|
||||||
InferredRelationshipDTO: {
|
InferredRelationshipDTO: {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { buildLayout, NODE_W, NODE_H, type Layout } from '$lib/person/genealogy/layout/buildLayout';
|
import {
|
||||||
|
buildLayout,
|
||||||
|
NODE_W,
|
||||||
|
NODE_H,
|
||||||
|
ROW_GAP,
|
||||||
|
type Layout
|
||||||
|
} from '$lib/person/genealogy/layout/buildLayout';
|
||||||
|
|
||||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -17,10 +23,52 @@ interface Props {
|
|||||||
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
|
let { nodes, edges, selectedId, zoom, onSelect }: Props = $props();
|
||||||
|
|
||||||
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
|
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)';
|
||||||
|
let isMdOrUp = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||||
|
const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
|
||||||
|
isMdOrUp = mq.matches;
|
||||||
|
const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
});
|
||||||
|
const gutterWidth = $derived(isMdOrUp ? 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 viewBox = $derived.by(() => {
|
||||||
const w = layout.viewW / zoom;
|
const totalW = layout.viewW + gutterWidth;
|
||||||
|
const w = totalW / zoom;
|
||||||
const h = layout.viewH / 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;
|
const cy = layout.viewY + layout.viewH / 2;
|
||||||
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
||||||
});
|
});
|
||||||
@@ -117,6 +165,44 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
|||||||
aria-label="Stammbaum"
|
aria-label="Stammbaum"
|
||||||
class="block h-full w-full"
|
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
|
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
|
||||||
bar, then short verticals from the bar to each child top. -->
|
bar, then short verticals from the bar to each child top. -->
|
||||||
{#each parentLinks.shared as group (group.key)}
|
{#each parentLinks.shared as group (group.key)}
|
||||||
|
|||||||
@@ -648,3 +648,69 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
expect(accentRects.length).toBe(1);
|
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 () => {
|
||||||
|
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: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
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: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 matchMedia (min-width: 768px) is false', async () => {
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(StammbaumTree, {
|
||||||
|
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
||||||
|
edges: [],
|
||||||
|
selectedId: null,
|
||||||
|
zoom: 1,
|
||||||
|
onSelect: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelGroups = Array.from(document.querySelectorAll('g[role="text"]'));
|
||||||
|
expect(labelGroups).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
window.matchMedia = originalMatchMedia;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,49 +38,71 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iterative longest-path generation assignment.
|
// Two-stage rank assignment (#689):
|
||||||
//
|
//
|
||||||
// Each node's generation = max(parent generations) + 1 (roots stay at 0).
|
// 1. Seed: every node with imported generation is locked at that rank.
|
||||||
// Then spouses are pulled to share the deeper generation. Pulling a spouse
|
// The fallback heuristic never moves a locked rank, and spouse-pulldown
|
||||||
// down can shift their own descendants, so we iterate until stable rather
|
// never pulls a locked rank.
|
||||||
// than running BFS once like the previous implementation (which left
|
// 2. Fallback: for the remaining (unseeded) nodes, rank = max(parent rank)
|
||||||
// e.g. a child of a "later-pulled" spouse stranded one row too high).
|
// + 1, reading parent rank from the same unified map so an unseeded
|
||||||
const generation = new Map<string, number>();
|
// child of a seeded G 2 parent correctly inherits rank 3. Spouse-
|
||||||
for (const n of allNodes) generation.set(n.id, 0);
|
// 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;
|
const maxIters = allNodes.length + 4;
|
||||||
for (let it = 0; it < maxIters; it++) {
|
for (let it = 0; it < maxIters; it++) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const n of allNodes) {
|
for (const n of allNodes) {
|
||||||
|
if (locked.has(n.id)) continue;
|
||||||
const parents = childToParents.get(n.id) ?? [];
|
const parents = childToParents.get(n.id) ?? [];
|
||||||
if (parents.length === 0) continue;
|
if (parents.length === 0) continue;
|
||||||
let maxParentGen = -1;
|
let maxParentRank = -Infinity;
|
||||||
for (const pid of parents) {
|
for (const pid of parents) {
|
||||||
maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0);
|
maxParentRank = Math.max(maxParentRank, rank.get(pid) ?? 0);
|
||||||
}
|
}
|
||||||
const newGen = maxParentGen + 1;
|
const newRank = maxParentRank + 1;
|
||||||
if ((generation.get(n.id) ?? 0) < newGen) {
|
if ((rank.get(n.id) ?? 0) < newRank) {
|
||||||
generation.set(n.id, newGen);
|
rank.set(n.id, newRank);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [a, b] of spousePairs) {
|
for (const [a, b] of spousePairs) {
|
||||||
const m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
|
const ra = rank.get(a) ?? 0;
|
||||||
if ((generation.get(a) ?? 0) < m) {
|
const rb = rank.get(b) ?? 0;
|
||||||
generation.set(a, m);
|
const m = Math.max(ra, rb);
|
||||||
|
if (!locked.has(a) && ra < m) {
|
||||||
|
rank.set(a, m);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
if ((generation.get(b) ?? 0) < m) {
|
if (!locked.has(b) && rb < m) {
|
||||||
generation.set(b, m);
|
rank.set(b, m);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!changed) break;
|
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 generation, then sort within generation by display name.
|
// Group by rank, then sort within rank by display name.
|
||||||
const generations = new Map<number, string[]>();
|
const generations = new Map<number, string[]>();
|
||||||
for (const n of allNodes) {
|
for (const n of allNodes) {
|
||||||
const g = generation.get(n.id) ?? 0;
|
const g = rank.get(n.id) ?? 0;
|
||||||
if (!generations.has(g)) generations.set(g, []);
|
if (!generations.has(g)) generations.set(g, []);
|
||||||
generations.get(g)!.push(n.id);
|
generations.get(g)!.push(n.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type PersonFormData = {
|
|||||||
alias?: string | null;
|
alias?: string | null;
|
||||||
birthYear?: number | null;
|
birthYear?: number | null;
|
||||||
deathYear?: number | null;
|
deathYear?: number | null;
|
||||||
|
generation?: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,12 @@
|
|||||||
with axe (tracked in #480) before tweaking the palette. */
|
with axe (tracked in #480) before tweaking the palette. */
|
||||||
--timeline-bar-idle: rgba(161, 220, 216, 0.35);
|
--timeline-bar-idle: rgba(161, 220, 216, 0.35);
|
||||||
--timeline-bar-outside: var(--c-line);
|
--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 ─────────────────────────────────────────────────────────── */
|
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
||||||
@@ -236,6 +242,10 @@
|
|||||||
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
||||||
--timeline-bar-idle: #3a6e8c;
|
--timeline-bar-idle: #3a6e8c;
|
||||||
--timeline-bar-outside: #1a2735;
|
--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. */
|
clears WCAG 1.4.11 non-text contrast for large UI elements. */
|
||||||
--timeline-bar-idle: #3a6e8c;
|
--timeline-bar-idle: #3a6e8c;
|
||||||
--timeline-bar-outside: #1a2735;
|
--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> ──── */
|
/* ─── 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 deathYearStr = formData.get('deathYear')?.toString().trim();
|
||||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||||
const deathYear = deathYearStr ? parseInt(deathYearStr, 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);
|
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||||
if (validationKey) {
|
if (validationKey) {
|
||||||
@@ -68,7 +74,8 @@ export const actions = {
|
|||||||
...(alias ? { alias } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(notes ? { notes } : {}),
|
...(notes ? { notes } : {}),
|
||||||
...(birthYear ? { birthYear } : {}),
|
...(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 isPerson = $derived(selectedType === 'PERSON');
|
||||||
const lastNameLabel = $derived(
|
const lastNameLabel = $derived(
|
||||||
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
|
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
|
||||||
@@ -108,6 +114,28 @@ const inputCls =
|
|||||||
class={inputCls}
|
class={inputCls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
|
|||||||
@@ -113,4 +113,48 @@ describe('PersonEditForm', () => {
|
|||||||
expect(alias.value).toBe('');
|
expect(alias.value).toBe('');
|
||||||
expect(birthYear.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 birthYearStr = formData.get('birthYear')?.toString().trim();
|
||||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
||||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
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);
|
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||||
if (validationKey) {
|
if (validationKey) {
|
||||||
@@ -52,7 +58,8 @@ export const actions = {
|
|||||||
...(alias ? { alias } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(birthYear ? { birthYear } : {}),
|
...(birthYear ? { birthYear } : {}),
|
||||||
...(deathYear ? { deathYear } : {}),
|
...(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