Compare commits
3 Commits
39276b179d
...
f124529ee8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f124529ee8 | ||
|
|
61ca5a6e40 | ||
|
|
516a0a3814 |
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.importing;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonGeneration;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.person.PersonType;
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
@@ -33,8 +34,6 @@ public class PersonRegisterImporter {
|
|||||||
// carry an inline note. Out-of-range values are caught by the post-parse
|
// carry an inline note. Out-of-range values are caught by the post-parse
|
||||||
// range guard, not by the regex.
|
// range guard, not by the regex.
|
||||||
private static final Pattern GENERATION_PATTERN = Pattern.compile("^\\s*G?\\s*(-?\\d+)");
|
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;
|
||||||
|
|
||||||
@@ -68,16 +67,16 @@ public class PersonRegisterImporter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses an optional {@code G n} generation cell. Returns null for blanks,
|
* 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}
|
* non-matching strings, and any value outside the {@link PersonGeneration}
|
||||||
* (mirroring the V70 CHECK). Out-of-range values log a WARN but never abort
|
* bounds (mirroring the V70 CHECK). Out-of-range values log a WARN but
|
||||||
* the batch — REQ-IMP-001.
|
* never abort the batch — REQ-IMP-001.
|
||||||
*/
|
*/
|
||||||
static Integer parseGeneration(String raw, String personId) {
|
static Integer parseGeneration(String raw, String personId) {
|
||||||
if (raw == null || raw.isBlank()) return null;
|
if (raw == null || raw.isBlank()) return null;
|
||||||
Matcher m = GENERATION_PATTERN.matcher(raw);
|
Matcher m = GENERATION_PATTERN.matcher(raw);
|
||||||
if (!m.find()) return null;
|
if (!m.find()) return null;
|
||||||
int parsed = Integer.parseInt(m.group(1));
|
int parsed = Integer.parseInt(m.group(1));
|
||||||
if (parsed < GENERATION_MIN || parsed > GENERATION_MAX) {
|
if (parsed < PersonGeneration.MIN_GENERATION || parsed > PersonGeneration.MAX_GENERATION) {
|
||||||
log.warn("Skipping out-of-range generation '{}' for row {}", raw, personId);
|
log.warn("Skipping out-of-range generation '{}' for row {}", raw, personId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonGeneration;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.person.PersonType;
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
@@ -87,24 +88,21 @@ public class PersonTreeImporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the JSON {@code generation} value if present and in
|
* Returns the JSON {@code generation} value if present and within the
|
||||||
* {@value #GENERATION_MIN}..{@value #GENERATION_MAX}; null otherwise. Out-of-range
|
* {@link PersonGeneration} bounds; null otherwise. Out-of-range values
|
||||||
* values log a WARN but never abort the batch — mirrors the register-importer
|
* log a WARN but never abort the batch — mirrors the register-importer
|
||||||
* skip-and-warn policy.
|
* skip-and-warn policy.
|
||||||
*/
|
*/
|
||||||
private static Integer generationOrNull(JsonNode node, String personId) {
|
private static Integer generationOrNull(JsonNode node, String personId) {
|
||||||
Integer raw = intOrNull(node, "generation");
|
Integer raw = intOrNull(node, "generation");
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
if (raw < GENERATION_MIN || raw > GENERATION_MAX) {
|
if (raw < PersonGeneration.MIN_GENERATION || raw > PersonGeneration.MAX_GENERATION) {
|
||||||
log.warn("Skipping out-of-range generation '{}' for person {}", raw, personId);
|
log.warn("Skipping out-of-range generation '{}' for person {}", raw, personId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return raw;
|
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) {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the {@code persons.generation} value range.
|
||||||
|
* The DB CHECK in V70, the {@code PersonUpdateDTO} Bean Validation annotations,
|
||||||
|
* and the canonical importers all reference these constants so a future widening
|
||||||
|
* (e.g. accepting {@code G −1} ancestors) happens in one place. Mirror this file
|
||||||
|
* by hand in the V70 migration comment when adjusting bounds.
|
||||||
|
*/
|
||||||
|
public final class PersonGeneration {
|
||||||
|
|
||||||
|
public static final int MIN_GENERATION = 0;
|
||||||
|
public static final int MAX_GENERATION = 10;
|
||||||
|
|
||||||
|
private PersonGeneration() {}
|
||||||
|
}
|
||||||
@@ -23,9 +23,9 @@ public class PersonUpdateDTO {
|
|||||||
private String notes;
|
private String notes;
|
||||||
private Integer birthYear;
|
private Integer birthYear;
|
||||||
private Integer deathYear;
|
private Integer deathYear;
|
||||||
// Mirror of the persons.generation CHECK constraint (V70).
|
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
|
||||||
// null clears the field; 0..10 is the allowlist of valid indices.
|
// PersonGeneration so DB, DTO, and importer all read from one place.
|
||||||
@Min(0)
|
@Min(PersonGeneration.MIN_GENERATION)
|
||||||
@Max(10)
|
@Max(PersonGeneration.MAX_GENERATION)
|
||||||
private Integer generation;
|
private Integer generation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@
|
|||||||
|
|
||||||
ALTER TABLE persons ADD COLUMN generation SMALLINT;
|
ALTER TABLE persons ADD COLUMN generation SMALLINT;
|
||||||
|
|
||||||
-- Allowlist of valid generation indices. The upper bound is intentionally
|
-- Allowlist of valid generation indices. The 0..10 bounds mirror
|
||||||
-- loose — current data tops out at G 5, but a future G 6 → G 10 widening
|
-- PersonGeneration.MIN_GENERATION / MAX_GENERATION in Java — keep the
|
||||||
-- needs no migration. A G −1 ancestor would require a separate one-shot
|
-- two in sync (the DTO @Min/@Max and both importer range guards read from
|
||||||
-- shift migration (out of scope here; the layout's normalise step already
|
-- those Java constants). Current data tops out at G 5, but a future G 6 →
|
||||||
-- handles negative seeds at render time).
|
-- G 10 widening needs no migration. A G −1 ancestor would require a
|
||||||
|
-- separate one-shot shift migration (out of scope here; the layout's
|
||||||
|
-- normalise step already handles negative seeds at render time).
|
||||||
ALTER TABLE persons ADD CONSTRAINT chk_generation_range
|
ALTER TABLE persons ADD CONSTRAINT chk_generation_range
|
||||||
CHECK (generation IS NULL OR generation BETWEEN 0 AND 10);
|
CHECK (generation IS NULL OR generation BETWEEN 0 AND 10);
|
||||||
|
|
||||||
|
|||||||
@@ -746,6 +746,10 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns200_whenGenerationNull() throws Exception {
|
void updatePerson_returns200_whenGenerationNull() throws Exception {
|
||||||
|
// Symmetric body assertion: the response must echo generation as null (not
|
||||||
|
// absent), so the frontend re-hydrates the "(none)" option after a clear.
|
||||||
|
// Without this, the in-range test below would be the only end-to-end proof
|
||||||
|
// that the field flows through the controller.
|
||||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
when(personService.updatePerson(any(), any())).thenReturn(saved);
|
when(personService.updatePerson(any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
@@ -753,7 +757,8 @@ class PersonControllerTest {
|
|||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||||
+ "\"personType\":\"PERSON\",\"generation\":null}"))
|
+ "\"personType\":\"PERSON\",\"generation\":null}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.generation").value(org.hamcrest.Matchers.nullValue()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -124,6 +124,59 @@ class PersonServiceIntegrationTest {
|
|||||||
assertThat(personRepository.findById(target.getId())).isEmpty();
|
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── generation full-stack round-trip (#689) ──────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_clearGenerationToNull_readsBackNullFromDb() {
|
||||||
|
// Sara's QA concern: pin the full PUT→DB→GET round-trip for the
|
||||||
|
// null-clear path. Without this we only have the WebMvcTest mocked
|
||||||
|
// boundary; nothing proved the JPA flush actually wrote SQL NULL.
|
||||||
|
Person seeded = personRepository.save(Person.builder()
|
||||||
|
.firstName("Hans").lastName("Raddatz")
|
||||||
|
.personType(PersonType.PERSON).generation(3).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setPersonType(PersonType.PERSON);
|
||||||
|
dto.setFirstName("Hans");
|
||||||
|
dto.setLastName("Raddatz");
|
||||||
|
dto.setGeneration(null);
|
||||||
|
|
||||||
|
personService.updatePerson(seeded.getId(), dto);
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getGeneration()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_setGenerationToZero_readsBackZeroFromDb() {
|
||||||
|
// Pin the G 0 case end-to-end. The form-action spec covers that 0
|
||||||
|
// doesn't get spread-dropped at the SvelteKit boundary; this test
|
||||||
|
// covers that the controller + service + JPA chain preserves the
|
||||||
|
// primitive zero (not coerced to null somewhere along the way).
|
||||||
|
Person seeded = personRepository.save(Person.builder()
|
||||||
|
.firstName("Walter").lastName("Raddatz")
|
||||||
|
.personType(PersonType.PERSON).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setPersonType(PersonType.PERSON);
|
||||||
|
dto.setFirstName("Walter");
|
||||||
|
dto.setLastName("Raddatz");
|
||||||
|
dto.setGeneration(0);
|
||||||
|
|
||||||
|
personService.updatePerson(seeded.getId(), dto);
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getGeneration()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
||||||
// A person referenced as BOTH a document sender and a document receiver must delete
|
// A person referenced as BOTH a document sender and a document receiver must delete
|
||||||
|
|||||||
@@ -30,11 +30,17 @@ const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
|
|||||||
// too costly on a 320 px screen).
|
// too costly on a 320 px screen).
|
||||||
const GUTTER_WIDTH_DESKTOP = 100;
|
const GUTTER_WIDTH_DESKTOP = 100;
|
||||||
const GUTTER_MEDIA_QUERY = '(min-width: 768px)';
|
const GUTTER_MEDIA_QUERY = '(min-width: 768px)';
|
||||||
let isMdOrUp = $state(false);
|
// Seed synchronously so the first paint already has the right gutter state —
|
||||||
|
// otherwise the test (and a brief flash on real CSR mount) would see the
|
||||||
|
// pre-effect false. SSR has no window; the gutter stays hidden until hydrate.
|
||||||
|
let isMdOrUp = $state(
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia(GUTTER_MEDIA_QUERY).matches
|
||||||
|
: false
|
||||||
|
);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||||
const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
|
const mq = window.matchMedia(GUTTER_MEDIA_QUERY);
|
||||||
isMdOrUp = mq.matches;
|
|
||||||
const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
|
const handler = (e: MediaQueryListEvent) => (isMdOrUp = e.matches);
|
||||||
mq.addEventListener('change', handler);
|
mq.addEventListener('change', handler);
|
||||||
return () => mq.removeEventListener('change', handler);
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
|||||||
Reference in New Issue
Block a user