feat(annotations): add polygon JSONB support for quadrilateral shapes

- V23 migration adds polygon JSONB column with 4-point CHECK constraint
- PolygonConverter: AttributeConverter for List<List<Double>> <-> JSONB
- @UniquePoints custom validator rejects duplicate coordinates
- CreateAnnotationDTO: validated optional polygon field
- DocumentAnnotation entity: polygon field with converter

Refs #227

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-12 15:10:35 +02:00
parent ec32d225b5
commit 878a90a86d
8 changed files with 291 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class UniquePointsValidatorTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void shouldAcceptNull() {
var dto = new CreateAnnotationDTO();
dto.setPolygon(null);
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
assertThat(violations).noneMatch(v -> v.getPropertyPath().toString().equals("polygon"));
}
@Test
void shouldAcceptFourUniquePoints() {
var dto = new CreateAnnotationDTO();
dto.setPolygon(List.of(
List.of(0.1, 0.1),
List.of(0.9, 0.1),
List.of(0.9, 0.9),
List.of(0.1, 0.9)));
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
assertThat(violations).noneMatch(v -> v.getPropertyPath().toString().equals("polygon"));
}
@Test
void shouldRejectDuplicatePoints() {
var dto = new CreateAnnotationDTO();
dto.setPolygon(List.of(
List.of(0.1, 0.1),
List.of(0.1, 0.1),
List.of(0.9, 0.9),
List.of(0.1, 0.9)));
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon"));
}
@Test
void shouldRejectPolygonWithThreePoints() {
var dto = new CreateAnnotationDTO();
dto.setPolygon(List.of(
List.of(0.1, 0.1),
List.of(0.9, 0.1),
List.of(0.9, 0.9)));
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon"));
}
@Test
void shouldRejectPolygonWithFivePoints() {
var dto = new CreateAnnotationDTO();
dto.setPolygon(List.of(
List.of(0.1, 0.1),
List.of(0.5, 0.1),
List.of(0.9, 0.1),
List.of(0.9, 0.9),
List.of(0.1, 0.9)));
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon"));
}
@Test
void shouldRejectCoordinateOutOfRange() {
var dto = new CreateAnnotationDTO();
dto.setPolygon(List.of(
List.of(1.5, 0.1),
List.of(0.9, 0.1),
List.of(0.9, 0.9),
List.of(0.1, 0.9)));
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon"));
}
@Test
void shouldRejectNegativeCoordinate() {
var dto = new CreateAnnotationDTO();
dto.setPolygon(List.of(
List.of(-0.1, 0.1),
List.of(0.9, 0.1),
List.of(0.9, 0.9),
List.of(0.1, 0.9)));
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon"));
}
@Test
void shouldRejectPointWithOneCoordinate() {
var dto = new CreateAnnotationDTO();
dto.setPolygon(List.of(
List.of(0.1),
List.of(0.9, 0.1),
List.of(0.9, 0.9),
List.of(0.1, 0.9)));
Set<ConstraintViolation<CreateAnnotationDTO>> violations = validator.validate(dto);
assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon"));
}
}

View File

@@ -0,0 +1,65 @@
package org.raddatz.familienarchiv.model;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class PolygonConverterTest {
private final PolygonConverter converter = new PolygonConverter();
@Test
void convertToDatabaseColumn_returnsNull_whenPolygonIsNull() {
assertThat(converter.convertToDatabaseColumn(null)).isNull();
}
@Test
void convertToDatabaseColumn_returnsJsonArray_whenPolygonIsValid() {
List<List<Double>> polygon = List.of(
List.of(0.1, 0.2),
List.of(0.9, 0.2),
List.of(0.9, 0.8),
List.of(0.1, 0.8));
String json = converter.convertToDatabaseColumn(polygon);
assertThat(json).isEqualTo("[[0.1,0.2],[0.9,0.2],[0.9,0.8],[0.1,0.8]]");
}
@Test
void convertToEntityAttribute_returnsNull_whenJsonIsNull() {
assertThat(converter.convertToEntityAttribute(null)).isNull();
}
@Test
void convertToEntityAttribute_returnsNull_whenJsonIsEmpty() {
assertThat(converter.convertToEntityAttribute("")).isNull();
}
@Test
void convertToEntityAttribute_returnsPolygon_whenJsonIsValid() {
String json = "[[0.1,0.2],[0.9,0.2],[0.9,0.8],[0.1,0.8]]";
List<List<Double>> polygon = converter.convertToEntityAttribute(json);
assertThat(polygon).hasSize(4);
assertThat(polygon.get(0)).containsExactly(0.1, 0.2);
assertThat(polygon.get(3)).containsExactly(0.1, 0.8);
}
@Test
void roundTrip_preservesValues() {
List<List<Double>> original = List.of(
List.of(0.12, 0.08),
List.of(0.88, 0.09),
List.of(0.87, 0.14),
List.of(0.11, 0.13));
String json = converter.convertToDatabaseColumn(original);
List<List<Double>> restored = converter.convertToEntityAttribute(json);
assertThat(restored).isEqualTo(original);
}
}