feat(import): pass generation from JSON in PersonTreeImporter (#689)

Reads the optional `generation` integer from the canonical tree JSON and
routes it into PersonUpsertCommand. Out-of-range values are skip-and-
warned with the same policy as the register importer.

Tree imports run after register (per CanonicalImportOrchestrator); a
tree-confirmed integer overwrites a register-parsed value — both sides
are "canonical" in preferHuman terms (neither is a human edit).

Refs #689

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-28 15:32:27 +02:00
parent df0037cba2
commit a68a822c13
2 changed files with 79 additions and 0 deletions

View File

@@ -79,12 +79,32 @@ 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 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) {
int created = 0;
for (JsonNode node : relationships) {

View File

@@ -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();
}