diff --git a/README.md b/README.md index 2eb1c23..928c2a4 100644 --- a/README.md +++ b/README.md @@ -253,3 +253,58 @@ Only successful `main` runs publish images to the Synology registry. See [the CI management guide](docs/gitea-ci.md) for runner setup, registry secrets, image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md) for the current operational status. + +### Java formatting (S8) + +Maven is the authoritative formatting path. The root build runs the actual +Palantir Java Format **2.101.0**, Palantir style, through a custom OpenRewrite +**8.63.4** text recipe in `tooling/format/Format.java`. The pinned +`exec-maven-plugin` **3.6.3** launches that helper with JDK 25. Native import +ordering and unused-import removal are enabled; extra Javadoc formatting is off. +No OpenRewrite AutoFormat or additional refactoring recipes run. + +From the repository root: + +```sh +mvn -N exec:exec@format # apply, without application compilation +mvn -N exec:exec@format -Dformat.mode=check # check, without application compilation +mvn compile # applies during validate, before compilation +mvn -Dformat.mode=check clean verify # CI: fails before compilation on differences +``` + +Root `compile`, `test`, `verify` and `install` may rewrite maintained Java files +in the validate phase. CI's Java orchestration passes `-Dformat.mode=check` and +never repairs source files. On failure, the output lists paths and the apply +command. Apply locally, inspect and commit the result, then repeat the check. +Run root Maven commands; selecting only the backend with `-pl agiledancers-be` +bypasses the root formatting execution. + +The scope is every `.java` file recursively under `agiledancers-be/src/main/java`, +`agiledancers-be/src/test/java`, `ci` (including the runner helper), and the +formatter helper itself. Generated `target` files and frontend files are excluded. +The adapter keeps paths and UTF-8 content, computes changes before writing, and +uses atomic per-file replacements. Unsupported syntax fails with its path/cause. +The source launcher compiles the adapter directly in memory from a clean checkout; +no custom recipe jar needs compiling or installing beforehand. Maven downloads +only pinned external tooling dependencies on first use. + +Successfully checked/formatted content hashes are cached in +`target/palantir-cache.properties`; new or changed files are always processed. +The root POM, adapter source, dependency classpath and JDK version invalidate the cache. `clean` removes +it. Check and apply share the engine, options, scope and successful-content cache; +a failing check does not write sources or update the cache. For controlled timing +comparisons only, `-Dformat.skip=true` disables the execution. + +Editors are optional. Palantir supplies an IntelliJ plugin (see the +[upstream instructions](https://github.com/palantir/palantir-java-format#intellij-plugin)). +Use it only with the same engine version/style and Javadoc setting; if the editor +cannot match them, use Maven to format instead. Editor import optimization or +format-on-save must not override Maven's result. Other editors can invoke the +standalone Maven apply command as an external tool. + +Verification and measurements are recorded in +[the S8 evidence](specs/S8-formatting-evidence.md). Reproduce the isolated failure, +UTF-8, import, cache and idempotence checks with +`python3 tooling/format/test.py`, and the sequential end-to-end build measurements +with `python3 tooling/format/benchmark.py` (requires the ordinary backend test +prerequisites). Results are written under `target/`. diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/AbstractEntity.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/AbstractEntity.java index 3a08a0e..0e0e3f9 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/AbstractEntity.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/AbstractEntity.java @@ -1,10 +1,9 @@ package nl.agilecc.agiledancers.model; -import lombok.*; - import java.util.Collections; import java.util.HashSet; import java.util.Set; +import lombok.*; @Getter @Setter @@ -19,9 +18,7 @@ public abstract class AbstractEntity { } private Set emptyIfNull(Set set) { - return set == null ? - new HashSet<>() : - set; + return set == null ? new HashSet<>() : set; } protected Set unmodifiableNullSafe(Set set) { diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceGroup.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceGroup.java index ad1f121..ec15a75 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceGroup.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceGroup.java @@ -1,12 +1,11 @@ package nl.agilecc.agiledancers.model; +import java.util.LinkedHashSet; +import java.util.Set; import lombok.*; import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; -import java.util.Set; -import java.util.LinkedHashSet; - @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder @@ -15,21 +14,27 @@ public class DanceGroup extends AbstractEntity { @ToString.Include @Setter private String category; + @ToString.Include @Setter private DanceGroupType type; + @ToString.Include @Setter private int startAge; + @ToString.Include @Setter private int endAge; + @ToString.Include @Builder.Default private final Set dancers = new LinkedHashSet<>(); + @ToString.Include @Builder.Default private final Set styles = new LinkedHashSet<>(); + @ToString.Include private Coach coach; diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java index 2358db3..1408c03 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java @@ -1,9 +1,8 @@ package nl.agilecc.agiledancers.model; -import lombok.*; - -import java.util.Set; import java.util.LinkedHashSet; +import java.util.Set; +import lombok.*; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -12,14 +11,19 @@ import java.util.LinkedHashSet; public class DanceSchool extends AbstractEntity { @Builder.Default private final java.util.UUID key = java.util.UUID.randomUUID(); + @Setter private String name; + @Builder.Default private final Set dancers = new LinkedHashSet<>(); + @Builder.Default private final Set danceGroups = new LinkedHashSet<>(); + @Builder.Default private final Set shows = new LinkedHashSet<>(); + @Builder.Default private final Set coaches = new LinkedHashSet<>(); diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Dancer.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Dancer.java index b3c3ece..a57ccae 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Dancer.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Dancer.java @@ -1,9 +1,8 @@ package nl.agilecc.agiledancers.model; -import lombok.*; - import java.time.LocalDate; import java.time.Period; +import lombok.*; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -13,6 +12,7 @@ public class Dancer extends AbstractEntity { @ToString.Include @Setter private String name; + @ToString.Include @Setter private LocalDate birthDate; diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DayPart.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DayPart.java index ce65985..2b58c58 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DayPart.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DayPart.java @@ -1,11 +1,10 @@ package nl.agilecc.agiledancers.model; +import java.time.LocalDate; +import java.util.List; import lombok.*; import nl.agilecc.agiledancers.model.specifications.DayPartType; -import java.util.List; -import java.time.LocalDate; - @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder @@ -13,7 +12,9 @@ import java.time.LocalDate; public class DayPart extends AbstractEntity { @ToString.Include private final DayPartType type; + private final LocalDate date; + @ToString.Include @Setter private List danceGroups; diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java index 8d50f62..a4a53e2 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java @@ -1,9 +1,8 @@ package nl.agilecc.agiledancers.model; -import lombok.*; - import java.util.List; import java.util.Map; +import lombok.*; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; @Getter @@ -13,8 +12,10 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType; public class Show extends AbstractEntity { @Setter private String name; + @Setter private Map appearances; + @ToString.Include @Setter private List dayParts; diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DanceGroupStyle.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DanceGroupStyle.java index daa408c..925bdcf 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DanceGroupStyle.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DanceGroupStyle.java @@ -1,5 +1,10 @@ package nl.agilecc.agiledancers.model.specifications; public enum DanceGroupStyle { - HIPHOP, JAZZ, MODERN, STREETDANCE, CLASSIC, BREAKDANCE + HIPHOP, + JAZZ, + MODERN, + STREETDANCE, + CLASSIC, + BREAKDANCE } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DanceGroupType.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DanceGroupType.java index 77039c7..ae1f840 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DanceGroupType.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DanceGroupType.java @@ -1,5 +1,9 @@ package nl.agilecc.agiledancers.model.specifications; public enum DanceGroupType { - SHOW_CASE, PRE_EDUCATION, SELECTION, REGULAR, PRE_SCHOOLER + SHOW_CASE, + PRE_EDUCATION, + SELECTION, + REGULAR, + PRE_SCHOOLER } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DayPartType.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DayPartType.java index 7bfdefa..b404eab 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DayPartType.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/specifications/DayPartType.java @@ -1,5 +1,7 @@ package nl.agilecc.agiledancers.model.specifications; public enum DayPartType { - MORNING, AFTERNOON, EVENING; + MORNING, + AFTERNOON, + EVENING; } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java index 5c26353..d200964 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java @@ -1,8 +1,7 @@ package nl.agilecc.agiledancers.repository; -import nl.agilecc.agiledancers.model.AbstractEntity; - import java.util.*; +import nl.agilecc.agiledancers.model.AbstractEntity; public abstract class AbstractRepository { private long sequence = 0L; @@ -28,6 +27,7 @@ public abstract class AbstractRepository { public T findById(long id) { return entities.get(id); } + public void delete(long id) { entities.remove(id); } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/CoachRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/CoachRepository.java index 2257e5c..f7b5141 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/CoachRepository.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/CoachRepository.java @@ -2,6 +2,4 @@ package nl.agilecc.agiledancers.repository; import nl.agilecc.agiledancers.model.Coach; -public class CoachRepository extends AbstractRepository { - -} +public class CoachRepository extends AbstractRepository {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DanceGroupRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DanceGroupRepository.java index 57f4ed2..38a39ad 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DanceGroupRepository.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DanceGroupRepository.java @@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository; import nl.agilecc.agiledancers.model.DanceGroup; -public class DanceGroupRepository extends AbstractRepository { -} +public class DanceGroupRepository extends AbstractRepository {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DanceSchoolRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DanceSchoolRepository.java index de5ce27..779315c 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DanceSchoolRepository.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DanceSchoolRepository.java @@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository; import nl.agilecc.agiledancers.model.DanceSchool; -public class DanceSchoolRepository extends AbstractRepository { -} +public class DanceSchoolRepository extends AbstractRepository {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DancerRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DancerRepository.java index 98cf1f3..ce6e707 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DancerRepository.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DancerRepository.java @@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository; import nl.agilecc.agiledancers.model.Dancer; -public class DancerRepository extends AbstractRepository { -} +public class DancerRepository extends AbstractRepository {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DayPartRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DayPartRepository.java index 5d0a703..914faca 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DayPartRepository.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/DayPartRepository.java @@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository; import nl.agilecc.agiledancers.model.DayPart; -public class DayPartRepository extends AbstractRepository { -} +public class DayPartRepository extends AbstractRepository {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/ShowRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/ShowRepository.java index 4e96e32..25d4bb8 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/ShowRepository.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/ShowRepository.java @@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository; import nl.agilecc.agiledancers.model.Show; -public class ShowRepository extends AbstractRepository { -} +public class ShowRepository extends AbstractRepository {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java index 4b737b6..44373a4 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java @@ -5,12 +5,10 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.*; import jakarta.ws.rs.core.*; -import nl.agilecc.agiledancers.service.SchoolManagementService; - import java.util.List; - import nl.agilecc.agiledancers.rest.apimodel.GroupRequest; import nl.agilecc.agiledancers.rest.apimodel.GroupView; +import nl.agilecc.agiledancers.service.SchoolManagementService; @Path("/api/dance-schools/{schoolId}/dance-groups") @Produces(MediaType.APPLICATION_JSON) @@ -35,15 +33,26 @@ public class DanceGroupResource { @POST public Response create(@NotNull @Valid GroupRequest request, @Context UriInfo uriInfo) { - var entity = service.createGroup(schoolId, request.category(), request.type(), request.startAge(), request.endAge(), request.styles()); - return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build()) - .entity(GroupView.of(entity)).build(); + var entity = service.createGroup( + schoolId, request.category(), request.type(), request.startAge(), request.endAge(), request.styles()); + return Response.created(uriInfo.getAbsolutePathBuilder() + .path(entity.getId().toString()) + .build()) + .entity(GroupView.of(entity)) + .build(); } @PUT @Path("/{id}") public GroupView update(@PathParam("id") long id, @NotNull @Valid GroupRequest request) { - return GroupView.of(service.updateGroup(schoolId, id, request.category(), request.type(), request.startAge(), request.endAge(), request.styles())); + return GroupView.of(service.updateGroup( + schoolId, + id, + request.category(), + request.type(), + request.startAge(), + request.endAge(), + request.styles())); } @DELETE diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java index ce9f9d0..5593f00 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java @@ -5,12 +5,10 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.*; import jakarta.ws.rs.core.*; -import nl.agilecc.agiledancers.service.SchoolManagementService; - import java.util.List; - import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest; import nl.agilecc.agiledancers.rest.apimodel.SchoolView; +import nl.agilecc.agiledancers.service.SchoolManagementService; @Path("/api/dance-schools") @Produces(MediaType.APPLICATION_JSON) @@ -33,8 +31,11 @@ public class DanceSchoolResource { @POST public Response create(@NotNull @Valid SchoolRequest request, @Context UriInfo uriInfo) { var entity = service.createSchool(request.name()); - return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build()) - .entity(SchoolView.of(entity)).build(); + return Response.created(uriInfo.getAbsolutePathBuilder() + .path(entity.getId().toString()) + .build()) + .entity(SchoolView.of(entity)) + .build(); } @PUT diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java index 4872d17..7c03ee3 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java @@ -5,12 +5,10 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.*; import jakarta.ws.rs.core.*; -import nl.agilecc.agiledancers.service.SchoolManagementService; - import java.util.List; - import nl.agilecc.agiledancers.rest.apimodel.DancerRequest; import nl.agilecc.agiledancers.rest.apimodel.DancerView; +import nl.agilecc.agiledancers.service.SchoolManagementService; @Path("/api/dance-schools/{schoolId}/dancers") @Produces(MediaType.APPLICATION_JSON) @@ -36,8 +34,11 @@ public class DancerResource { @POST public Response create(@NotNull @Valid DancerRequest request, @Context UriInfo uriInfo) { var entity = service.createDancer(schoolId, request.name(), request.birthDate()); - return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build()) - .entity(DancerView.of(entity)).build(); + return Response.created(uriInfo.getAbsolutePathBuilder() + .path(entity.getId().toString()) + .build()) + .entity(DancerView.of(entity)) + .build(); } @PUT diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java index 32e76cb..a61ee29 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java @@ -4,8 +4,8 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.ext.ExceptionMapper; import jakarta.ws.rs.ext.Provider; -import nl.agilecc.agiledancers.service.ManagementException; import nl.agilecc.agiledancers.rest.apimodel.ErrorView; +import nl.agilecc.agiledancers.service.ManagementException; @Provider public class ManagementExceptionMapper implements ExceptionMapper { @@ -16,7 +16,9 @@ public class ManagementExceptionMapper implements ExceptionMapper 400; case CONFLICT -> 409; }; - return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE) - .entity(new ErrorView(exception.getMessage())).build(); + return Response.status(status) + .type(MediaType.APPLICATION_JSON_TYPE) + .entity(new ErrorView(exception.getMessage())) + .build(); } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java index 4121884..aa512e0 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/SchoolSnapshotResource.java @@ -1,35 +1,50 @@ package nl.agilecc.agiledancers.rest; -import nl.agilecc.agiledancers.service.model.*; -import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.*; import jakarta.inject.Inject; import jakarta.ws.rs.*; import jakarta.ws.rs.core.*; -import nl.agilecc.agiledancers.service.*; import nl.agilecc.agiledancers.rest.apimodel.SchoolView; +import nl.agilecc.agiledancers.service.*; +import nl.agilecc.agiledancers.service.model.*; @Path("/api/dance-schools") @Produces(MediaType.APPLICATION_JSON) public class SchoolSnapshotResource { - @Inject SchoolManagementService service; - @Inject ObjectMapper mapper; + @Inject + SchoolManagementService service; - @GET @Path("/{id}/export") + @Inject + ObjectMapper mapper; + + @GET + @Path("/{id}/export") public Response export(@PathParam("id") long id) throws Exception { String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(service.exportSchool(id)); - return Response.ok(json).header("Content-Disposition", "attachment; filename=school-" + id + ".json").build(); + return Response.ok(json) + .header("Content-Disposition", "attachment; filename=school-" + id + ".json") + .build(); } - @POST @Path("/import/preview") @Consumes(MediaType.APPLICATION_JSON) - public ImportPreview preview(String json) { return service.preview(parse(json)); } - @POST @Path("/import") @Consumes(MediaType.APPLICATION_JSON) + @POST + @Path("/import/preview") + @Consumes(MediaType.APPLICATION_JSON) + public ImportPreview preview(String json) { + return service.preview(parse(json)); + } + + @POST + @Path("/import") + @Consumes(MediaType.APPLICATION_JSON) public SchoolView apply(String json, @QueryParam("replaceId") Long replaceId) { return SchoolView.of(service.importSchool(parse(json), replaceId)); } + private SchoolSnapshot parse(String json) { try { - SchoolSnapshot snapshot = mapper.copy().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + SchoolSnapshot snapshot = mapper.copy() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS) .disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT) @@ -38,7 +53,8 @@ public class SchoolSnapshotResource { if (snapshot == null) throw new IllegalArgumentException("Het bestand bevat geen school"); return snapshot; } catch (Exception e) { - throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage()); + throw new ManagementException( + ManagementExceptionKind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage()); } } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java index 3865d97..b082a48 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java @@ -5,15 +5,13 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.ws.rs.*; import jakarta.ws.rs.core.*; -import nl.agilecc.agiledancers.service.SchoolManagementService; -import nl.agilecc.agiledancers.service.SchedulingService; +import java.util.List; import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.rest.apimodel.SchedulingView; - -import java.util.List; - import nl.agilecc.agiledancers.rest.apimodel.ShowRequest; import nl.agilecc.agiledancers.rest.apimodel.ShowView; +import nl.agilecc.agiledancers.service.SchedulingService; +import nl.agilecc.agiledancers.service.SchoolManagementService; @Path("/api/dance-schools/{schoolId}/shows") @Produces(MediaType.APPLICATION_JSON) @@ -42,8 +40,11 @@ public class ShowResource { @POST public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) { var entity = service.createShow(schoolId, request.name(), parts(request), request.appearances()); - return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build()) - .entity(ShowView.of(entity)).build(); + return Response.created(uriInfo.getAbsolutePathBuilder() + .path(entity.getId().toString()) + .build()) + .entity(ShowView.of(entity)) + .build(); } @PUT @@ -53,8 +54,13 @@ public class ShowResource { } private static List parts(ShowRequest request) { - return request.dayParts().stream().map(p -> DayPart.builder() - .date(p.date()).type(p.type()).danceGroups(List.of()).build()).toList(); + return request.dayParts().stream() + .map(p -> DayPart.builder() + .date(p.date()) + .type(p.type()) + .danceGroups(List.of()) + .build()) + .toList(); } @POST @@ -62,8 +68,8 @@ public class ShowResource { @Consumes(MediaType.WILDCARD) public SchedulingView schedule(@PathParam("id") long id) { var result = scheduling.schedule(schoolId, id); - return new SchedulingView(result.optimal(), result.optimal() - ? "Optimal balance" : "Valid result; optimal balance not proven"); + return new SchedulingView( + result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven"); } @DELETE diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerRequest.java index 501d2c1..2471bc1 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerRequest.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerRequest.java @@ -5,4 +5,5 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PastOrPresent; import java.time.LocalDate; -public record DancerRequest(@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {} +public record DancerRequest( + @NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java index 0939305..417aa5f 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java @@ -1,7 +1,7 @@ package nl.agilecc.agiledancers.rest.apimodel; -import nl.agilecc.agiledancers.model.Dancer; import java.time.LocalDate; +import nl.agilecc.agiledancers.model.Dancer; public record DancerView(long id, String name, LocalDate birthDate) { public static DancerView of(Dancer dancer) { diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartRequest.java index f44e044..d002a09 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartRequest.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartRequest.java @@ -1,5 +1,8 @@ package nl.agilecc.agiledancers.rest.apimodel; -import java.time.LocalDate; + import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; import nl.agilecc.agiledancers.model.specifications.DayPartType; -public record DayPartRequest(@NotNull LocalDate date, @NotNull DayPartType type) {} + +public record DayPartRequest( + @NotNull LocalDate date, @NotNull DayPartType type) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartView.java index 8c8006d..45379cf 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartView.java @@ -1,5 +1,7 @@ package nl.agilecc.agiledancers.rest.apimodel; -import nl.agilecc.agiledancers.model.specifications.DayPartType; -import java.util.List; + import java.time.LocalDate; +import java.util.List; +import nl.agilecc.agiledancers.model.specifications.DayPartType; + public record DayPartView(LocalDate date, DayPartType type, List danceGroupIds) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupRequest.java index 0a0abbb..794851d 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupRequest.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupRequest.java @@ -3,10 +3,13 @@ package nl.agilecc.agiledancers.rest.apimodel; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import nl.agilecc.agiledancers.model.specifications.DanceGroupType; -import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; import java.util.Set; +import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; -public record GroupRequest(@NotBlank String category, @NotNull DanceGroupType type, - @NotNull @Min(0) Integer startAge, @NotNull @Min(0) Integer endAge, - @NotNull Set<@NotNull DanceGroupStyle> styles) {} +public record GroupRequest( + @NotBlank String category, + @NotNull DanceGroupType type, + @NotNull @Min(0) Integer startAge, + @NotNull @Min(0) Integer endAge, + @NotNull Set<@NotNull DanceGroupStyle> styles) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java index abe79d0..c5e3514 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java @@ -1,17 +1,28 @@ package nl.agilecc.agiledancers.rest.apimodel; -import nl.agilecc.agiledancers.model.DanceGroup; -import nl.agilecc.agiledancers.model.Dancer; -import nl.agilecc.agiledancers.model.specifications.DanceGroupType; -import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; import java.util.List; import java.util.Set; +import nl.agilecc.agiledancers.model.DanceGroup; +import nl.agilecc.agiledancers.model.Dancer; +import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; -public record GroupView(long id, String category, DanceGroupType type, int startAge, int endAge, - Set styles, List dancerIds) { +public record GroupView( + long id, + String category, + DanceGroupType type, + int startAge, + int endAge, + Set styles, + List dancerIds) { public static GroupView of(DanceGroup group) { - return new GroupView(group.getId(), group.getCategory(), group.getType(), group.getStartAge(), - group.getEndAge(), Set.copyOf(group.getStyles()), + return new GroupView( + group.getId(), + group.getCategory(), + group.getType(), + group.getStartAge(), + group.getEndAge(), + Set.copyOf(group.getStyles()), group.getDancers().stream().map(Dancer::getId).toList()); } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingView.java index e17c30b..d747a09 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingView.java @@ -1,2 +1,3 @@ package nl.agilecc.agiledancers.rest.apimodel; + public record SchedulingView(boolean optimal, String message) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java index c677b24..42c1e87 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java @@ -1,15 +1,16 @@ package nl.agilecc.agiledancers.rest.apimodel; +import java.util.List; +import nl.agilecc.agiledancers.model.DanceGroup; import nl.agilecc.agiledancers.model.DanceSchool; import nl.agilecc.agiledancers.model.Dancer; -import nl.agilecc.agiledancers.model.DanceGroup; import nl.agilecc.agiledancers.model.Show; -import java.util.List; -public record SchoolView(long id, String name, List dancerIds, - List danceGroupIds, List showIds) { +public record SchoolView(long id, String name, List dancerIds, List danceGroupIds, List showIds) { public static SchoolView of(DanceSchool school) { - return new SchoolView(school.getId(), school.getName(), + return new SchoolView( + school.getId(), + school.getName(), school.getDancers().stream().map(Dancer::getId).toList(), school.getDanceGroups().stream().map(DanceGroup::getId).toList(), school.getShows().stream().map(Show::getId).toList()); diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java index 012e2b6..1e256fb 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java @@ -1,7 +1,11 @@ package nl.agilecc.agiledancers.rest.apimodel; + import jakarta.validation.Valid; import jakarta.validation.constraints.*; import java.util.*; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; -public record ShowRequest(@NotBlank String name, @NotNull List<@NotNull @Valid DayPartRequest> dayParts, - Map appearances) {} + +public record ShowRequest( + @NotBlank String name, + @NotNull List<@NotNull @Valid DayPartRequest> dayParts, + Map appearances) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java index 1f9c7f4..fcf0e2a 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java @@ -1,10 +1,22 @@ package nl.agilecc.agiledancers.rest.apimodel; + +import java.util.*; import nl.agilecc.agiledancers.model.*; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; -import java.util.*; + public record ShowView(long id, String name, List dayParts, Map appearances) { public static ShowView of(Show show) { - return new ShowView(show.getId(), show.getName(), show.getDayParts().stream() - .map(p -> new DayPartView(p.getDate(), p.getType(), p.getDanceGroups().stream().map(DanceGroup::getId).toList())).toList(), show.getAppearances()); + return new ShowView( + show.getId(), + show.getName(), + show.getDayParts().stream() + .map(p -> new DayPartView( + p.getDate(), + p.getType(), + p.getDanceGroups().stream() + .map(DanceGroup::getId) + .toList())) + .toList(), + show.getAppearances()); } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/DanceGroupService.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/DanceGroupService.java index a201877..1862a13 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/DanceGroupService.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/DanceGroupService.java @@ -1,5 +1,6 @@ package nl.agilecc.agiledancers.service; +import java.util.Set; import nl.agilecc.agiledancers.model.Coach; import nl.agilecc.agiledancers.model.DanceGroup; import nl.agilecc.agiledancers.model.Dancer; @@ -7,12 +8,17 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; import nl.agilecc.agiledancers.repository.DanceGroupRepository; -import java.util.Set; - public class DanceGroupService { private final DanceGroupRepository danceGroupRepository = new DanceGroupRepository(); - public DanceGroup add(String category, DanceGroupType type, int startAge, int endAge, Set dancers, Set styles, Coach coach) { + public DanceGroup add( + String category, + DanceGroupType type, + int startAge, + int endAge, + Set dancers, + Set styles, + Coach coach) { DanceGroup danceGroup = DanceGroup.builder() .category(category) .type(type) @@ -20,7 +26,8 @@ public class DanceGroupService { .endAge(endAge) .dancers(dancers) .styles(styles) - .coach(coach).build(); + .coach(coach) + .build(); return danceGroupRepository.add(danceGroup); } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/DancerService.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/DancerService.java index 7898505..ba66ff1 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/DancerService.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/DancerService.java @@ -1,10 +1,9 @@ package nl.agilecc.agiledancers.service; +import java.time.LocalDate; import nl.agilecc.agiledancers.model.Dancer; import nl.agilecc.agiledancers.repository.DancerRepository; -import java.time.LocalDate; - public class DancerService { private final DancerRepository dancerRepository = new DancerRepository(); diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java index e7ff49d..1e3a6ea 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java @@ -1,3 +1,7 @@ package nl.agilecc.agiledancers.service; -public enum ManagementExceptionKind { NOT_FOUND, INVALID_INPUT, CONFLICT } +public enum ManagementExceptionKind { + NOT_FOUND, + INVALID_INPUT, + CONFLICT +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingService.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingService.java index b2ef7f7..81cb17c 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingService.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingService.java @@ -1,22 +1,32 @@ package nl.agilecc.agiledancers.service; -import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; -import nl.agilecc.agiledancers.service.port.SchedulingEngine; import jakarta.inject.Inject; import jakarta.inject.Singleton; +import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; +import nl.agilecc.agiledancers.service.port.SchedulingEngine; + @Singleton public class SchedulingService { - @Inject SchoolManagementService schools; - @Inject SchedulingEngine engine; + @Inject + SchoolManagementService schools; + + @Inject + SchedulingEngine engine; + public SchedulingResult schedule(long schoolId, long showId) { var input = schools.beginScheduling(schoolId, showId); try { var result = engine.solve(input); schools.finishScheduling(schoolId, input, result); return result; - } catch (ManagementException e) { throw e; } - catch (RuntimeException | LinkageError e) { - throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Solver failed. Existing assignments were preserved. Please run scheduling again."); - } finally { schools.endScheduling(showId); } + } catch (ManagementException e) { + throw e; + } catch (RuntimeException | LinkageError e) { + throw new ManagementException( + ManagementExceptionKind.INVALID_INPUT, + "Solver failed. Existing assignments were preserved. Please run scheduling again."); + } finally { + schools.endScheduling(showId); + } } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java index 5cb37e0..8253896 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java @@ -1,16 +1,17 @@ package nl.agilecc.agiledancers.service; +import static nl.agilecc.agiledancers.service.ManagementExceptionKind.*; + +import jakarta.inject.Singleton; +import java.time.LocalDate; +import java.util.*; +import nl.agilecc.agiledancers.model.*; import nl.agilecc.agiledancers.model.scheduling.GroupRequirement; import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; -import nl.agilecc.agiledancers.service.model.*; -import jakarta.inject.Singleton; -import nl.agilecc.agiledancers.model.*; import nl.agilecc.agiledancers.model.specifications.*; import nl.agilecc.agiledancers.repository.*; -import java.time.LocalDate; -import java.util.*; -import static nl.agilecc.agiledancers.service.ManagementExceptionKind.*; +import nl.agilecc.agiledancers.service.model.*; /** Owns the in-memory school aggregates; all writes go through this shared service. */ @Singleton @@ -22,31 +23,52 @@ public class SchoolManagementService { private final Map revisions = new HashMap<>(); private final Set running = new HashSet<>(); - private void touch(Show show) { revisions.merge(show.getId(), 1L, Long::sum); } + + private void touch(Show show) { + revisions.merge(show.getId(), 1L, Long::sum); + } public synchronized SchedulingInput beginScheduling(long schoolId, long showId) { Show show = show(schoolId, showId); - if (running.contains(showId)) throw new ManagementException(CONFLICT, "Scheduling is already running for this show."); + if (running.contains(showId)) + throw new ManagementException(CONFLICT, "Scheduling is already running for this show."); List current = groups(schoolId); - if (current.isEmpty() || show.getDayParts().isEmpty()) throw new ManagementException(INVALID_INPUT, "Scheduling requires at least one school group and one daypart."); + if (current.isEmpty() || show.getDayParts().isEmpty()) + throw new ManagementException( + INVALID_INPUT, "Scheduling requires at least one school group and one daypart."); ShowConfiguration.validate(show.getDayParts(), show.getAppearances()); running.add(showId); - return new SchedulingInput(showId, revisions.getOrDefault(showId, 0L), show.getDayParts().size(), - current.stream().map(g -> new GroupRequirement(g.getId(), show.getAppearances().get(g.getType()))).toList()); + return new SchedulingInput( + showId, + revisions.getOrDefault(showId, 0L), + show.getDayParts().size(), + current.stream() + .map(g -> new GroupRequirement( + g.getId(), show.getAppearances().get(g.getType()))) + .toList()); } + public synchronized void finishScheduling(long schoolId, SchedulingInput input, SchedulingResult result) { Show show; - try { show = show(schoolId, input.showId()); } - catch (ManagementException e) { throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again."); } + try { + show = show(schoolId, input.showId()); + } catch (ManagementException e) { + throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again."); + } if (revisions.getOrDefault(input.showId(), 0L) != input.revision()) throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again."); // Validate the complete solver output before mutating any assignments. - if (result == null || result.assignments() == null || result.assignments().size() != input.dayParts()) - throw new ManagementException(INVALID_INPUT, "Solver returned an invalid result. Existing assignments were preserved."); + if (result == null + || result.assignments() == null + || result.assignments().size() != input.dayParts()) + throw new ManagementException( + INVALID_INPUT, "Solver returned an invalid result. Existing assignments were preserved."); Map current = new HashMap<>(); groups(schoolId).forEach(g -> current.put(g.getId(), g)); for (var part : result.assignments()) { - if (part == null || new HashSet<>(part).size() != part.size() || !current.keySet().containsAll(part)) + if (part == null + || new HashSet<>(part).size() != part.size() + || !current.keySet().containsAll(part)) throw new ManagementException(INVALID_INPUT, "Solver returned invalid group assignments."); } for (GroupRequirement group : input.groups()) { @@ -57,12 +79,20 @@ public class SchoolManagementService { List replacement = new ArrayList<>(); for (int p = 0; p < input.dayParts(); p++) { DayPart old = show.getDayParts().get(p); - replacement.add(DayPart.builder().date(old.getDate()).type(old.getType()) - .danceGroups(result.assignments().get(p).stream().map(current::get).toList()).build()); + replacement.add(DayPart.builder() + .date(old.getDate()) + .type(old.getType()) + .danceGroups(result.assignments().get(p).stream() + .map(current::get) + .toList()) + .build()); } show.setDayParts(List.copyOf(replacement)); } - public synchronized void endScheduling(long showId) { running.remove(showId); } + + public synchronized void endScheduling(long showId) { + running.remove(showId); + } public synchronized List schools() { return schools.findAll(); @@ -100,7 +130,8 @@ public class SchoolManagementService { public synchronized Dancer createDancer(long schoolId, String name, LocalDate birthDate) { DanceSchool school = school(schoolId); - Dancer dancer = dancers.add(Dancer.builder().name(name).birthDate(birthDate).build()); + Dancer dancer = + dancers.add(Dancer.builder().name(name).birthDate(birthDate).build()); school.addDancer(dancer); return dancer; } @@ -128,19 +159,35 @@ public class SchoolManagementService { return member(school(schoolId).getDanceGroups(), groupId, "Dance group"); } - public synchronized DanceGroup createGroup(long schoolId, String category, DanceGroupType type, - int startAge, int endAge, Set styles) { + public synchronized DanceGroup createGroup( + long schoolId, + String category, + DanceGroupType type, + int startAge, + int endAge, + Set styles) { DanceSchool school = school(schoolId); validateAgeRange(startAge, endAge); - DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type) - .startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build()); + DanceGroup group = groups.add(DanceGroup.builder() + .category(category) + .type(type) + .startAge(startAge) + .endAge(endAge) + .styles(new LinkedHashSet<>(styles)) + .build()); school.addDanceGroup(group); school.getShows().forEach(this::touch); return group; } - public synchronized DanceGroup updateGroup(long schoolId, long groupId, String category, DanceGroupType type, - int startAge, int endAge, Set styles) { + public synchronized DanceGroup updateGroup( + long schoolId, + long groupId, + String category, + DanceGroupType type, + int startAge, + int endAge, + Set styles) { DanceGroup group = group(schoolId, groupId); validateAgeRange(startAge, endAge); group.setCategory(category); @@ -178,23 +225,39 @@ public class SchoolManagementService { return member(school(schoolId).getShows(), showId, "Show"); } - public synchronized Show createShow(long schoolId, String name, List parts, Map counts) { + public synchronized Show createShow( + long schoolId, String name, List parts, Map counts) { DanceSchool school = school(schoolId); if (counts == null) counts = ShowConfiguration.defaults(parts.size()); ShowConfiguration.validate(parts, counts); - Show show = shows.add(Show.builder().name(name).dayParts(List.copyOf(parts)).appearances(Map.copyOf(counts)).build()); + Show show = shows.add(Show.builder() + .name(name) + .dayParts(List.copyOf(parts)) + .appearances(Map.copyOf(counts)) + .build()); school.addShow(show); return show; } - public synchronized Show updateShow(long schoolId, long showId, String name, List parts, Map counts) { + public synchronized Show updateShow( + long schoolId, long showId, String name, List parts, Map counts) { Show show = show(schoolId, showId); - if (counts == null) counts = show.getAppearances().isEmpty() ? ShowConfiguration.defaults(parts.size()) : show.getAppearances(); + if (counts == null) + counts = show.getAppearances().isEmpty() ? ShowConfiguration.defaults(parts.size()) : show.getAppearances(); ShowConfiguration.validate(parts, counts); - boolean changed = !counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts())); - List retained = parts.stream().map(p -> DayPart.builder().date(p.getDate()).type(p.getType()) - .danceGroups(show.getDayParts().stream().filter(old -> old.getDate().equals(p.getDate()) && old.getType() == p.getType()) - .findFirst().map(DayPart::getDanceGroups).orElse(List.of())).build()).toList(); + boolean changed = + !counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts())); + List retained = parts.stream() + .map(p -> DayPart.builder() + .date(p.getDate()) + .type(p.getType()) + .danceGroups(show.getDayParts().stream() + .filter(old -> old.getDate().equals(p.getDate()) && old.getType() == p.getType()) + .findFirst() + .map(DayPart::getDanceGroups) + .orElse(List.of())) + .build()) + .toList(); show.setName(name); show.setDayParts(retained); show.setAppearances(Map.copyOf(counts)); @@ -212,16 +275,23 @@ public class SchoolManagementService { shows.delete(showId); } - public synchronized SchoolSnapshot exportSchool(long id) { return SchoolSnapshot.of(school(id)); } public synchronized ImportPreview preview(SchoolSnapshot snapshot) { snapshot.restore(); - DanceSchool existing = schools.findAll().stream().filter(s -> s.getKey().equals(snapshot.key())).findFirst().orElse(null); - return new ImportPreview(snapshot.name(), snapshot.dancers().size(), snapshot.groups().size(), snapshot.shows().size(), - existing == null ? null : existing.getId(), existing == null ? null : existing.getName()); + DanceSchool existing = schools.findAll().stream() + .filter(s -> s.getKey().equals(snapshot.key())) + .findFirst() + .orElse(null); + return new ImportPreview( + snapshot.name(), + snapshot.dancers().size(), + snapshot.groups().size(), + snapshot.shows().size(), + existing == null ? null : existing.getId(), + existing == null ? null : existing.getName()); } public synchronized DanceSchool importSchool(SchoolSnapshot snapshot, Long replaceId) { @@ -244,21 +314,49 @@ public class SchoolManagementService { List rebuiltGroups = new ArrayList<>(); Map replacements = new IdentityHashMap<>(); for (DanceGroup g : importedGroups) { - DanceGroup rebuilt = DanceGroup.builder().category(g.getCategory()).type(g.getType()) - .startAge(g.getStartAge()).endAge(g.getEndAge()).styles(new LinkedHashSet<>(g.getStyles())) - .coach(g.getCoach()).dancers(new LinkedHashSet<>(memberships.get(g))).build(); - rebuilt.setId(g.getId()); rebuiltGroups.add(rebuilt); replacements.put(g, rebuilt); + DanceGroup rebuilt = DanceGroup.builder() + .category(g.getCategory()) + .type(g.getType()) + .startAge(g.getStartAge()) + .endAge(g.getEndAge()) + .styles(new LinkedHashSet<>(g.getStyles())) + .coach(g.getCoach()) + .dancers(new LinkedHashSet<>(memberships.get(g))) + .build(); + rebuilt.setId(g.getId()); + rebuiltGroups.add(rebuilt); + replacements.put(g, rebuilt); } List rebuiltShows = new ArrayList<>(); for (Show show : importedShows) { - Show rebuilt = Show.builder().name(show.getName()).appearances(show.getAppearances()).dayParts(show.getDayParts().stream() - .map(p -> DayPart.builder().date(p.getDate()).type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build(); - rebuilt.setId(shows.nextId()); rebuiltShows.add(rebuilt); + Show rebuilt = Show.builder() + .name(show.getName()) + .appearances(show.getAppearances()) + .dayParts(show.getDayParts().stream() + .map(p -> DayPart.builder() + .date(p.getDate()) + .type(p.getType()) + .danceGroups(p.getDanceGroups().stream() + .map(replacements::get) + .toList()) + .build()) + .toList()) + .build(); + rebuilt.setId(shows.nextId()); + rebuiltShows.add(rebuilt); + } + DanceSchool imported = DanceSchool.builder() + .key(staged.getKey()) + .name(staged.getName()) + .dancers(new LinkedHashSet<>(importedDancers)) + .danceGroups(new LinkedHashSet<>(rebuiltGroups)) + .shows(new LinkedHashSet<>(rebuiltShows)) + .coaches(new LinkedHashSet<>(staged.getCoaches())) + .build(); + if (replaceId != null) { + deleteSchool(replaceId); + imported.setId(replaceId); } - DanceSchool imported = DanceSchool.builder().key(staged.getKey()).name(staged.getName()) - .dancers(new LinkedHashSet<>(importedDancers)).danceGroups(new LinkedHashSet<>(rebuiltGroups)) - .shows(new LinkedHashSet<>(rebuiltShows)).coaches(new LinkedHashSet<>(staged.getCoaches())).build(); - if (replaceId != null) { deleteSchool(replaceId); imported.setId(replaceId); } importedDancers.forEach(dancers::add); rebuiltGroups.forEach(groups::add); rebuiltShows.forEach(shows::add); @@ -272,7 +370,9 @@ public class SchoolManagementService { } private static T member(Collection entities, long id, String type) { - return entities.stream().filter(entity -> entity.getId() == id).findFirst() + return entities.stream() + .filter(entity -> entity.getId() == id) + .findFirst() .orElseThrow(() -> new ManagementException(NOT_FOUND, type + " " + id + " not found in this school")); } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowConfiguration.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowConfiguration.java index 73b31d2..75db05a 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowConfiguration.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowConfiguration.java @@ -1,33 +1,51 @@ package nl.agilecc.agiledancers.service; +import java.util.*; import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.model.specifications.DanceGroupType; -import java.util.*; public final class ShowConfiguration { private ShowConfiguration() {} + public static Map defaults(int n) { Map counts = new EnumMap<>(DanceGroupType.class); - if (n > 0) for (var type : DanceGroupType.values()) counts.put(type, switch (type) { - case PRE_SCHOOLER -> 1; - case SHOW_CASE, PRE_EDUCATION -> n; - default -> Math.min(2, n); - }); + if (n > 0) + for (var type : DanceGroupType.values()) + counts.put( + type, + switch (type) { + case PRE_SCHOOLER -> 1; + case SHOW_CASE, PRE_EDUCATION -> n; + default -> Math.min(2, n); + }); return counts; } + public static void validate(List parts, Map counts) { - if (parts == null || parts.size() > 6 || parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null) - || parts.stream().map(p -> p.getDate() + ":" + p.getType()).distinct().count() != parts.size()) - throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Configure zero to six dayparts with unique date/type combinations."); + if (parts == null + || parts.size() > 6 + || parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null) + || parts.stream() + .map(p -> p.getDate() + ":" + p.getType()) + .distinct() + .count() + != parts.size()) + throw new ManagementException( + ManagementExceptionKind.INVALID_INPUT, + "Configure zero to six dayparts with unique date/type combinations."); if (counts == null) invalidCounts(); if (parts.isEmpty() && counts.isEmpty()) return; if (counts.size() != DanceGroupType.values().length - || Arrays.stream(DanceGroupType.values()).anyMatch(t -> counts.get(t) == null || counts.get(t) < 1 - || (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts(); + || Arrays.stream(DanceGroupType.values()) + .anyMatch(t -> counts.get(t) == null + || counts.get(t) < 1 + || (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts(); } private static void invalidCounts() { - throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, - "Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are retained when dayparts are removed."); + throw new ManagementException( + ManagementExceptionKind.INVALID_INPUT, + "Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are" + + " retained when dayparts are removed."); } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Group.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Group.java index 92bdb7a..2367e5a 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Group.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Group.java @@ -1,8 +1,15 @@ package nl.agilecc.agiledancers.service.model; import java.util.List; -import nl.agilecc.agiledancers.model.specifications.DanceGroupType; import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; -public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge, - List styles, List dancerIds, String coachId) {} +public record Group( + String id, + String category, + DanceGroupType type, + Integer startAge, + Integer endAge, + List styles, + List dancerIds, + String coachId) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolSnapshot.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolSnapshot.java index f3bd199..0fa488c 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolSnapshot.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolSnapshot.java @@ -1,31 +1,62 @@ package nl.agilecc.agiledancers.service.model; -import nl.agilecc.agiledancers.service.ManagementExceptionKind; -import nl.agilecc.agiledancers.service.ManagementException; -import nl.agilecc.agiledancers.service.ShowConfiguration; -import nl.agilecc.agiledancers.model.*; -import nl.agilecc.agiledancers.model.specifications.*; import java.time.LocalDate; import java.util.*; +import nl.agilecc.agiledancers.model.*; +import nl.agilecc.agiledancers.model.specifications.*; +import nl.agilecc.agiledancers.service.ManagementException; +import nl.agilecc.agiledancers.service.ManagementExceptionKind; +import nl.agilecc.agiledancers.service.ShowConfiguration; /** Version 2 file format. IDs are local to the file, never server identifiers. */ -public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List dancers, - List coaches, List groups, List shows) { +public record SchoolSnapshot( + Integer formatVersion, + UUID key, + String name, + List dancers, + List coaches, + List groups, + List shows) { public static SchoolSnapshot of(DanceSchool s) { Map dancers = ids(s.getDancers()); Map coaches = ids(s.getCoaches()); Map groups = ids(s.getDanceGroups()); Map shows = ids(s.getShows()); - return new SchoolSnapshot(2, s.getKey(), s.getName(), - s.getDancers().stream().map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate())).toList(), - s.getCoaches().stream().map(c -> new Trainer(coaches.get(c), c.getName())).toList(), - s.getDanceGroups().stream().map(g -> new Group(groups.get(g), g.getCategory(), g.getType(), - g.getStartAge(), g.getEndAge(), List.copyOf(g.getStyles()), - g.getDancers().stream().map(dancers::get).toList(), - g.getCoach() == null ? null : coaches.get(g.getCoach()))).toList(), - s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(), - show.getDayParts().stream().map(p -> new Part(p.getDate(), p.getType(), - p.getDanceGroups().stream().map(groups::get).toList())).toList(), show.getAppearances())).toList()); + return new SchoolSnapshot( + 2, + s.getKey(), + s.getName(), + s.getDancers().stream() + .map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate())) + .toList(), + s.getCoaches().stream() + .map(c -> new Trainer(coaches.get(c), c.getName())) + .toList(), + s.getDanceGroups().stream() + .map(g -> new Group( + groups.get(g), + g.getCategory(), + g.getType(), + g.getStartAge(), + g.getEndAge(), + List.copyOf(g.getStyles()), + g.getDancers().stream().map(dancers::get).toList(), + g.getCoach() == null ? null : coaches.get(g.getCoach()))) + .toList(), + s.getShows().stream() + .map(show -> new Performance( + shows.get(show), + show.getName(), + show.getDayParts().stream() + .map(p -> new Part( + p.getDate(), + p.getType(), + p.getDanceGroups().stream() + .map(groups::get) + .toList())) + .toList(), + show.getAppearances())) + .toList()); } private static Map ids(Collection values) { @@ -36,57 +67,129 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List< public DanceSchool restore() { check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion); - check(key != null, "key ontbreekt"); text(name, "schoolnaam"); + check(key != null, "key ontbreekt"); + text(name, "schoolnaam"); DanceSchool school = DanceSchool.builder().key(key).name(name).build(); Map dancerMap = new LinkedHashMap<>(); for (Person d : list(dancers, "dancers")) { - check(d != null, "Lege danser"); text(d.name(), "dansernaam"); - check(d.birthDate() != null && !d.birthDate().isAfter(LocalDate.now()), "Ongeldige geboortedatum voor " + d.name()); - Dancer dancer = Dancer.builder().name(d.name()).birthDate(d.birthDate()).build(); + check(d != null, "Lege danser"); + text(d.name(), "dansernaam"); + check( + d.birthDate() != null && !d.birthDate().isAfter(LocalDate.now()), + "Ongeldige geboortedatum voor " + d.name()); + Dancer dancer = + Dancer.builder().name(d.name()).birthDate(d.birthDate()).build(); // Assign temporary distinct IDs before inserting entities into sets. dancer.setId((long) dancerMap.size()); - put(dancerMap, d.id(), dancer); school.addDancer(dancer); + put(dancerMap, d.id(), dancer); + school.addDancer(dancer); } Map coachMap = new LinkedHashMap<>(); for (Trainer c : list(coaches, "coaches")) { - check(c != null, "Lege coach"); text(c.name(), "coachnaam"); - Coach coach = Coach.builder().name(c.name()).build(); coach.setId((long) coachMap.size()); - put(coachMap, c.id(), coach); school.addCoach(coach); + check(c != null, "Lege coach"); + text(c.name(), "coachnaam"); + Coach coach = Coach.builder().name(c.name()).build(); + coach.setId((long) coachMap.size()); + put(coachMap, c.id(), coach); + school.addCoach(coach); } Map groupMap = new LinkedHashMap<>(); for (Group g : list(groups, "groups")) { - check(g != null, "Lege groep"); text(g.category(), "groepsnaam"); - check(g.type() != null && g.startAge() != null && g.endAge() != null && g.startAge() >= 0 && g.endAge() >= g.startAge(), + check(g != null, "Lege groep"); + text(g.category(), "groepsnaam"); + check( + g.type() != null + && g.startAge() != null + && g.endAge() != null + && g.startAge() >= 0 + && g.endAge() >= g.startAge(), "Ongeldig type of leeftijdsbereik voor groep " + g.category()); - DanceGroup group = DanceGroup.builder().category(g.category()).type(g.type()).startAge(g.startAge()).endAge(g.endAge()) - .styles(new LinkedHashSet<>(list(g.styles(), "styles"))).coach(g.coachId() == null ? null : ref(coachMap, g.coachId(), "groep " + g.category() + " verwijst naar een onbekende coach" )).build(); + DanceGroup group = DanceGroup.builder() + .category(g.category()) + .type(g.type()) + .startAge(g.startAge()) + .endAge(g.endAge()) + .styles(new LinkedHashSet<>(list(g.styles(), "styles"))) + .coach( + g.coachId() == null + ? null + : ref( + coachMap, + g.coachId(), + "groep " + g.category() + " verwijst naar een onbekende coach")) + .build(); check(!group.getStyles().contains(null), "Ongeldige dansstijl"); group.setId((long) groupMap.size()); - for (String id : unique(g.dancerIds(), "dancerIds")) group.addDancer(ref(dancerMap, id, "groep " + g.category() + " verwijst naar een onbekende danser")); - put(groupMap, g.id(), group); school.addDanceGroup(group); + for (String id : unique(g.dancerIds(), "dancerIds")) + group.addDancer(ref(dancerMap, id, "groep " + g.category() + " verwijst naar een onbekende danser")); + put(groupMap, g.id(), group); + school.addDanceGroup(group); } Set showIds = new HashSet<>(); for (Performance show : list(shows, "shows")) { - check(show != null, "Lege show"); text(show.id(), "show-id"); - check(showIds.add(show.id()), "Dubbele show-id " + show.id()); text(show.name(), "shownaam"); - List parts = new ArrayList<>(); Set types = new HashSet<>(); + check(show != null, "Lege show"); + text(show.id(), "show-id"); + check(showIds.add(show.id()), "Dubbele show-id " + show.id()); + text(show.name(), "shownaam"); + List parts = new ArrayList<>(); + Set types = new HashSet<>(); for (Part part : list(show.dayParts(), "dayParts")) { - check(part != null && part.type() != null && part.date() != null && types.add(part.date() + ":" + part.type()), "Ongeldig of dubbel dagdeel voor " + show.name()); - parts.add(DayPart.builder().date(part.date()).type(part.type()).danceGroups(unique(part.groupIds(), "groupIds").stream() - .map(id -> ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep")).toList()).build()); + check( + part != null + && part.type() != null + && part.date() != null + && types.add(part.date() + ":" + part.type()), + "Ongeldig of dubbel dagdeel voor " + show.name()); + parts.add(DayPart.builder() + .date(part.date()) + .type(part.type()) + .danceGroups(unique(part.groupIds(), "groupIds").stream() + .map(id -> + ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep")) + .toList()) + .build()); } ShowConfiguration.validate(parts, show.appearances()); - Show entity = Show.builder().name(show.name()).dayParts(parts).appearances(Map.copyOf(show.appearances())).build(); - entity.setId((long) showIds.size()); school.addShow(entity); + Show entity = Show.builder() + .name(show.name()) + .dayParts(parts) + .appearances(Map.copyOf(show.appearances())) + .build(); + entity.setId((long) showIds.size()); + school.addShow(entity); } return school; } - private static void text(String value, String field) { check(value != null && !value.isBlank(), field + " ontbreekt"); } - private static List list(List value, String field) { check(value != null, field + " ontbreekt"); return value; } - private static List unique(List value, String field) { - list(value, field); check(value.stream().noneMatch(Objects::isNull) && new HashSet<>(value).size() == value.size(), "Ongeldige of dubbele verwijzing in " + field); return value; + + private static void text(String value, String field) { + check(value != null && !value.isBlank(), field + " ontbreekt"); + } + + private static List list(List value, String field) { + check(value != null, field + " ontbreekt"); + return value; + } + + private static List unique(List value, String field) { + list(value, field); + check( + value.stream().noneMatch(Objects::isNull) && new HashSet<>(value).size() == value.size(), + "Ongeldige of dubbele verwijzing in " + field); + return value; + } + + private static void put(Map map, String id, T value) { + text(id, "id"); + check(map.putIfAbsent(id, value) == null, "Dubbele id " + id); + } + + private static T ref(Map map, String id, String error) { + T value = map.get(id); + check(value != null, error); + return value; + } + + private static void check(boolean valid, String message) { + if (!valid) throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, message); } - private static void put(Map map, String id, T value) { text(id, "id"); check(map.putIfAbsent(id, value) == null, "Dubbele id " + id); } - private static T ref(Map map, String id, String error) { T value = map.get(id); check(value != null, error); return value; } - private static void check(boolean valid, String message) { if (!valid) throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, message); } } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java index 58fbd75..869e211 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java @@ -1,13 +1,14 @@ package nl.agilecc.agiledancers.solver; -import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; -import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; -import nl.agilecc.agiledancers.service.port.SchedulingEngine; import com.google.ortools.Loader; import com.google.ortools.sat.*; import jakarta.inject.Singleton; -import nl.agilecc.agiledancers.service.*; import java.util.*; +import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; +import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; +import nl.agilecc.agiledancers.service.*; +import nl.agilecc.agiledancers.service.port.SchedulingEngine; + @Singleton public class OrToolsSchedulingEngine implements SchedulingEngine { @Override @@ -30,7 +31,7 @@ public class OrToolsSchedulingEngine implements SchedulingEngine { IntVar max = model.newIntVar(0, groups, "max"), min = model.newIntVar(0, groups, "min"); model.addMaxEquality(max, loads); model.addMinEquality(min, loads); - model.minimize(LinearExpr.weightedSum(new IntVar[]{max, min}, new long[]{1, -1})); + model.minimize(LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1})); CpSolver solver = new CpSolver(); solver.getParameters().setMaxTimeInSeconds(1800); // Quarkus owns process shutdown; do not install a native SIGINT handler. @@ -39,15 +40,21 @@ public class OrToolsSchedulingEngine implements SchedulingEngine { solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0); CpSolverStatus status = solver.solve(model); if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) - throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, switch (status) { - case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved."; - case UNKNOWN -> "The 30-minute limit was reached without a valid result. Existing assignments were preserved."; - default -> "Solver failed (" + status + "). Existing assignments were preserved."; - }); + throw new ManagementException( + ManagementExceptionKind.INVALID_INPUT, + switch (status) { + case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved."; + case UNKNOWN -> + "The 30-minute limit was reached without a valid result. Existing assignments were" + + " preserved."; + default -> "Solver failed (" + status + "). Existing assignments were preserved."; + }); List> parts = new ArrayList<>(); for (int p = 0; p < n; p++) { List ids = new ArrayList<>(); - for (int g = 0; g < groups; g++) if (solver.booleanValue(assigned[g][p])) ids.add(input.groups().get(g).groupId()); + for (int g = 0; g < groups; g++) + if (solver.booleanValue(assigned[g][p])) + ids.add(input.groups().get(g).groupId()); parts.add(List.copyOf(ids)); } return new SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts)); diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/ArchitectureTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/ArchitectureTest.java index c375152..5924f87 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/ArchitectureTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/ArchitectureTest.java @@ -1,15 +1,16 @@ package nl.agilecc.agiledancers; -import com.tngtech.archunit.core.importer.ImportOption; -import com.tngtech.archunit.core.domain.JavaModifier; -import com.tngtech.archunit.junit.AnalyzeClasses; -import com.tngtech.archunit.junit.ArchTest; -import com.tngtech.archunit.lang.ArchRule; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; import static com.tngtech.archunit.library.Architectures.layeredArchitecture; import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + @AnalyzeClasses(packages = "nl.agilecc.agiledancers", importOptions = ImportOption.DoNotIncludeTests.class) class ArchitectureTest { private static final String ROOT = "nl.agilecc.agiledancers"; @@ -18,85 +19,128 @@ class ArchitectureTest { // Exclude Lombok builders and compiler-generated enum-switch helper classes. @ArchTest static final ArchRule handwritten_types_are_top_level = classes() - .that().areNotAnnotatedWith("lombok.Generated") - .and().doNotHaveModifier(JavaModifier.SYNTHETIC) - .should().beTopLevelClasses(); + .that() + .areNotAnnotatedWith("lombok.Generated") + .and() + .doNotHaveModifier(JavaModifier.SYNTHETIC) + .should() + .beTopLevelClasses(); @ArchTest static final ArchRule service_records_reside_in_service_model = classes() - .that().resideInAPackage(ROOT + ".service..") - .and().areRecords() - .should().resideInAPackage(ROOT + ".service.model"); + .that() + .resideInAPackage(ROOT + ".service..") + .and() + .areRecords() + .should() + .resideInAPackage(ROOT + ".service.model"); @ArchTest static final ArchRule service_interfaces_reside_in_service_port = classes() - .that().resideInAPackage(ROOT + ".service..") - .and().areInterfaces() - .should().resideInAPackage(ROOT + ".service.port"); + .that() + .resideInAPackage(ROOT + ".service..") + .and() + .areInterfaces() + .should() + .resideInAPackage(ROOT + ".service.port"); @ArchTest static final ArchRule scheduling_port_uses_domain_types = classes() - .that().resideInAPackage(ROOT + ".service.port..") - .should().onlyDependOnClassesThat() + .that() + .resideInAPackage(ROOT + ".service.port..") + .should() + .onlyDependOnClassesThat() .resideInAnyPackage(ROOT + ".service.port..", ROOT + ".model..", "java.."); // API model classes and records use the Request, Response, View, Dto or DTO suffix. @ArchTest static final ArchRule api_models_reside_in_the_api_model_package = classes() - .that().haveNameMatching(".*(Request|Response|View|Dto|DTO)$") - .should().resideInAPackage(API_MODEL); + .that() + .haveNameMatching(".*(Request|Response|View|Dto|DTO)$") + .should() + .resideInAPackage(API_MODEL); @ArchTest static final ArchRule rest_records_reside_in_the_api_model_package = classes() - .that().resideInAPackage(ROOT + ".rest..") - .and().areRecords() - .should().resideInAPackage(API_MODEL); + .that() + .resideInAPackage(ROOT + ".rest..") + .and() + .areRecords() + .should() + .resideInAPackage(API_MODEL); @ArchTest static final ArchRule api_models_are_public_top_level_types = classes() - .that().resideInAPackage(API_MODEL + "..") - .should().bePublic() - .andShould().beTopLevelClasses() - .andShould().haveNameMatching(".*(Request|Response|View|Dto|DTO)$"); + .that() + .resideInAPackage(API_MODEL + "..") + .should() + .bePublic() + .andShould() + .beTopLevelClasses() + .andShould() + .haveNameMatching(".*(Request|Response|View|Dto|DTO)$"); // The existing services depend on concrete repositories. This rule preserves that // arrangement until repository ports and dependency injection are introduced. @ArchTest static final ArchRule layer_dependencies = layeredArchitecture() .consideringOnlyDependenciesInLayers() - .layer("Model").definedBy(ROOT + ".model..") - .layer("Services").definedBy(ROOT + ".service..") - .layer("Repositories").definedBy(ROOT + ".repository..") - .optionalLayer("REST").definedBy(ROOT + ".rest..") - .optionalLayer("Solver").definedBy(ROOT + ".solver..") - .whereLayer("Model").mayNotAccessAnyLayer() - .whereLayer("Services").mayOnlyAccessLayers("Model", "Repositories") - .whereLayer("Repositories").mayOnlyAccessLayers("Model") - .whereLayer("REST").mayOnlyAccessLayers("Services", "Model") - .whereLayer("Solver").mayOnlyAccessLayers("Services", "Model"); + .layer("Model") + .definedBy(ROOT + ".model..") + .layer("Services") + .definedBy(ROOT + ".service..") + .layer("Repositories") + .definedBy(ROOT + ".repository..") + .optionalLayer("REST") + .definedBy(ROOT + ".rest..") + .optionalLayer("Solver") + .definedBy(ROOT + ".solver..") + .whereLayer("Model") + .mayNotAccessAnyLayer() + .whereLayer("Services") + .mayOnlyAccessLayers("Model", "Repositories") + .whereLayer("Repositories") + .mayOnlyAccessLayers("Model") + .whereLayer("REST") + .mayOnlyAccessLayers("Services", "Model") + .whereLayer("Solver") + .mayOnlyAccessLayers("Services", "Model"); @ArchTest static final ArchRule production_classes_have_an_architectural_home = classes() - .should().resideInAnyPackage(ROOT + ".model..", ROOT + ".service..", - ROOT + ".repository..", ROOT + ".rest..", ROOT + ".solver.."); + .should() + .resideInAnyPackage( + ROOT + ".model..", + ROOT + ".service..", + ROOT + ".repository..", + ROOT + ".rest..", + ROOT + ".solver.."); @ArchTest static final ArchRule model_is_independent_of_frameworks = classes() - .that().resideInAPackage(ROOT + ".model..") - .should().onlyDependOnClassesThat() + .that() + .resideInAPackage(ROOT + ".model..") + .should() + .onlyDependOnClassesThat() .resideInAnyPackage(ROOT + ".model..", "java..", "lombok.."); @ArchTest static final ArchRule ortools_is_encapsulated_in_solver = noClasses() - .that().resideOutsideOfPackage(ROOT + ".solver..") - .should().dependOnClassesThat().resideInAPackage("com.google.ortools.."); + .that() + .resideOutsideOfPackage(ROOT + ".solver..") + .should() + .dependOnClassesThat() + .resideInAPackage("com.google.ortools.."); @ArchTest static final ArchRule http_api_is_encapsulated_in_rest = noClasses() - .that().resideOutsideOfPackage(ROOT + ".rest..") - .should().dependOnClassesThat().resideInAnyPackage("jakarta.ws.rs..", "org.jboss.resteasy.."); + .that() + .resideOutsideOfPackage(ROOT + ".rest..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("jakarta.ws.rs..", "org.jboss.resteasy.."); @ArchTest - static final ArchRule packages_are_free_of_cycles = slices() - .matching(ROOT + ".(*)..").should().beFreeOfCycles(); + static final ArchRule packages_are_free_of_cycles = + slices().matching(ROOT + ".(*)..").should().beFreeOfCycles(); } diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/HealthTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/HealthTest.java index 04130b4..e6c3472 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/HealthTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/HealthTest.java @@ -1,18 +1,15 @@ package nl.agilecc.agiledancers; -import io.quarkus.test.junit.QuarkusTest; -import org.junit.jupiter.api.Test; - import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + @QuarkusTest class HealthTest { @Test void applicationIsReady() { - given() - .when().get("/q/health/ready") - .then().statusCode(200) - .body("status", equalTo("UP")); + given().when().get("/q/health/ready").then().statusCode(200).body("status", equalTo("UP")); } } diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java index d7836d1..8fc2d31 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java @@ -1,54 +1,80 @@ package nl.agilecc.agiledancers; -import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; -import nl.agilecc.agiledancers.service.model.*; -import nl.agilecc.agiledancers.service.port.SchedulingEngine; -import nl.agilecc.agiledancers.model.*; -import nl.agilecc.agiledancers.model.specifications.*; -import nl.agilecc.agiledancers.service.*; -import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + import java.time.LocalDate; import java.util.*; -import static org.junit.jupiter.api.Assertions.*; +import nl.agilecc.agiledancers.model.*; +import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; +import nl.agilecc.agiledancers.model.specifications.*; +import nl.agilecc.agiledancers.service.*; +import nl.agilecc.agiledancers.service.model.*; +import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; +import org.junit.jupiter.api.Test; class SchedulingTest { private final SchoolManagementService service = new SchoolManagementService(); private final long school = service.createSchool("Studio").getId(); + private List parts(int n) { - return java.util.stream.IntStream.range(0, n).mapToObj(i -> DayPart.builder() - .date(LocalDate.of(2027, 1, 1).plusDays(i)).type(DayPartType.AFTERNOON).danceGroups(List.of()).build()).toList(); + return java.util.stream.IntStream.range(0, n) + .mapToObj(i -> DayPart.builder() + .date(LocalDate.of(2027, 1, 1).plusDays(i)) + .type(DayPartType.AFTERNOON) + .danceGroups(List.of()) + .build()) + .toList(); } + private DanceGroup group(DanceGroupType type) { return service.createGroup(school, "Group", type, 0, 99, Set.of()); } + private void run(Show show) { var input = service.beginScheduling(school, show.getId()); - try { service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input)); } - finally { service.endScheduling(show.getId()); } + try { + service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input)); + } finally { + service.endScheduling(show.getId()); + } } - @Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() { + + @Test + void exactAppearancesAndOptimalBalanceIncludingEmptyParts() { group(DanceGroupType.PRE_SCHOOLER); var show = service.createShow(school, "Show", parts(6), null); run(show); - var loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics(); - assertEquals(1, loads.getSum()); assertEquals(0, loads.getMin()); assertEquals(1, loads.getMax()); + var loads = show.getDayParts().stream() + .mapToInt(p -> p.getDanceGroups().size()) + .summaryStatistics(); + assertEquals(1, loads.getSum()); + assertEquals(0, loads.getMin()); + assertEquals(1, loads.getMax()); for (var type : DanceGroupType.values()) group(type); run(show); - for (var group : service.groups(school)) assertEquals(show.getAppearances().get(group.getType()).longValue(), - show.getDayParts().stream().filter(p -> p.getDanceGroups().contains(group)).count()); - loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics(); + for (var group : service.groups(school)) + assertEquals( + show.getAppearances().get(group.getType()).longValue(), + show.getDayParts().stream() + .filter(p -> p.getDanceGroups().contains(group)) + .count()); + loads = show.getDayParts().stream() + .mapToInt(p -> p.getDanceGroups().size()) + .summaryStatistics(); assertTrue(loads.getMax() - loads.getMin() <= 1); var snapshot = service.exportSchool(school); assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore())); } - @Test void defaultsInitialConfigurationAndPreservationOnEdits() { + + @Test + void defaultsInitialConfigurationAndPreservationOnEdits() { var show = service.createShow(school, "Show", List.of(), null); assertTrue(show.getAppearances().isEmpty()); service.updateShow(school, show.getId(), "Show", parts(3), null); assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION)); assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER)); - var counts = new EnumMap<>(show.getAppearances()); counts.put(DanceGroupType.PRE_SCHOOLER, 2); + var counts = new EnumMap<>(show.getAppearances()); + counts.put(DanceGroupType.PRE_SCHOOLER, 2); service.updateShow(school, show.getId(), "Show", parts(4), counts); assertEquals(counts, show.getAppearances()); assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null)); @@ -58,19 +84,25 @@ class SchedulingTest { service.updateShow(school, show.getId(), "Show", parts(4), null); assertEquals(counts, show.getAppearances()); } - @Test void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() { + + @Test + void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() { var duplicate = List.of(parts(1).getFirst(), parts(1).getFirst()); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", duplicate, null)); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null)); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of())); - var counts = ShowConfiguration.defaults(2); counts.put(DanceGroupType.REGULAR, 3); + var counts = ShowConfiguration.defaults(2); + counts.put(DanceGroupType.REGULAR, 3); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts)); counts.put(DanceGroupType.REGULAR, 0); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts)); } - @Test void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() { + + @Test + void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() { var group = group(DanceGroupType.REGULAR); - var show = service.createShow(school, "Show", parts(2), null); run(show); + var show = service.createShow(school, "Show", parts(2), null); + run(show); var before = show.getDayParts().stream().map(DayPart::getDanceGroups).toList(); var input = service.beginScheduling(school, show.getId()); assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId())); @@ -79,18 +111,25 @@ class SchedulingTest { assertDoesNotThrow(() -> service.finishScheduling(school, input, result)); group(DanceGroupType.PRE_SCHOOLER); assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result)); - assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList()); + assertEquals( + before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList()); service.endScheduling(show.getId()); var fresh = service.beginScheduling(school, show.getId()); - assertThrows(ManagementException.class, () -> service.finishScheduling(school, fresh, - new SchedulingResult(false, List.of(List.of(), List.of())))); - assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList()); + assertThrows( + ManagementException.class, + () -> service.finishScheduling( + school, fresh, new SchedulingResult(false, List.of(List.of(), List.of())))); + assertEquals( + before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList()); // Feasible results are accepted without requiring proof of optimality. var valid = new OrToolsSchedulingEngine().solve(fresh); - assertDoesNotThrow(() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments()))); + assertDoesNotThrow( + () -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments()))); service.endScheduling(show.getId()); } - @Test void staleDaypartCountChangesAndDeletedShowsAreRejected() { + + @Test + void staleDaypartCountChangesAndDeletedShowsAreRejected() { group(DanceGroupType.REGULAR); var show = service.createShow(school, "Show", parts(2), null); var input = service.beginScheduling(school, show.getId()); @@ -101,7 +140,9 @@ class SchedulingTest { assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result)); service.endScheduling(show.getId()); } - @Test void needsGroupsAndDayparts() { + + @Test + void needsGroupsAndDayparts() { var show = service.createShow(school, "Show", parts(1), null); assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId())); group(DanceGroupType.REGULAR); diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java index 33f0218..efc8043 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java @@ -1,19 +1,21 @@ package nl.agilecc.agiledancers; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + import io.quarkus.test.junit.QuarkusTest; import io.restassured.response.ValidatableResponse; -import org.junit.jupiter.api.Test; import java.util.Map; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; +import org.junit.jupiter.api.Test; @QuarkusTest class SchoolManagementResourceTest { private static final String SCHOOLS = "/api/dance-schools"; private static final Map DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12"); - private static final Map GROUP = Map.of("category", "Teens", "type", "REGULAR", - "startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"}); - private static final Map SHOW = Map.of("name", "Annual show", "dayParts", new Object[]{Map.of("date", "2027-06-12", "type", "AFTERNOON")}); + private static final Map GROUP = Map.of( + "category", "Teens", "type", "REGULAR", "startAge", 12, "endAge", 18, "styles", new String[] {"HIPHOP"}); + private static final Map SHOW = + Map.of("name", "Annual show", "dayParts", new Object[] {Map.of("date", "2027-06-12", "type", "AFTERNOON")}); @Test void managesSchoolAndItsResources() { @@ -27,37 +29,64 @@ class SchoolManagementResourceTest { long dancerId = create(school + "/dancers", DANCER); String dancer = school + "/dancers/" + dancerId; put(dancer, Map.of("name", "Sam updated", "birthDate", "2011-06-13")) - .statusCode(200).body("birthDate", equalTo("2011-06-13")); + .statusCode(200) + .body("birthDate", equalTo("2011-06-13")); given().get(dancer).then().statusCode(200).body("name", equalTo("Sam updated")); given().get(school + "/dancers").then().statusCode(200).body("id", contains((int) dancerId)); long groupId = create(school + "/dance-groups", GROUP); String group = school + "/dance-groups/" + groupId; given().put(group + "/dancers/" + dancerId).then().statusCode(204); - put(group, Map.of("category", "Selection", "type", "SELECTION", "startAge", 10, - "endAge", 20, "styles", new String[]{"JAZZ", "MODERN"})) - .statusCode(200).body("category", equalTo("Selection")) - .body("dancerIds", contains((int) dancerId)).body("styles", containsInAnyOrder("JAZZ", "MODERN")); + put( + group, + Map.of( + "category", + "Selection", + "type", + "SELECTION", + "startAge", + 10, + "endAge", + 20, + "styles", + new String[] {"JAZZ", "MODERN"})) + .statusCode(200) + .body("category", equalTo("Selection")) + .body("dancerIds", contains((int) dancerId)) + .body("styles", containsInAnyOrder("JAZZ", "MODERN")); given().get(group).then().statusCode(200).body("type", equalTo("SELECTION")); given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId)); long showId = create(school + "/shows", SHOW); String show = school + "/shows/" + showId; - given().get(show).then().statusCode(200) + given().get(show) + .then() + .statusCode(200) .body("dayParts.type", contains("AFTERNOON")) .body("dayParts.danceGroupIds", everyItem(empty())); - put(show, Map.of("name", "Summer show", "dayParts", new Object[]{Map.of("date", "2027-07-01", "type", "EVENING")})) - .statusCode(200).body("dayParts.date", contains("2027-07-01")); + put(show, Map.of("name", "Summer show", "dayParts", new Object[] { + Map.of("date", "2027-07-01", "type", "EVENING") + })) + .statusCode(200) + .body("dayParts.date", contains("2027-07-01")); given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId)); - given().get(school).then().statusCode(200).body("dancerIds", contains((int) dancerId)) - .body("danceGroupIds", contains((int) groupId)).body("showIds", contains((int) showId)); + given().get(school) + .then() + .statusCode(200) + .body("dancerIds", contains((int) dancerId)) + .body("danceGroupIds", contains((int) groupId)) + .body("showIds", contains((int) showId)); - for (String path : new String[]{dancer, group, show}) { + for (String path : new String[] {dancer, group, show}) { given().delete(path).then().statusCode(204); given().get(path).then().statusCode(404); given().delete(path).then().statusCode(404); } - given().get(school).then().body("dancerIds", empty()).body("danceGroupIds", empty()).body("showIds", empty()); + given().get(school) + .then() + .body("dancerIds", empty()) + .body("danceGroupIds", empty()) + .body("showIds", empty()); } finally { given().delete(school).then().statusCode(204); } @@ -71,7 +100,7 @@ class SchoolManagementResourceTest { long dancerId = create(school + "/dancers", DANCER); String first = school + "/dance-groups/" + create(school + "/dance-groups", GROUP); String second = school + "/dance-groups/" + create(school + "/dance-groups", GROUP); - for (String group : new String[]{first, second}) { + for (String group : new String[] {first, second}) { given().put(group + "/dancers/" + dancerId).then().statusCode(204); given().put(group + "/dancers/" + dancerId).then().statusCode(204); given().get(group).then().body("dancerIds", contains((int) dancerId)); @@ -98,14 +127,16 @@ class SchoolManagementResourceTest { long group = create(first + "/dance-groups", GROUP); long show = create(first + "/shows", SHOW); long otherGroup = create(second + "/dance-groups", GROUP); - for (String path : new String[]{"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) { + for (String path : new String[] {"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) { given().get(second + path).then().statusCode(404); given().delete(second + path).then().statusCode(404); } put(second + "/dancers/" + dancer, DANCER).statusCode(404); put(second + "/dance-groups/" + group, GROUP).statusCode(404); put(second + "/shows/" + show, SHOW).statusCode(404); - given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer).then().statusCode(404); + given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer) + .then() + .statusCode(404); given().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty()); given().delete(first).then().statusCode(204); given().get(first + "/dancers").then().statusCode(404); @@ -121,30 +152,66 @@ class SchoolManagementResourceTest { @Test void rejectsInvalidInputWithoutChangingStoredData() { given().contentType("application/json").body("{}").post(SCHOOLS).then().statusCode(400); - given().contentType("application/json").body("null").post(SCHOOLS).then().statusCode(400); + given().contentType("application/json") + .body("null") + .post(SCHOOLS) + .then() + .statusCode(400); String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Validation school")); try { put(school, Map.of("name", " ")).statusCode(400); given().get(school).then().body("name", equalTo("Validation school")); - for (Object body : new Object[]{Map.of("name", "Sam"), Map.of("name", "Sam", "birthDate", "2999-01-01"), - Map.of("name", "Sam", "birthDate", "not-a-date")}) { - given().contentType("application/json").body(body).post(school + "/dancers").then().statusCode(400); + for (Object body : new Object[] { + Map.of("name", "Sam"), + Map.of("name", "Sam", "birthDate", "2999-01-01"), + Map.of("name", "Sam", "birthDate", "not-a-date") + }) { + given().contentType("application/json") + .body(body) + .post(school + "/dancers") + .then() + .statusCode(400); } - for (String body : new String[]{"{}", "{\"category\":\"Teens\",\"type\":\"INVALID\"}", - "{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}", - "{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"}) { - given().contentType("application/json").body(body).post(school + "/dance-groups").then().statusCode(400); + for (String body : new String[] { + "{}", + "{\"category\":\"Teens\",\"type\":\"INVALID\"}", + "{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}", + "{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}" + }) { + given().contentType("application/json") + .body(body) + .post(school + "/dance-groups") + .then() + .statusCode(400); } long group = create(school + "/dance-groups", GROUP); - put(school + "/dance-groups/" + group, Map.of("category", "Invalid", "type", "REGULAR", - "startAge", 20, "endAge", 10, "styles", new String[]{})).statusCode(400); + put( + school + "/dance-groups/" + group, + Map.of( + "category", + "Invalid", + "type", + "REGULAR", + "startAge", + 20, + "endAge", + 10, + "styles", + new String[] {})) + .statusCode(400); given().get(school + "/dance-groups/" + group).then().body("category", equalTo("Teens")); - given().contentType("application/json").body(Map.of("name", "Show")) - .post(school + "/shows").then().statusCode(400); + given().contentType("application/json") + .body(Map.of("name", "Show")) + .post(school + "/shows") + .then() + .statusCode(400); given().get(school + "/dancers").then().body("$", empty()); given().get(school + "/shows").then().body("$", empty()); - given().contentType("application/json").body(DANCER) - .post(SCHOOLS + "/9223372036854775807/dancers").then().statusCode(404); + given().contentType("application/json") + .body(DANCER) + .post(SCHOOLS + "/9223372036854775807/dancers") + .then() + .statusCode(404); } finally { given().delete(school).then().statusCode(204); } @@ -161,22 +228,44 @@ class SchoolManagementResourceTest { given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group)); String before = given().get(show).asString(); String counts = "{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}"; - for (String count : new String[]{"0", "2", "1.5", "null"}) { - String body = "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":" + counts.formatted(count) + "}"; + for (String count : new String[] {"0", "2", "1.5", "null"}) { + String body = + "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":" + + counts.formatted(count) + "}"; put(show, body).statusCode(400); given().get(show).then().body(equalTo(before)); } - String exported = given().get(school + "/export").then().statusCode(200).extract().asString(); - given().contentType("application/json").body(exported).post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1)) - .then().statusCode(200); - given().get(school + "/export").then().body("$", equalTo(io.restassured.path.json.JsonPath.from(exported).getMap("$"))); - } finally { given().delete(school); } + String exported = given().get(school + "/export") + .then() + .statusCode(200) + .extract() + .asString(); + given().contentType("application/json") + .body(exported) + .post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1)) + .then() + .statusCode(200); + given().get(school + "/export") + .then() + .body( + "$", + equalTo(io.restassured.path.json.JsonPath.from(exported) + .getMap("$"))); + } finally { + given().delete(school); + } } private static long create(String path, Object body) { - return given().contentType("application/json").body(body).post(path).then() - .statusCode(201).header("Location", matchesPattern(".*/[0-9]+$")) - .extract().jsonPath().getLong("id"); + return given().contentType("application/json") + .body(body) + .post(path) + .then() + .statusCode(201) + .header("Location", matchesPattern(".*/[0-9]+$")) + .extract() + .jsonPath() + .getLong("id"); } private static ValidatableResponse put(String path, Object body) { diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java index bfa2f9b..f7d83c0 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java @@ -1,32 +1,68 @@ package nl.agilecc.agiledancers; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + import io.quarkus.test.junit.QuarkusTest; import org.junit.jupiter.api.Test; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; @QuarkusTest class SchoolSnapshotResourceTest { private static final String BASE = "/api/dance-schools"; - @Test void downloadPreviewRestoreAndRejectInvalidFiles() { - long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE) - .then().statusCode(201).extract().jsonPath().getLong("id"); - String json = given().get(BASE + "/" + id + "/export").then().statusCode(200) - .header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(2)).extract().asString(); + + @Test + void downloadPreviewRestoreAndRejectInvalidFiles() { + long id = given().contentType("application/json") + .body("{\"name\":\"JSON school\"}") + .post(BASE) + .then() + .statusCode(201) + .extract() + .jsonPath() + .getLong("id"); + String json = given().get(BASE + "/" + id + "/export") + .then() + .statusCode(200) + .header("Content-Disposition", containsString(".json")) + .body("formatVersion", equalTo(2)) + .extract() + .asString(); try { - given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200) - .body("existingId", equalTo((int) id)).body("dancers", equalTo(0)); - for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"), - json.replace("\"name\" :", "\"unknown\" :"), json + " {}", - json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) { - given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id) - .then().statusCode(400).body("message", not(emptyOrNullString())); + given().contentType("application/json") + .body(json) + .post(BASE + "/import/preview") + .then() + .statusCode(200) + .body("existingId", equalTo((int) id)) + .body("dancers", equalTo(0)); + for (String bad : new String[] { + "{", + "null", + json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"), + json.replace("\"name\" :", "\"unknown\" :"), + json + " {}", + json.replace("\"dancers\" : [ ]", "\"dancers\" : null") + }) { + given().contentType("application/json") + .body(bad) + .post(BASE + "/import?replaceId=" + id) + .then() + .statusCode(400) + .body("message", not(emptyOrNullString())); } given().get(BASE + "/" + id + "/export").then().body(equalTo(json)); given().delete(BASE + "/" + id).then().statusCode(204); - id = given().contentType("application/json").body(json).post(BASE + "/import") - .then().statusCode(200).extract().jsonPath().getLong("id"); + id = given().contentType("application/json") + .body(json) + .post(BASE + "/import") + .then() + .statusCode(200) + .extract() + .jsonPath() + .getLong("id"); given().get(BASE + "/" + id + "/export").then().body(equalTo(json)); - } finally { given().delete(BASE + "/" + id); } + } finally { + given().delete(BASE + "/" + id); + } } } diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotTest.java index 56aba70..947395c 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotTest.java @@ -1,31 +1,51 @@ package nl.agilecc.agiledancers; -import nl.agilecc.agiledancers.service.model.*; -import nl.agilecc.agiledancers.service.*; -import nl.agilecc.agiledancers.model.specifications.*; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + import java.time.LocalDate; import java.util.*; -import static org.junit.jupiter.api.Assertions.*; +import nl.agilecc.agiledancers.model.specifications.*; +import nl.agilecc.agiledancers.service.*; +import nl.agilecc.agiledancers.service.model.*; +import org.junit.jupiter.api.Test; class SchoolSnapshotTest { private SchoolSnapshot complete() { - return new SchoolSnapshot(2, UUID.randomUUID(), "Studio", List.of(new Person("a", "Sam", LocalDate.of(2010,1,1))), + return new SchoolSnapshot( + 2, + UUID.randomUUID(), + "Studio", + List.of(new Person("a", "Sam", LocalDate.of(2010, 1, 1))), List.of(new Trainer("c", "Alex")), - List.of(new Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"), + List.of( + new Group( + "g", + "Hiphop", + DanceGroupType.REGULAR, + 0, + 99, + List.of(DanceGroupStyle.HIPHOP), + List.of("a"), + "c"), new Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")), - List.of(new Performance("s", "Show", - List.of(new Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1)))); + List.of(new Performance( + "s", + "Show", + List.of(new Part(LocalDate.of(2027, 1, 1), DayPartType.MORNING, List.of("g", "h"))), + ShowConfiguration.defaults(1)))); } - @Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() { + + @Test + void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() { var snapshot = complete(); var service = new SchoolManagementService(); var existing = service.createSchool("Other"); - service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000,1,1)); + service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000, 1, 1)); var imported = service.importSchool(snapshot, null); assertEquals(2, imported.getDanceGroups().size()); var dancer = imported.getDancers().iterator().next(); - imported.getDanceGroups().forEach(g -> assertSame(dancer, g.getDancers().iterator().next())); + imported.getDanceGroups() + .forEach(g -> assertSame(dancer, g.getDancers().iterator().next())); assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(imported.getId())); var restarted = new SchoolManagementService(); var restored = restarted.importSchool(service.exportSchool(imported.getId()), null); @@ -36,14 +56,26 @@ class SchoolSnapshotTest { assertEquals(1, existing.getDancers().size()); var group = imported.getDanceGroups().iterator().next(); service.deleteGroup(imported.getId(), group.getId()); - assertEquals(1, imported.getShows().iterator().next().getDayParts().getFirst().getDanceGroups().size()); + assertEquals( + 1, + imported.getShows() + .iterator() + .next() + .getDayParts() + .getFirst() + .getDanceGroups() + .size()); assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore()); } - @Test void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() { - var service = new SchoolManagementService(); var snapshot = complete(); - assertNull(service.preview(snapshot).existingId()); assertTrue(service.schools().isEmpty()); + + @Test + void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() { + var service = new SchoolManagementService(); + var snapshot = complete(); + assertNull(service.preview(snapshot).existingId()); + assertTrue(service.schools().isEmpty()); var original = service.importSchool(snapshot, null); - service.createDancer(original.getId(), "Extra", LocalDate.of(2000,1,1)); + service.createDancer(original.getId(), "Extra", LocalDate.of(2000, 1, 1)); var other = service.createSchool(snapshot.name()); assertEquals(original.getId(), service.preview(snapshot).existingId()); assertThrows(ManagementException.class, () -> service.importSchool(snapshot, null)); @@ -52,20 +84,31 @@ class SchoolSnapshotTest { assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(replaced.getId())); assertSame(other, service.school(other.getId())); service.deleteSchool(replaced.getId()); - assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId())); + assertEquals( + SchoolSnapshot.of(snapshot.restore()), + service.exportSchool(service.importSchool(snapshot, null).getId())); } - @Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() { - var service = new SchoolManagementService(); var valid = complete(); + + @Test + void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() { + var service = new SchoolManagementService(); + var valid = complete(); var original = service.importSchool(valid, null); - var bad = new SchoolSnapshot(2, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows()); + var bad = new SchoolSnapshot( + 2, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows()); assertThrows(ManagementException.class, () -> service.preview(bad)); assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId())); assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId())); } - @Test void emptySchoolRoundTrips() { + + @Test + void emptySchoolRoundTrips() { var service = new SchoolManagementService(); - var empty = service.createSchool("Empty"); var snapshot = service.exportSchool(empty.getId()); + var empty = service.createSchool("Empty"); + var snapshot = service.exportSchool(empty.getId()); service.deleteSchool(empty.getId()); - assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId())); + assertEquals( + SchoolSnapshot.of(snapshot.restore()), + service.exportSchool(service.importSchool(snapshot, null).getId())); } } diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingServiceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingServiceTest.java index 4caa7fc..0d91c0b 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingServiceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingServiceTest.java @@ -1,30 +1,44 @@ package nl.agilecc.agiledancers.service; -import nl.agilecc.agiledancers.service.port.SchedulingEngine; -import nl.agilecc.agiledancers.model.*; -import nl.agilecc.agiledancers.model.specifications.*; -import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + import java.time.LocalDate; import java.util.*; import java.util.concurrent.*; -import static org.junit.jupiter.api.Assertions.*; +import nl.agilecc.agiledancers.model.*; +import nl.agilecc.agiledancers.model.specifications.*; +import nl.agilecc.agiledancers.service.port.SchedulingEngine; +import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; +import org.junit.jupiter.api.Test; class SchedulingServiceTest { - @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() { + @Test + void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() { var schools = new SchoolManagementService(); long school = schools.createSchool("School").getId(); schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of()); - var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1)) - .type(DayPartType.MORNING).danceGroups(List.of()).build()), null); + var show = schools.createShow( + school, + "Show", + List.of(DayPart.builder() + .date(LocalDate.of(2027, 1, 1)) + .type(DayPartType.MORNING) + .danceGroups(List.of()) + .build()), + null); var scheduling = new SchedulingService(); scheduling.schools = schools; scheduling.engine = new OrToolsSchedulingEngine(); scheduling.schedule(school, show.getId()); var before = schools.exportSchool(school); for (SchedulingEngine failure : List.of( - input -> { throw new IllegalStateException("Native failure"); }, - input -> { throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit"); }, + input -> { + throw new IllegalStateException("Native failure"); + }, + input -> { + throw new ManagementException( + ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit"); + }, input -> null)) { scheduling.engine = failure; assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId())); @@ -34,28 +48,46 @@ class SchedulingServiceTest { } } - @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception { + @Test + void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception { var schools = new SchoolManagementService(); long school = schools.createSchool("School").getId(); schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of()); - var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1)) - .type(DayPartType.MORNING).danceGroups(List.of()).build()), null); - var scheduling = new SchedulingService(); scheduling.schools = schools; - var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var show = schools.createShow( + school, + "Show", + List.of(DayPart.builder() + .date(LocalDate.of(2027, 1, 1)) + .type(DayPartType.MORNING) + .danceGroups(List.of()) + .build()), + null); + var scheduling = new SchedulingService(); + scheduling.schools = schools; + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); scheduling.engine = input -> { entered.countDown(); - try { if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout"); } - catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } + try { + if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } return new OrToolsSchedulingEngine().solve(input); }; try (var executor = Executors.newSingleThreadExecutor()) { var first = executor.submit(() -> scheduling.schedule(school, show.getId())); try { assertTrue(entered.await(10, TimeUnit.SECONDS)); - assertEquals(ManagementExceptionKind.CONFLICT, assertThrows(ManagementException.class, - () -> scheduling.schedule(school, show.getId())).getKind()); + assertEquals( + ManagementExceptionKind.CONFLICT, + assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId())) + .getKind()); schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of()); - } finally { release.countDown(); } + } finally { + release.countDown(); + } var exception = assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS)); assertInstanceOf(ManagementException.class, exception.getCause()); assertTrue(exception.getCause().getMessage().contains("inputs changed")); diff --git a/agiledancers-be/src/test/java/sit/DanceSchoolSit.java b/agiledancers-be/src/test/java/sit/DanceSchoolSit.java index 6d09bfa..20751ac 100644 --- a/agiledancers-be/src/test/java/sit/DanceSchoolSit.java +++ b/agiledancers-be/src/test/java/sit/DanceSchoolSit.java @@ -14,13 +14,11 @@ class DanceSchoolSit { int aantalDansers11tot14 = 70; int aantalDansers14tm17 = 140; int aantalDansers18Plus = 100; - + private DanceSchoolService danceSchoolService = new DanceSchoolService(); @Test void testDanceSchoolModel() { DanceSchool danceSchool = danceSchoolService.add("24 Dance Studio"); - - } } diff --git a/ci/Ci.java b/ci/Ci.java index 0eff197..c08c052 100644 --- a/ci/Ci.java +++ b/ci/Ci.java @@ -24,16 +24,18 @@ public class Ci { case "attempt" -> { Path file = Path.of(Setup.required(env, "GITHUB_ENV")); String id = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSSSSSSSS") - .withZone(ZoneOffset.UTC).format(Instant.now()); - Files.writeString(file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND); + .withZone(ZoneOffset.UTC) + .format(Instant.now()); + Files.writeString( + file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND); } case "configure-gitea" -> Setup.configureGitea(env); case "configure-docker" -> { if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path"); Setup.configureDocker(Path.of(args[1])); } - case "install-runner" -> Setup.installRunner(root, env, commands, - args.length == 2 && args[1].equals("--prepare-only")); + case "install-runner" -> + Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only")); case "test" -> CiTests.run(); default -> throw new IllegalArgumentException(usage()); } @@ -42,13 +44,16 @@ public class Ci { System.exit(e.exitCode); } catch (Exception e) { // Never dump HTTP bodies, process arguments, environment or tokens. - System.err.println(e instanceof IllegalArgumentException || e instanceof Setup.ApiFailure ? e.getMessage() - : "CI failed (" + e.getClass().getSimpleName() + "). Check diagnostics and configuration."); + System.err.println( + e instanceof IllegalArgumentException || e instanceof Setup.ApiFailure + ? e.getMessage() + : "CI failed (" + e.getClass().getSimpleName() + "). Check diagnostics and configuration."); System.exit(1); } } static String usage() { - return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker |install-runner [--prepare-only]"; + return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker" + + " |install-runner [--prepare-only]"; } } diff --git a/ci/CiTests.java b/ci/CiTests.java index f1be880..e1e4456 100644 --- a/ci/CiTests.java +++ b/ci/CiTests.java @@ -6,26 +6,41 @@ import java.util.*; class CiTests { static final String SHA = "a".repeat(40); static int count; - interface Test { void run() throws Exception; } + + interface Test { + void run() throws Exception; + } + static void test(String name, Test test) throws Exception { - test.run(); count++; System.out.println("PASS " + name); + test.run(); + count++; + System.out.println("PASS " + name); } + static void equal(Object expected, Object actual) { - if (!Objects.equals(expected,actual)) throw new AssertionError("Expected " + expected + ", got " + actual); + if (!Objects.equals(expected, actual)) throw new AssertionError("Expected " + expected + ", got " + actual); } + static void failure(Test action) throws Exception { - try { action.run(); } catch (Exception expected) { return; } + try { + action.run(); + } catch (Exception expected) { + return; + } throw new AssertionError("Expected failure"); } + static void run() throws Exception { count = 0; test("JSON preserves configuration values and escaped text", () -> { String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}"; - Object data = Json.parse(json); equal(data,Json.parse(Json.write(data))); - equal("line\nquote\"€",Json.object(data).get("text")); + Object data = Json.parse(json); + equal(data, Json.parse(Json.write(data))); + equal("line\nquote\"€", Json.object(data).get("text")); }); test("JSON rejects malformed/ambiguous input", () -> { - for (String value : List.of("{\"a\":null,\"a\":1}","{} []","[1,]","01","\"\\uZZZZ\"","\"line\n\"","truefalse","+1")) + for (String value : List.of( + "{\"a\":null,\"a\":1}", "{} []", "[1,]", "01", "\"\\uZZZZ\"", "\"line\n\"", "truefalse", "+1")) failure(() -> Json.parse(value)); }); test("Docker configuration merge is idempotent and preserves backup", () -> { @@ -33,149 +48,288 @@ class CiTests { try { Path file = dir.resolve("daemon.json"); String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}"; - Files.writeString(file,original); - Setup.configureDocker(file); String first = Files.readString(file); Setup.configureDocker(file); - equal(first,Files.readString(file)); - equal(List.of("existing:5000","192.168.2.28:3000"),Json.object(Json.parse(first)).get("insecure-registries")); - equal(original,Files.readString(dir.resolve("daemon.json.before-agiledancers"))); - } finally { Pipeline.deleteTree(dir); } + Files.writeString(file, original); + Setup.configureDocker(file); + String first = Files.readString(file); + Setup.configureDocker(file); + equal(first, Files.readString(file)); + equal( + List.of("existing:5000", "192.168.2.28:3000"), + Json.object(Json.parse(first)).get("insecure-registries")); + equal(original, Files.readString(dir.resolve("daemon.json.before-agiledancers"))); + } finally { + Pipeline.deleteTree(dir); + } }); test("Invalid Docker configuration is unchanged", () -> { Path dir = Files.createTempDirectory("ci-docker-test-"); try { - Path file = dir.resolve("daemon.json"); String original = "{\"insecure-registries\":\"invalid\"}"; - Files.writeString(file,original); failure(() -> Setup.configureDocker(file)); equal(original,Files.readString(file)); - } finally { Pipeline.deleteTree(dir); } + Path file = dir.resolve("daemon.json"); + String original = "{\"insecure-registries\":\"invalid\"}"; + Files.writeString(file, original); + failure(() -> Setup.configureDocker(file)); + equal(original, Files.readString(file)); + } finally { + Pipeline.deleteTree(dir); + } }); test("Feature branches never contact registry", () -> { try (Fixture f = new Fixture()) { - f.pipeline.env.put("CI_REF","refs/heads/feature/test"); f.pipeline.publish(); equal(0L,f.calls("login")); equal(0L,f.calls("push")); + f.pipeline.env.put("CI_REF", "refs/heads/feature/test"); + f.pipeline.publish(); + equal(0L, f.calls("login")); + equal(0L, f.calls("push")); } }); test("Unverified commit cannot publish", () -> { try (Fixture f = new Fixture()) { - Files.delete(f.root.resolve("ci/artifacts/verified-commit")); failure(f.pipeline::publish); equal(0L,f.calls("push")); + Files.delete(f.root.resolve("ci/artifacts/verified-commit")); + failure(f.pipeline::publish); + equal(0L, f.calls("push")); } }); test("Publication pullback and rerun preserve commit tags", () -> { try (Fixture f = new Fixture()) { - f.pipeline.publish(); equal(4L,f.calls("push")); f.pipeline.publish(); equal(6L,f.calls("push")); equal(8L,f.calls("pull")); + f.pipeline.publish(); + equal(4L, f.calls("push")); + f.pipeline.publish(); + equal(6L, f.calls("push")); + equal(8L, f.calls("pull")); } }); test("Partial publication is completed without overwriting backend", () -> { try (Fixture f = new Fixture()) { - f.fake.pushFailure = "frontend:" + SHA; failure(f.pipeline::publish); - equal(0L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith(":main")).count()); - f.fake.pushFailure = ""; f.pipeline.publish(); - equal(1L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith("backend:" + SHA)).count()); + f.fake.pushFailure = "frontend:" + SHA; + failure(f.pipeline::publish); + equal( + 0L, + f.fake.calls.stream() + .filter(a -> a.contains("push") && a.getLast().endsWith(":main")) + .count()); + f.fake.pushFailure = ""; + f.pipeline.publish(); + equal( + 1L, + f.fake.calls.stream() + .filter(a -> a.contains("push") && a.getLast().endsWith("backend:" + SHA)) + .count()); } }); test("Authentication error is not a missing tag", () -> { - try (Fixture f = new Fixture()) { f.fake.manifestError = true; failure(f.pipeline::publish); equal(0L,f.calls("push")); } + try (Fixture f = new Fixture()) { + f.fake.manifestError = true; + failure(f.pipeline::publish); + equal(0L, f.calls("push")); + } }); test("Pull failure blocks moving tags", () -> { try (Fixture f = new Fixture()) { - f.fake.pullFailure = "backend:" + SHA; failure(f.pipeline::publish); - equal(0L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith(":main")).count()); + f.fake.pullFailure = "backend:" + SHA; + failure(f.pipeline::publish); + equal( + 0L, + f.fake.calls.stream() + .filter(a -> a.contains("push") && a.getLast().endsWith(":main")) + .count()); } }); test("Older commit cannot move main", () -> { - try (Fixture f = new Fixture()) { f.fake.head = "c".repeat(40); f.pipeline.publish(); equal(2L,f.calls("push")); } + try (Fixture f = new Fixture()) { + f.fake.head = "c".repeat(40); + f.pipeline.publish(); + equal(2L, f.calls("push")); + } }); test("Unavailable Docker fails before build with a concrete cause", () -> { try (Fixture f = new Fixture()) { f.fake.dockerAvailable = false; - try { f.pipeline.verify(); throw new AssertionError("Expected failure"); } - catch (IllegalArgumentException error) { equal(true,error.getMessage().contains("Docker is unavailable")); } - equal(0L,f.fake.calls.stream().filter(a -> a.getFirst().equals("mvn")).count()); + try { + f.pipeline.verify(); + throw new AssertionError("Expected failure"); + } catch (IllegalArgumentException error) { + equal(true, error.getMessage().contains("Docker is unavailable")); + } + equal( + 0L, + f.fake.calls.stream() + .filter(a -> a.getFirst().equals("mvn")) + .count()); } }); test("Architecture mismatch blocks publication", () -> { - try (Fixture f = new Fixture()) { f.pipeline.env.put("TARGET_PLATFORM","linux/arm64"); failure(f.pipeline::publish); equal(0L,f.calls("push")); } + try (Fixture f = new Fixture()) { + f.pipeline.env.put("TARGET_PLATFORM", "linux/arm64"); + failure(f.pipeline::publish); + equal(0L, f.calls("push")); + } }); test("Failed verification removes success marker and preserves exit status", () -> { try (Fixture f = new Fixture()) { f.fake.mavenFailure = true; - try { f.pipeline.verify(); throw new AssertionError("Expected failure"); } - catch (Commands.Failure error) { equal(42,error.exitCode); } - equal(false,Files.exists(f.root.resolve("ci/artifacts/verified-commit"))); - equal(0L,f.calls("build")); equal(0L,f.calls("push")); + try { + f.pipeline.verify(); + throw new AssertionError("Expected failure"); + } catch (Commands.Failure error) { + equal(42, error.exitCode); + } + equal( + List.of("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"), + f.fake.calls.stream() + .filter(a -> a.getFirst().equals("mvn")) + .findFirst() + .orElseThrow()); + equal(false, Files.exists(f.root.resolve("ci/artifacts/verified-commit"))); + equal(0L, f.calls("build")); + equal(0L, f.calls("push")); } }); test("Frontend lint runs after install and blocks tests and build on failure", () -> { try (Fixture f = new Fixture()) { f.fake.lintFailure = true; - try { f.pipeline.verifyFrontend(); throw new AssertionError("Expected failure"); } - catch (Commands.Failure error) { equal(1,error.exitCode); } + try { + f.pipeline.verifyFrontend(); + throw new AssertionError("Expected failure"); + } catch (Commands.Failure error) { + equal(1, error.exitCode); + } equal(List.of(List.of("npm", "ci"), List.of("npm", "run", "lint")), f.fake.calls); } }); test("Frontend verification checks before testing and building", () -> { try (Fixture f = new Fixture()) { f.pipeline.verifyFrontend(); - equal(List.of("ci", "run lint", "test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml", "run build"), - f.fake.calls.stream().map(a -> String.join(" ", a.subList(1,a.size()))).toList()); + equal( + List.of( + "ci", + "run lint", + "test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml", + "run build"), + f.fake.calls.stream() + .map(a -> String.join(" ", a.subList(1, a.size()))) + .toList()); } }); test("Process execution enforces its deadline", () -> { Path dir = Files.createTempDirectory("ci-process-test-"); try { Path file = dir.resolve("Sleep.java"); - Files.writeString(file,"class Sleep { public static void main(String[] args) throws Exception { Thread.sleep(10000); } }"); + Files.writeString( + file, + "class Sleep { public static void main(String[] args) throws Exception { Thread.sleep(10000);" + + " } }"); Commands runner = new Commands(Duration.ofMillis(100)); try { - runner.execute(List.of(System.getProperty("java.home") + "/bin/java",file.toString()),dir,System.getenv(),null,false); + runner.execute( + List.of(System.getProperty("java.home") + "/bin/java", file.toString()), + dir, + System.getenv(), + null, + false); throw new AssertionError("Expected timeout"); - } catch (Commands.Failure error) { equal(124,error.exitCode); } - } finally { Pipeline.deleteTree(dir); } + } catch (Commands.Failure error) { + equal(124, error.exitCode); + } + } finally { + Pipeline.deleteTree(dir); + } }); System.out.println(count + " CI tests passed"); } + static class Fixture implements AutoCloseable { final Path root = Files.createTempDirectory("ci-publication-test-"); final FakeCommands fake = new FakeCommands(); final Pipeline pipeline; + Fixture() throws Exception { - Map env = new HashMap<>(System.getenv()); - env.putAll(Map.of("CI_COMMIT",SHA,"CI_RUN_ID","test","CI_RUN_ATTEMPT","1","CI_REF","refs/heads/main", - "CI_REPOSITORY","wanjakrah/agiledancers","TARGET_PLATFORM","linux/amd64","GITEA_SERVER_URL","http://192.168.2.28:3000", - "REGISTRY_USER","test","REGISTRY_TOKEN","dummy-token","CI_PUBLISH_LOCK_DIR",root.resolve("lock").toString())); - pipeline = new Pipeline(root,env,fake) { @Override String mainHead() { return fake.head; } }; - Files.writeString(root.resolve("ci/artifacts/verified-commit"),SHA + "\n"); + Map env = new HashMap<>(System.getenv()); + env.putAll(Map.of( + "CI_COMMIT", + SHA, + "CI_RUN_ID", + "test", + "CI_RUN_ATTEMPT", + "1", + "CI_REF", + "refs/heads/main", + "CI_REPOSITORY", + "wanjakrah/agiledancers", + "TARGET_PLATFORM", + "linux/amd64", + "GITEA_SERVER_URL", + "http://192.168.2.28:3000", + "REGISTRY_USER", + "test", + "REGISTRY_TOKEN", + "dummy-token", + "CI_PUBLISH_LOCK_DIR", + root.resolve("lock").toString())); + pipeline = new Pipeline(root, env, fake) { + @Override + String mainHead() { + return fake.head; + } + }; + Files.writeString(root.resolve("ci/artifacts/verified-commit"), SHA + "\n"); + } + + long calls(String verb) { + return fake.calls.stream() + .filter(a -> a.size() > 1 + && a.getFirst().equals("docker") + && a.get(1).equals(verb)) + .count(); + } + + public void close() throws Exception { + pipeline.closeDockerConfig(); + Pipeline.deleteTree(root); } - long calls(String verb) { return fake.calls.stream().filter(a -> a.size()>1 && a.getFirst().equals("docker") && a.get(1).equals(verb)).count(); } - public void close() throws Exception { pipeline.closeDockerConfig(); Pipeline.deleteTree(root); } } + static class FakeCommands extends Commands { final List> calls = new ArrayList<>(); - final Map images = new HashMap<>(), tags = new HashMap<>(); + final Map images = new HashMap<>(), tags = new HashMap<>(); String head = SHA, pushFailure = "", pullFailure = ""; boolean manifestError, mavenFailure, lintFailure; boolean dockerAvailable = true; - FakeCommands() { super(Duration.ofMinutes(1)); } - @Override Result execute(List args, Path cwd, Map env, String input, boolean echo) { + + FakeCommands() { + super(Duration.ofMinutes(1)); + } + + @Override + Result execute(List args, Path cwd, Map env, String input, boolean echo) { calls.add(List.copyOf(args)); - if (args.getFirst().equals("git")) return new Result(0,SHA); - if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42,"deliberate failure"); - if (args.equals(List.of("npm", "run", "lint")) && lintFailure) return new Result(1,"deliberate lint failure"); - if (!args.getFirst().equals("docker")) return new Result(0,""); + if (args.getFirst().equals("git")) return new Result(0, SHA); + if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42, "deliberate failure"); + if (args.equals(List.of("npm", "run", "lint")) && lintFailure) + return new Result(1, "deliberate lint failure"); + if (!args.getFirst().equals("docker")) return new Result(0, ""); String command = args.get(1), ref = args.getLast(); - if (command.equals("info")) return new Result(dockerAvailable ? 0 : 1,args.contains("{{.Architecture}}") ? "amd64" : "linux"); - if (command.equals("context")) return new Result(0,"unix:///tmp/test-docker.sock"); + if (command.equals("info")) + return new Result(dockerAvailable ? 0 : 1, args.contains("{{.Architecture}}") ? "amd64" : "linux"); + if (command.equals("context")) return new Result(0, "unix:///tmp/test-docker.sock"); if (command.equals("manifest")) { - if (manifestError) return new Result(1,"unauthorized"); - return images.containsKey(ref) ? new Result(0,"{}") : new Result(1,"manifest unknown"); + if (manifestError) return new Result(1, "unauthorized"); + return images.containsKey(ref) ? new Result(0, "{}") : new Result(1, "manifest unknown"); } - if (command.equals("tag")) tags.put(ref,images.getOrDefault(args.get(2),SHA)); + if (command.equals("tag")) tags.put(ref, images.getOrDefault(args.get(2), SHA)); if (command.equals("push")) { - if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1,"push failed"); - images.put(ref,tags.getOrDefault(ref,SHA)); + if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1, "push failed"); + images.put(ref, tags.getOrDefault(ref, SHA)); } - if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure)) return new Result(1,"pull failed"); - if (command.equals("image")) return new Result(0,args.stream().anyMatch(a -> a.contains("RepoDigests")) - ? ref.substring(0,ref.lastIndexOf(':')) + "@sha256:" + "b".repeat(64) : images.getOrDefault(ref,"missing")); - if (command.equals("container") || command.equals("network") && args.contains("inspect")) return new Result(1,""); - return new Result(0,""); + if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure)) + return new Result(1, "pull failed"); + if (command.equals("image")) + return new Result( + 0, + args.stream().anyMatch(a -> a.contains("RepoDigests")) + ? ref.substring(0, ref.lastIndexOf(':')) + "@sha256:" + "b".repeat(64) + : images.getOrDefault(ref, "missing")); + if (command.equals("container") || command.equals("network") && args.contains("inspect")) + return new Result(1, ""); + return new Result(0, ""); } } } diff --git a/ci/Commands.java b/ci/Commands.java index 0d6b84a..fb1bb16 100644 --- a/ci/Commands.java +++ b/ci/Commands.java @@ -13,20 +13,37 @@ class Commands { return output.strip(); } } + static class Failure extends RuntimeException { final int exitCode; - Failure(int exitCode, String message) { super(message); this.exitCode = exitCode; } + + Failure(int exitCode, String message) { + super(message); + this.exitCode = exitCode; + } } + Instant deadline; volatile Process active; Path log; - void renew(Duration timeout) { deadline = Instant.now().plus(timeout); } - void cancel() { Process process = active; if (process != null) stop(process); } - Commands(Duration timeout) { deadline = Instant.now().plus(timeout); } + + void renew(Duration timeout) { + deadline = Instant.now().plus(timeout); + } + + void cancel() { + Process process = active; + if (process != null) stop(process); + } + + Commands(Duration timeout) { + deadline = Instant.now().plus(timeout); + } Result execute(List args, Path cwd, Map env, String input, boolean echo) throws Exception { if (!Instant.now().isBefore(deadline)) throw new Failure(124, "CI time limit exceeded"); - ProcessBuilder builder = new ProcessBuilder(args).directory(cwd.toFile()).redirectErrorStream(true); + ProcessBuilder builder = + new ProcessBuilder(args).directory(cwd.toFile()).redirectErrorStream(true); builder.environment().clear(); builder.environment().putAll(env); Process process = builder.start(); @@ -36,22 +53,30 @@ class Commands { try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { Future reader = executor.submit(() -> { try (InputStream stream = process.getInputStream(); - OutputStream file = destination == null ? OutputStream.nullOutputStream() - : Files.newOutputStream(destination, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { + OutputStream file = destination == null + ? OutputStream.nullOutputStream() + : Files.newOutputStream( + destination, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { byte[] buffer = new byte[8192]; int count; while ((count = stream.read(buffer)) != -1) { captured.write(buffer, 0, count); - if (echo) { System.out.write(buffer, 0, count); System.out.flush(); } + if (echo) { + System.out.write(buffer, 0, count); + System.out.flush(); + } file.write(buffer, 0, count); } - } catch (IOException e) { throw new UncheckedIOException(e); } + } catch (IOException e) { + throw new UncheckedIOException(e); + } }); try { try (OutputStream stdin = process.getOutputStream()) { if (input != null) stdin.write(input.getBytes(StandardCharsets.UTF_8)); } - if (!process.waitFor(Math.max(1, Duration.between(Instant.now(), deadline).toMillis()), TimeUnit.MILLISECONDS)) { + if (!process.waitFor( + Math.max(1, Duration.between(Instant.now(), deadline).toMillis()), TimeUnit.MILLISECONDS)) { stop(process); throw new Failure(124, "CI time limit exceeded"); } diff --git a/ci/Json.java b/ci/Json.java index 18e84f6..e8fefdf 100644 --- a/ci/Json.java +++ b/ci/Json.java @@ -5,93 +5,145 @@ import java.util.*; final class Json { static Object parse(String source) { Parser parser = new Parser(source); - Object result = parser.value(); parser.space(); + Object result = parser.value(); + parser.space(); if (parser.pos != source.length()) throw parser.invalid(); return result; } + @SuppressWarnings("unchecked") static Map object(Object value) { if (!(value instanceof Map)) throw new IllegalArgumentException("Expected a JSON object"); return (Map) value; } + static String write(Object value) { if (value == null) return "null"; if (value instanceof String text) { StringBuilder out = new StringBuilder("\""); for (char c : text.toCharArray()) { switch (c) { - case '"' -> out.append("\\\""); case '\\' -> out.append("\\\\"); - case '\n' -> out.append("\\n"); case '\r' -> out.append("\\r"); case '\t' -> out.append("\\t"); - default -> { if (c < 32) out.append(String.format("\\u%04x", (int)c)); else out.append(c); } + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (c < 32) out.append(String.format("\\u%04x", (int) c)); + else out.append(c); + } } } return out.append('"').toString(); } - if (value instanceof Boolean || value instanceof BigDecimal || value instanceof Integer || value instanceof Long) - return value.toString(); + if (value instanceof Boolean + || value instanceof BigDecimal + || value instanceof Integer + || value instanceof Long) return value.toString(); if (value instanceof Map map) { StringJoiner out = new StringJoiner(",", "{", "}"); - map.forEach((k,v) -> out.add(write(Objects.toString(k)) + ":" + write(v))); return out.toString(); + map.forEach((k, v) -> out.add(write(Objects.toString(k)) + ":" + write(v))); + return out.toString(); } if (value instanceof List list) { - StringJoiner out = new StringJoiner(",", "[", "]"); list.forEach(v -> out.add(write(v))); return out.toString(); + StringJoiner out = new StringJoiner(",", "[", "]"); + list.forEach(v -> out.add(write(v))); + return out.toString(); } throw new IllegalArgumentException("Unsupported JSON value"); } + private static final class Parser { - final String source; int pos; - Parser(String source) { this.source = source; } - IllegalArgumentException invalid() { return new IllegalArgumentException("Invalid JSON at offset " + pos); } - void space() { while (pos < source.length() && " \r\n\t".indexOf(source.charAt(pos)) >= 0) pos++; } - boolean take(char c) { space(); if (pos < source.length() && source.charAt(pos) == c) { pos++; return true; } return false; } + final String source; + int pos; + + Parser(String source) { + this.source = source; + } + + IllegalArgumentException invalid() { + return new IllegalArgumentException("Invalid JSON at offset " + pos); + } + + void space() { + while (pos < source.length() && " \r\n\t".indexOf(source.charAt(pos)) >= 0) pos++; + } + + boolean take(char c) { + space(); + if (pos < source.length() && source.charAt(pos) == c) { + pos++; + return true; + } + return false; + } + Object value() { - space(); if (pos >= source.length()) throw invalid(); + space(); + if (pos >= source.length()) throw invalid(); char c = source.charAt(pos); if (c == '"') return string(); if (take('{')) { - Map out = new LinkedHashMap<>(); + Map out = new LinkedHashMap<>(); if (take('}')) return out; do { - space(); if (pos >= source.length() || source.charAt(pos) != '"') throw invalid(); - String key = string(); if (!take(':') || out.containsKey(key)) throw invalid(); + space(); + if (pos >= source.length() || source.charAt(pos) != '"') throw invalid(); + String key = string(); + if (!take(':') || out.containsKey(key)) throw invalid(); out.put(key, value()); } while (take(',')); - if (!take('}')) throw invalid(); return out; + if (!take('}')) throw invalid(); + return out; } if (take('[')) { - List out = new ArrayList<>(); if (take(']')) return out; - do { out.add(value()); } while (take(',')); - if (!take(']')) throw invalid(); return out; + List out = new ArrayList<>(); + if (take(']')) return out; + do { + out.add(value()); + } while (take(',')); + if (!take(']')) throw invalid(); + return out; } for (String token : List.of("true", "false", "null")) { if (source.startsWith(token, pos)) { - pos += token.length(); return token.equals("null") ? null : Boolean.valueOf(token); + pos += token.length(); + return token.equals("null") ? null : Boolean.valueOf(token); } } int start = pos; while (pos < source.length() && "-+0123456789.eE".indexOf(source.charAt(pos)) >= 0) pos++; - String number = source.substring(start,pos); + String number = source.substring(start, pos); if (!number.matches("-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?")) throw invalid(); return new BigDecimal(number); } + String string() { - pos++; StringBuilder out = new StringBuilder(); + pos++; + StringBuilder out = new StringBuilder(); while (pos < source.length()) { char c = source.charAt(pos++); if (c == '"') return out.toString(); if (c < 32) throw invalid(); - if (c != '\\') { out.append(c); continue; } + if (c != '\\') { + out.append(c); + continue; + } if (pos >= source.length()) throw invalid(); char escaped = source.charAt(pos++); switch (escaped) { case '"', '\\', '/' -> out.append(escaped); - case 'b' -> out.append('\b'); case 'f' -> out.append('\f'); - case 'n' -> out.append('\n'); case 'r' -> out.append('\r'); case 't' -> out.append('\t'); + case 'b' -> out.append('\b'); + case 'f' -> out.append('\f'); + case 'n' -> out.append('\n'); + case 'r' -> out.append('\r'); + case 't' -> out.append('\t'); case 'u' -> { if (pos + 4 > source.length()) throw invalid(); - String digits = source.substring(pos,pos+4); + String digits = source.substring(pos, pos + 4); if (!digits.matches("[0-9a-fA-F]{4}")) throw invalid(); - out.append((char) Integer.parseInt(digits,16)); pos += 4; + out.append((char) Integer.parseInt(digits, 16)); + pos += 4; } default -> throw invalid(); } diff --git a/ci/Pipeline.java b/ci/Pipeline.java index f265fe8..b5ea06c 100644 --- a/ci/Pipeline.java +++ b/ci/Pipeline.java @@ -7,12 +7,12 @@ import java.util.*; /** Verification, publication and cleanup shared by local use and Gitea Actions. */ class Pipeline { final Path root, artifacts; - final Map env; + final Map env; final Commands commands; final String commit, runName, backendImage, frontendImage; Path isolatedConfig; - Pipeline(Path root, Map environment, Commands commands) throws Exception { + Pipeline(Path root, Map environment, Commands commands) throws Exception { this.root = root; this.env = new HashMap<>(environment); this.commands = commands; @@ -23,190 +23,401 @@ class Pipeline { runName = "agiledancers-" + id + "-" + attempt; backendImage = "agiledancers-ci-backend:" + commit + "-" + id + "-" + attempt; frontendImage = "agiledancers-ci-frontend:" + commit + "-" + id + "-" + attempt; - artifacts = root.resolve("ci/artifacts"); Files.createDirectories(artifacts); + artifacts = root.resolve("ci/artifacts"); + Files.createDirectories(artifacts); } - interface Value { String get() throws Exception; } + + interface Value { + String get() throws Exception; + } + String value(String key, Value defaultValue) throws Exception { if (env.getOrDefault(key, "").isBlank()) env.put(key, defaultValue.get()); return env.get(key); } + static void check(boolean condition, String message) { if (!condition) throw new IllegalArgumentException(message); } + Commands.Result result(boolean echo, String... args) throws Exception { return commands.execute(List.of(args), root, env, null, echo); } - String capture(String... args) throws Exception { return result(false, args).checked(); } - void run(String... args) throws Exception { result(true, args).checked(); } + + String capture(String... args) throws Exception { + return result(false, args).checked(); + } + + void run(String... args) throws Exception { + result(true, args).checked(); + } + void requirePlatform() throws Exception { - String target = Setup.required(env,"TARGET_PLATFORM"); - var docker = result(false,"docker", "info", "--format", "{{.Architecture}}"); - check(docker.code() == 0, "Docker is unavailable; start Docker Desktop before running CI (details in verify.log)"); + String target = Setup.required(env, "TARGET_PLATFORM"); + var docker = result(false, "docker", "info", "--format", "{{.Architecture}}"); + check( + docker.code() == 0, + "Docker is unavailable; start Docker Desktop before running CI (details in verify.log)"); String arch = docker.output().strip(); - arch = switch (arch) { case "x86_64" -> "amd64"; case "aarch64" -> "arm64"; default -> arch; }; + arch = switch (arch) { + case "x86_64" -> "amd64"; + case "aarch64" -> "arm64"; + default -> arch; + }; String nativePlatform = capture("docker", "info", "--format", "{{.OSType}}") + "/" + arch; check(target.equals(nativePlatform), "Target " + target + " differs from Docker host " + nativePlatform); } + void isolateDocker() throws Exception { value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}")); env.remove("DOCKER_CONTEXT"); isolatedConfig = Files.createTempDirectory("agiledancers-docker-"); env.put("DOCKER_CONFIG", isolatedConfig.toString()); } + void verify() throws Exception { Files.deleteIfExists(artifacts.resolve("verified-commit")); env.put("CI", "true"); - commands.log = artifacts.resolve("verify.log"); Files.writeString(commands.log, ""); - Thread shutdown = new Thread(() -> { - commands.cancel(); - try { cleanup(); closeDockerConfig(); } catch (Exception e) { System.err.println("CI shutdown cleanup failed"); } - }, "ci-cleanup"); + commands.log = artifacts.resolve("verify.log"); + Files.writeString(commands.log, ""); + Thread shutdown = new Thread( + () -> { + commands.cancel(); + try { + cleanup(); + closeDockerConfig(); + } catch (Exception e) { + System.err.println("CI shutdown cleanup failed"); + } + }, + "ci-cleanup"); Runtime.getRuntime().addShutdownHook(shutdown); Exception failure = null; try { - requirePlatform(); isolateDocker(); + requirePlatform(); + isolateDocker(); check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit"); - run("java", "-version"); run("node", "--version"); run("mvn", "-B", "-ntp", "clean", "verify"); + run("java", "-version"); + run("node", "--version"); + run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"); CiTests.run(); Path frontend = root.resolve("agiledancers-fe"); verifyFrontend(); String platform = env.get("TARGET_PLATFORM"); - run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label", - "org.opencontainers.image.revision=" + commit, "-t", backendImage, "."); - run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label", - "org.opencontainers.image.revision=" + commit, "-t", frontendImage, "agiledancers-fe"); + run( + "docker", + "build", + "--platform", + platform, + "--target", + "prebuilt", + "--label", + "org.opencontainers.image.revision=" + commit, + "-t", + backendImage, + "."); + run( + "docker", + "build", + "--platform", + platform, + "--target", + "prebuilt", + "--label", + "org.opencontainers.image.revision=" + commit, + "-t", + frontendImage, + "agiledancers-fe"); Files.writeString(artifacts.resolve("containers-started"), ""); run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName); - run("docker", "run", "-d", "--name", runName + "-backend", "--label", "agiledancers.ci.run=" + runName, - "--platform", platform, "--network", runName, "--network-alias", "backend", "--memory", "1g", "--cpus", "2", backendImage); - run("docker", "run", "-d", "--name", runName + "-frontend", "--label", "agiledancers.ci.run=" + runName, - "--platform", platform, "--network", runName, "--network-alias", "frontend", "--memory", "256m", "--cpus", "1", frontendImage); + run( + "docker", + "run", + "-d", + "--name", + runName + "-backend", + "--label", + "agiledancers.ci.run=" + runName, + "--platform", + platform, + "--network", + runName, + "--network-alias", + "backend", + "--memory", + "1g", + "--cpus", + "2", + backendImage); + run( + "docker", + "run", + "-d", + "--name", + runName + "-frontend", + "--label", + "agiledancers.ci.run=" + runName, + "--platform", + platform, + "--network", + runName, + "--network-alias", + "frontend", + "--memory", + "256m", + "--cpus", + "1", + frontendImage); waitForApplication(); String user = capture("id", "-u") + ":" + capture("id", "-g"); - run("docker", "run", "--name", runName + "-browser", "--label", "agiledancers.ci.run=" + runName, - "--platform", platform, "--network", runName, "--memory", "2g", "--cpus", "2", "--shm-size=1g", - "--user", user, "--env", "HOME=/tmp", "--env", "CI=true", "--env", "PLAYWRIGHT_BASE_URL=http://frontend:8080", - "--volume", frontend + ":/workspace", "--workdir", "/workspace", "mcr.microsoft.com/playwright:v1.64.1-noble", - "npm", "run", "e2e"); + run( + "docker", + "run", + "--name", + runName + "-browser", + "--label", + "agiledancers.ci.run=" + runName, + "--platform", + platform, + "--network", + runName, + "--memory", + "2g", + "--cpus", + "2", + "--shm-size=1g", + "--user", + user, + "--env", + "HOME=/tmp", + "--env", + "CI=true", + "--env", + "PLAYWRIGHT_BASE_URL=http://frontend:8080", + "--volume", + frontend + ":/workspace", + "--workdir", + "/workspace", + "mcr.microsoft.com/playwright:v1.64.1-noble", + "npm", + "run", + "e2e"); Files.writeString(artifacts.resolve("verified-commit"), commit + "\n"); - } catch (Exception e) { failure = e; throw e; } - finally { - try { cleanup(); } catch (Exception e) { if (failure != null) failure.addSuppressed(e); else throw e; } - finally { + } catch (Exception e) { + failure = e; + throw e; + } finally { + try { + cleanup(); + } catch (Exception e) { + if (failure != null) failure.addSuppressed(e); + else throw e; + } finally { closeDockerConfig(); - try { Runtime.getRuntime().removeShutdownHook(shutdown); } catch (IllegalStateException ignored) { /* shutting down */ } + try { + Runtime.getRuntime().removeShutdownHook(shutdown); + } catch (IllegalStateException ignored) { + /* shutting down */ + } } } } + void verifyFrontend() throws Exception { Path frontend = root.resolve("agiledancers-fe"); commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked(); - commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true).checked(); - commands.execute(List.of("npm", "test", "--", "--reporters=default", "--reporters=junit", - "--output-file=unit-results/junit.xml"), frontend, env, null, true).checked(); - commands.execute(List.of("npm", "run", "build"), frontend, env, null, true).checked(); + commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true) + .checked(); + commands.execute( + List.of( + "npm", + "test", + "--", + "--reporters=default", + "--reporters=junit", + "--output-file=unit-results/junit.xml"), + frontend, + env, + null, + true) + .checked(); + commands.execute(List.of("npm", "run", "build"), frontend, env, null, true) + .checked(); } + void waitForApplication() throws Exception { for (int i = 0; i < 60; i++) { - if (result(false,"docker", "exec", runName + "-frontend", "wget", "-q", "-O", "-", - "http://127.0.0.1:8080/api/dance-schools").code() == 0) return; + if (result( + false, + "docker", + "exec", + runName + "-frontend", + "wget", + "-q", + "-O", + "-", + "http://127.0.0.1:8080/api/dance-schools") + .code() + == 0) return; Thread.sleep(2000); } throw new IllegalArgumentException("Application readiness timed out"); } + synchronized void cleanup() throws Exception { // A separate cleanup budget remains available after the build deadline. commands.renew(Duration.ofMinutes(2)); List failures = new ArrayList<>(); - for (String path : List.of("agiledancers-be/target/surefire-reports", "agiledancers-be/target/failsafe-reports", - "agiledancers-fe/unit-results", "agiledancers-fe/test-results", "agiledancers-fe/playwright-report")) { + for (String path : List.of( + "agiledancers-be/target/surefire-reports", + "agiledancers-be/target/failsafe-reports", + "agiledancers-fe/unit-results", + "agiledancers-fe/test-results", + "agiledancers-fe/playwright-report")) { if (Files.isDirectory(root.resolve(path))) { - try { copyTree(root.resolve(path), artifacts.resolve(path)); } catch (IOException e) { failures.add(path); } - } - } - if (result(false,"docker", "info").code() == 0) { - for (String role : List.of("backend", "frontend", "browser")) { - String container = runName + "-" + role; - if (result(false,"docker", "container", "inspect", container).code() == 0) { - var logs = result(false,"docker", "logs", container); - Files.writeString(artifacts.resolve(role + ".log"), logs.output()); - if (logs.code() != 0) failures.add(role + " logs"); - if (result(false,"docker", "rm", "-f", container).code() != 0) failures.add(role + " cleanup"); + try { + copyTree(root.resolve(path), artifacts.resolve(path)); + } catch (IOException e) { + failures.add(path); } } - if (result(false,"docker", "network", "inspect", runName).code() == 0 - && result(false,"docker", "network", "rm", runName).code() != 0) failures.add("network cleanup"); + } + if (result(false, "docker", "info").code() == 0) { + for (String role : List.of("backend", "frontend", "browser")) { + String container = runName + "-" + role; + if (result(false, "docker", "container", "inspect", container).code() == 0) { + var logs = result(false, "docker", "logs", container); + Files.writeString(artifacts.resolve(role + ".log"), logs.output()); + if (logs.code() != 0) failures.add(role + " logs"); + if (result(false, "docker", "rm", "-f", container).code() != 0) failures.add(role + " cleanup"); + } + } + if (result(false, "docker", "network", "inspect", runName).code() == 0 + && result(false, "docker", "network", "rm", runName).code() != 0) failures.add("network cleanup"); if (failures.isEmpty()) Files.deleteIfExists(artifacts.resolve("containers-started")); - } else if (Files.exists(artifacts.resolve("containers-started"))) failures.add("Docker unavailable during cleanup"); + } else if (Files.exists(artifacts.resolve("containers-started"))) + failures.add("Docker unavailable during cleanup"); check(failures.isEmpty(), "Cleanup incomplete: " + String.join(", ", failures)); } + void publish() throws Exception { - if (!"refs/heads/main".equals(env.get("CI_REF"))) { System.out.println("Branch is not main; no publication"); return; } - check(Files.exists(artifacts.resolve("verified-commit")) - && Files.readString(artifacts.resolve("verified-commit")).strip().equals(commit), "This commit has not passed verification"); - String user = Setup.required(env,"REGISTRY_USER"), token = Setup.required(env,"REGISTRY_TOKEN"); - String server = Setup.required(env,"GITEA_SERVER_URL"); - String repository = Setup.required(env,"CI_REPOSITORY"); + if (!"refs/heads/main".equals(env.get("CI_REF"))) { + System.out.println("Branch is not main; no publication"); + return; + } + check( + Files.exists(artifacts.resolve("verified-commit")) + && Files.readString(artifacts.resolve("verified-commit")) + .strip() + .equals(commit), + "This commit has not passed verification"); + String user = Setup.required(env, "REGISTRY_USER"), token = Setup.required(env, "REGISTRY_TOKEN"); + String server = Setup.required(env, "GITEA_SERVER_URL"); + String repository = Setup.required(env, "CI_REPOSITORY"); requirePlatform(); - commands.log = artifacts.resolve("publish.log"); Files.writeString(commands.log, ""); + commands.log = artifacts.resolve("publish.log"); + Files.writeString(commands.log, ""); var uri = java.net.URI.create(server); - check(Set.of("http", "https").contains(uri.getScheme()) && (uri.getPath().isEmpty() || uri.getPath().equals("/")), + check( + Set.of("http", "https").contains(uri.getScheme()) + && (uri.getPath().isEmpty() || uri.getPath().equals("/")), "Registry must be hosted at the Gitea server root"); String registry = uri.getRawAuthority(); String owner = repository.split("/")[0].toLowerCase(Locale.ROOT); check(owner.matches("[a-z0-9][a-z0-9_.-]*"), "Invalid registry namespace"); - Path lockDir = Path.of(env.getOrDefault("CI_PUBLISH_LOCK_DIR", - env.getOrDefault("XDG_STATE_HOME", Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci")); + Path lockDir = Path.of(env.getOrDefault( + "CI_PUBLISH_LOCK_DIR", + env.getOrDefault( + "XDG_STATE_HOME", + Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci")); Files.createDirectories(lockDir); // Shared host lock: Gitea 1.25 ignores workflow concurrency. - try (FileChannel channel = FileChannel.open(lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE); - FileLock lock = awaitLock(channel)) { + try (FileChannel channel = FileChannel.open( + lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock lock = awaitLock(channel)) { isolateDocker(); try { - commands.execute(List.of("docker", "login", registry, "--username", user, "--password-stdin"), root, env, token, true).checked(); + commands.execute( + List.of("docker", "login", registry, "--username", user, "--password-stdin"), + root, + env, + token, + true) + .checked(); String prefix = registry + "/" + owner + "/agiledancers-"; ensureImage(prefix + "backend", backendImage); ensureImage(prefix + "frontend", frontendImage); for (String role : List.of("backend", "frontend")) { - if (!commit.equals(mainHead())) { System.out.println("Newer main exists; moving tags unchanged"); return; } + if (!commit.equals(mainHead())) { + System.out.println("Newer main exists; moving tags unchanged"); + return; + } String image = prefix + role; run("docker", "tag", image + ":" + commit, image + ":main"); run("docker", "push", image + ":main"); run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), image + ":main"); } - } finally { closeDockerConfig(); } + } finally { + closeDockerConfig(); + } } } + FileLock awaitLock(FileChannel channel) throws Exception { Instant end = Instant.now().plusSeconds(600); while (Instant.now().isBefore(end)) { - FileLock lock = channel.tryLock(); if (lock != null) return lock; + FileLock lock = channel.tryLock(); + if (lock != null) return lock; Thread.sleep(250); } throw new IllegalArgumentException("Publication lock timed out"); } + String mainHead() throws Exception { - Map branch = Json.object(Setup.api(env.get("GITEA_SERVER_URL"), - env.get("REGISTRY_TOKEN"), "GET", "/api/v1/repos/" + env.get("CI_REPOSITORY") + "/branches/main", null)); + Map branch = Json.object(Setup.api( + env.get("GITEA_SERVER_URL"), + env.get("REGISTRY_TOKEN"), + "GET", + "/api/v1/repos/" + env.get("CI_REPOSITORY") + "/branches/main", + null)); return Objects.toString(Json.object(branch.get("commit")).get("id")); } + void ensureImage(String repository, String source) throws Exception { String ref = repository + ":" + commit; - var manifest = result(false,"docker", "manifest", "inspect", "--insecure", ref); + var manifest = result(false, "docker", "manifest", "inspect", "--insecure", ref); if (manifest.code() == 0) System.out.println("Preserving existing " + ref); else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) { - run("docker", "tag", source, ref); run("docker", "push", ref); - } else throw new IllegalArgumentException("Registry manifest lookup failed; authentication/network errors are not missing tags"); + run("docker", "tag", source, ref); + run("docker", "push", ref); + } else + throw new IllegalArgumentException( + "Registry manifest lookup failed; authentication/network errors are not missing tags"); run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), ref); - check(commit.equals(capture("docker", "image", "inspect", "--format", "{{index .Config.Labels \"org.opencontainers.image.revision\"}}", ref)), + check( + commit.equals(capture( + "docker", + "image", + "inspect", + "--format", + "{{index .Config.Labels \"org.opencontainers.image.revision\"}}", + ref)), "Existing image has an unexpected source revision"); - String digests = capture("docker", "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", ref); + String digests = + capture("docker", "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", ref); check(digests.contains(repository + "@sha256:"), "Missing registry digest"); - Files.writeString(artifacts.resolve(repository.endsWith("backend") ? "backend-digests.txt" : "frontend-digests.txt"), digests + "\n"); + Files.writeString( + artifacts.resolve(repository.endsWith("backend") ? "backend-digests.txt" : "frontend-digests.txt"), + digests + "\n"); } + synchronized void closeDockerConfig() throws IOException { - if (isolatedConfig != null) { deleteTree(isolatedConfig); isolatedConfig = null; } + if (isolatedConfig != null) { + deleteTree(isolatedConfig); + isolatedConfig = null; + } } + static void copyTree(Path source, Path destination) throws IOException { try (var paths = Files.walk(source)) { for (Path path : paths.toList()) { @@ -216,8 +427,11 @@ class Pipeline { } } } + static void deleteTree(Path root) throws IOException { if (!Files.exists(root)) return; - try (var paths = Files.walk(root)) { for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); } + try (var paths = Files.walk(root)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); + } } } diff --git a/ci/Setup.java b/ci/Setup.java index 4cf4a4c..ad4f550 100644 --- a/ci/Setup.java +++ b/ci/Setup.java @@ -8,154 +8,280 @@ import java.time.Duration; import java.util.*; class Setup { - static Path home() { return Path.of(System.getProperty("user.home")); } - static String required(Map env, String key) { + static Path home() { + return Path.of(System.getProperty("user.home")); + } + + static String required(Map env, String key) { String value = env.get(key); if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key); return value; } - static String secret(Map env, String key) throws IOException { - Path path = Path.of(required(env,key)); + + static String secret(Map env, String key) throws IOException { + Path path = Path.of(required(env, key)); Set permissions = Files.getPosixFilePermissions(path); - if (permissions.stream().anyMatch(p -> p.name().startsWith("GROUP_") || p.name().startsWith("OTHERS_"))) + if (permissions.stream() + .anyMatch(p -> p.name().startsWith("GROUP_") || p.name().startsWith("OTHERS_"))) throw new IllegalArgumentException(key + " must refer to an owner-only file (mode 600)"); String token = Files.readString(path).strip(); if (token.isEmpty()) throw new IllegalArgumentException(key + " is empty"); return token; } + static class ApiFailure extends IOException { final int status; - ApiFailure(int status) { super("Gitea HTTP " + status); this.status = status; } + + ApiFailure(int status) { + super("Gitea HTTP " + status); + this.status = status; + } } + static Object api(String server, String token, String method, String path, Object body) throws Exception { Pipeline.check(token.matches("[a-zA-Z0-9_-]+"), "Invalid token format"); HttpRequest request = HttpRequest.newBuilder(URI.create(server.replaceAll("/+$", "") + path)) - .timeout(Duration.ofSeconds(30)).header("Authorization", "token " + token) + .timeout(Duration.ofSeconds(30)) + .header("Authorization", "token " + token) .header("Content-Type", "application/json") - .method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(Json.write(body))) + .method( + method, + body == null + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofString(Json.write(body))) .build(); - try (HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build()) { + try (HttpClient client = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build()) { var response = client.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() < 200 || response.statusCode() >= 300) throw new ApiFailure(response.statusCode()); + if (response.statusCode() < 200 || response.statusCode() >= 300) + throw new ApiFailure(response.statusCode()); return response.body().isBlank() ? null : Json.parse(response.body()); } } - static void configureGitea(Map env) throws Exception { - String admin = secret(env,"GITEA_ADMIN_TOKEN_FILE"), registry = secret(env,"REGISTRY_TOKEN_FILE"); - String user = required(env,"REGISTRY_USER"), platform = required(env,"TARGET_PLATFORM"); - Pipeline.check(Set.of("linux/amd64","linux/arm64").contains(platform), "Set TARGET_PLATFORM from the verified Synology architecture"); + + static void configureGitea(Map env) throws Exception { + String admin = secret(env, "GITEA_ADMIN_TOKEN_FILE"), registry = secret(env, "REGISTRY_TOKEN_FILE"); + String user = required(env, "REGISTRY_USER"), platform = required(env, "TARGET_PLATFORM"); + Pipeline.check( + Set.of("linux/amd64", "linux/arm64").contains(platform), + "Set TARGET_PLATFORM from the verified Synology architecture"); String server = "http://192.168.2.28:3000", base = "/api/v1/repos/wanjakrah/agiledancers"; - api(server,admin,"PATCH",base,Map.of("has_actions",true)); - api(server,admin,"PUT",base + "/actions/secrets/REGISTRY_USER",Map.of("data",user)); - api(server,admin,"PUT",base + "/actions/secrets/REGISTRY_TOKEN",Map.of("data",registry)); + api(server, admin, "PATCH", base, Map.of("has_actions", true)); + api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_USER", Map.of("data", user)); + api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_TOKEN", Map.of("data", registry)); String variable = base + "/actions/variables/TARGET_PLATFORM"; boolean exists; - try { api(server,admin,"GET",variable,null); exists = true; } - catch (ApiFailure e) { if (e.status != 404) throw e; exists = false; } - api(server,admin,exists ? "PUT" : "POST",variable,Map.of("value",platform)); - Map response = Json.object(api(server,admin,"POST",base + "/actions/runners/registration-token",null)); + try { + api(server, admin, "GET", variable, null); + exists = true; + } catch (ApiFailure e) { + if (e.status != 404) throw e; + exists = false; + } + api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform)); + Map response = + Json.object(api(server, admin, "POST", base + "/actions/runners/registration-token", null)); Path directory = home().resolve(".config/agiledancers-runner"); privateDirectory(directory); privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token"))); System.out.println("Actions, registry secrets and platform configured; runner token saved outside Git."); } + static void configureDocker(Path file) throws IOException { - Map data = Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>(); + Map data = + Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>(); Object entry = data.getOrDefault("insecure-registries", new ArrayList<>()); - if (!(entry instanceof List)) throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged"); - List registries = new ArrayList<>((List)entry); - if (registries.contains("192.168.2.28:3000")) { System.out.println("Internal registry already configured; no change"); return; } - registries.add("192.168.2.28:3000"); data.put("insecure-registries",registries); - Path absolute = file.toAbsolutePath(), parent = absolute.getParent(); Files.createDirectories(parent); + if (!(entry instanceof List)) + throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged"); + List registries = new ArrayList<>((List) entry); + if (registries.contains("192.168.2.28:3000")) { + System.out.println("Internal registry already configured; no change"); + return; + } + registries.add("192.168.2.28:3000"); + data.put("insecure-registries", registries); + Path absolute = file.toAbsolutePath(), parent = absolute.getParent(); + Files.createDirectories(parent); if (Files.exists(file)) { Path backup = file.resolveSibling(file.getFileName() + ".before-agiledancers"); - if (!Files.exists(backup)) Files.copy(file,backup,StandardCopyOption.COPY_ATTRIBUTES); + if (!Files.exists(backup)) Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES); } - Path temp = Files.createTempFile(parent,"daemon-", ".json"); + Path temp = Files.createTempFile(parent, "daemon-", ".json"); try { - Files.writeString(temp,Json.write(data) + "\n"); - if (Files.exists(file)) Files.setPosixFilePermissions(temp,Files.getPosixFilePermissions(file)); - Files.move(temp,absolute,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING); - } finally { Files.deleteIfExists(temp); } + Files.writeString(temp, Json.write(data) + "\n"); + if (Files.exists(file)) Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(file)); + Files.move(temp, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temp); + } System.out.println("Internal registry added; restart/apply Docker Engine configuration to activate"); } - static void installRunner(Path root, Map env, Commands commands, boolean prepareOnly) throws Exception { - Pipeline.check(System.getProperty("os.name").equals("Linux") && Set.of("amd64","x86_64").contains(System.getProperty("os.arch")), + + static void installRunner(Path root, Map env, Commands commands, boolean prepareOnly) + throws Exception { + Pipeline.check( + System.getProperty("os.name").equals("Linux") + && Set.of("amd64", "x86_64").contains(System.getProperty("os.arch")), "Installer targets the existing Linux x86_64 mini-PC"); - Path runnerDir = home().resolve(".local/share/agiledancers-runner"), configDir = home().resolve(".config/agiledancers-runner"); + Path runnerDir = home().resolve(".local/share/agiledancers-runner"), + configDir = home().resolve(".config/agiledancers-runner"); Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user"); - for (Path path : List.of(runnerDir,configDir,bin,units)) privateDirectory(path); - for (String tool : List.of("node","java","mvn","docker","git","systemctl")) executable(env,tool); + for (Path path : List.of(runnerDir, configDir, bin, units)) privateDirectory(path); + for (String tool : List.of("node", "java", "mvn", "docker", "git", "systemctl")) executable(env, tool); String javaHome = System.getProperty("java.home"); - String version = commands.execute(List.of("node","--version"),root,env,null,false).checked(); + String version = commands.execute(List.of("node", "--version"), root, env, null, false) + .checked(); String[] parts = version.replaceFirst("^v", "").split("\\."); int major = Integer.parseInt(parts[0]), minor = Integer.parseInt(parts[1]), patch = Integer.parseInt(parts[2]); - Pipeline.check((major == 22 && (minor > 22 || minor == 22 && patch >= 3)) || (major == 24 && minor >= 15) || major >= 26, + Pipeline.check( + (major == 22 && (minor > 22 || minor == 22 && patch >= 3)) + || (major == 24 && minor >= 15) + || major >= 26, "Supported Node version required"); Path runner = bin.resolve("act_runner"); if (!Files.isExecutable(runner)) { URI uri = URI.create("https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-linux-amd64"); byte[] bytes; - try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()) { - var response = client.send(HttpRequest.newBuilder(uri).timeout(Duration.ofMinutes(2)).build(),HttpResponse.BodyHandlers.ofByteArray()); - Pipeline.check(response.statusCode() == 200, "Runner download failed"); bytes = response.body(); + try (HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build()) { + var response = client.send( + HttpRequest.newBuilder(uri) + .timeout(Duration.ofMinutes(2)) + .build(), + HttpResponse.BodyHandlers.ofByteArray()); + Pipeline.check(response.statusCode() == 200, "Runner download failed"); + bytes = response.body(); } - String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); - Pipeline.check(hash.equals("3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2"),"Runner checksum mismatch"); - Files.write(runner,bytes); Files.setPosixFilePermissions(runner,PosixFilePermissions.fromString("rwxr-xr-x")); + String hash = HexFormat.of() + .formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + Pipeline.check( + hash.equals("3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2"), + "Runner checksum mismatch"); + Files.write(runner, bytes); + Files.setPosixFilePermissions(runner, PosixFilePermissions.fromString("rwxr-xr-x")); } - String actual = commands.execute(List.of(runner.toString(),"--version"),root,env,null,false).checked(); + String actual = commands.execute(List.of(runner.toString(), "--version"), root, env, null, false) + .checked(); Pipeline.check(actual.contains("v0.2.13"), "Expected runner v0.2.13"); - privateWrite(runnerDir.resolve("config.yaml"),Files.readString(root.resolve("ci/runner/config.yaml"))); - Files.copy(root.resolve("ci/runner/agiledancers-runner.service"),units.resolve("agiledancers-runner.service"),StandardCopyOption.REPLACE_EXISTING); - Files.copy(root.resolve("ci/runner/WaitForDocker.java"),runnerDir.resolve("WaitForDocker.java"),StandardCopyOption.REPLACE_EXISTING); + privateWrite(runnerDir.resolve("config.yaml"), Files.readString(root.resolve("ci/runner/config.yaml"))); + Files.copy( + root.resolve("ci/runner/agiledancers-runner.service"), + units.resolve("agiledancers-runner.service"), + StandardCopyOption.REPLACE_EXISTING); + Files.copy( + root.resolve("ci/runner/WaitForDocker.java"), + runnerDir.resolve("WaitForDocker.java"), + StandardCopyOption.REPLACE_EXISTING); String dockerHost = env.get("DOCKER_HOST"); - if (dockerHost == null || dockerHost.isBlank()) dockerHost = commands.execute(List.of("docker","context","inspect","--format","{{.Endpoints.docker.Host}}"),root,env,null,false).checked(); - String path = javaHome + "/bin:" + executable(env,"node").getParent() + ":" + executable(env,"mvn").getParent() + ":/usr/local/bin:/usr/bin:/bin"; - privateWrite(configDir.resolve("environment"),"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost) + "\n"); + if (dockerHost == null || dockerHost.isBlank()) + dockerHost = commands.execute( + List.of("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"), + root, + env, + null, + false) + .checked(); + String path = javaHome + "/bin:" + executable(env, "node").getParent() + ":" + + executable(env, "mvn").getParent() + ":/usr/local/bin:/usr/bin:/bin"; + privateWrite( + configDir.resolve("environment"), + "JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost) + + "\n"); boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock"); Path overrides = units.resolve("agiledancers-runner.service.d"); Path desktopOverride = overrides.resolve("10-agiledancers-docker-desktop.conf"); if (desktop) { Files.createDirectories(overrides); - Files.copy(root.resolve("ci/runner/docker-desktop.conf"),desktopOverride,StandardCopyOption.REPLACE_EXISTING); + Files.copy( + root.resolve("ci/runner/docker-desktop.conf"), + desktopOverride, + StandardCopyOption.REPLACE_EXISTING); } else { Files.deleteIfExists(desktopOverride); } - commands.execute(List.of("systemctl","--user","daemon-reload"),root,env,null,true).checked(); - if (prepareOnly) { System.out.println("Runner installed; registration and startup still required."); return; } - commands.execute(List.of("docker","info"),root,env,null,false).checked(); + commands.execute(List.of("systemctl", "--user", "daemon-reload"), root, env, null, true) + .checked(); + if (prepareOnly) { + System.out.println("Runner installed; registration and startup still required."); + return; + } + commands.execute(List.of("docker", "info"), root, env, null, false).checked(); if (!Files.exists(runnerDir.resolve(".runner"))) { - Path tokenPath = Path.of(env.getOrDefault("RUNNER_REGISTRATION_TOKEN_FILE",configDir.resolve("registration-token").toString())); - Map tokenEnv = Map.of("RUNNER_TOKEN_FILE",tokenPath.toString()); - String token = secret(tokenEnv,"RUNNER_TOKEN_FILE"); - commands.execute(List.of(runner.toString(),"register","--no-interactive","--instance","http://192.168.2.28:3000", - "--name","agiledancers-mini-pc","--labels","agiledancers-mini-pc:host","--token",token),runnerDir,env,null,true).checked(); - Files.setPosixFilePermissions(runnerDir.resolve(".runner"),PosixFilePermissions.fromString("rw-------")); + Path tokenPath = Path.of(env.getOrDefault( + "RUNNER_REGISTRATION_TOKEN_FILE", + configDir.resolve("registration-token").toString())); + Map tokenEnv = Map.of("RUNNER_TOKEN_FILE", tokenPath.toString()); + String token = secret(tokenEnv, "RUNNER_TOKEN_FILE"); + commands.execute( + List.of( + runner.toString(), + "register", + "--no-interactive", + "--instance", + "http://192.168.2.28:3000", + "--name", + "agiledancers-mini-pc", + "--labels", + "agiledancers-mini-pc:host", + "--token", + token), + runnerDir, + env, + null, + true) + .checked(); + Files.setPosixFilePermissions(runnerDir.resolve(".runner"), PosixFilePermissions.fromString("rw-------")); } if (desktop) { - commands.execute(List.of("systemctl","--user","enable","--now","docker-desktop.service"),root,env,null,true).checked(); + commands.execute( + List.of("systemctl", "--user", "enable", "--now", "docker-desktop.service"), + root, + env, + null, + true) + .checked(); // Replace any previous default.target link with the desktop-login target. - commands.execute(List.of("systemctl","--user","disable","agiledancers-runner.service"),root,env,null,true).checked(); + commands.execute( + List.of("systemctl", "--user", "disable", "agiledancers-runner.service"), + root, + env, + null, + true) + .checked(); } - commands.execute(List.of("systemctl","--user","enable","--now","agiledancers-runner.service"),root,env,null,true).checked(); + commands.execute( + List.of("systemctl", "--user", "enable", "--now", "agiledancers-runner.service"), + root, + env, + null, + true) + .checked(); System.out.println("Runner registered and started; keep the mini-PC and Docker available."); } - static Path executable(Map env, String name) { + + static Path executable(Map env, String name) { for (String dir : env.getOrDefault("PATH", "").split(File.pathSeparator)) { - Path path = Path.of(dir,name); if (Files.isRegularFile(path) && Files.isExecutable(path)) return path.toAbsolutePath(); + Path path = Path.of(dir, name); + if (Files.isRegularFile(path) && Files.isExecutable(path)) return path.toAbsolutePath(); } throw new IllegalArgumentException("Missing executable: " + name); } + static String quoted(String value) { Pipeline.check(!value.contains("\n") && !value.contains("\r"), "Invalid service environment value"); - return "\"" + value.replace("\\","\\\\").replace("\"","\\\"") + "\""; + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; } + static void privateDirectory(Path path) throws IOException { - Files.createDirectories(path); Files.setPosixFilePermissions(path,PosixFilePermissions.fromString("rwx------")); + Files.createDirectories(path); + Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rwx------")); } + static void privateWrite(Path path, String text) throws IOException { - if (!Files.exists(path)) Files.createFile(path,PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); - Files.setPosixFilePermissions(path,PosixFilePermissions.fromString("rw-------")); Files.writeString(path,text); + if (!Files.exists(path)) + Files.createFile(path, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-------")); + Files.writeString(path, text); } } diff --git a/pom.xml b/pom.xml index bdd4768..22ca652 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,11 @@ UTF-8 3.40.1 + apply + false + 2.101.0 + 8.63.4 + 9.15.6755 1.18.48 @@ -77,6 +82,50 @@ + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + false + + + format + validate + exec + + + + ${format.skip} + ${java.home}/bin/java + false + true + + --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED + --class-path + ${maven.multiModuleProjectDirectory}/tooling/format/Format.java + ${maven.multiModuleProjectDirectory} + ${format.mode} + + + + + com.palantir.javaformat + palantir-java-format + ${palantir-java-format.version} + + + org.openrewrite + rewrite-core + ${rewrite-core.version} + + + + org.apache.maven.plugins maven-compiler-plugin diff --git a/specs/S14_add-sonar b/specs/S14_add-sonar new file mode 100644 index 0000000..e69de29 diff --git a/specs/S7_define-coding-standards-to-be b/specs/S7_define-coding-standards-to-be index 6e788cb..3a01d4c 100644 --- a/specs/S7_define-coding-standards-to-be +++ b/specs/S7_define-coding-standards-to-be @@ -1 +1,43 @@ -Create a file (or append to it) CODING_STANDARDS.md and have the coding standards in there, for the review skill. \ No newline at end of file +As developer +I want anyone, including agents to follow coding standards for the agiledancers-be folder +so that the code stays readable and maintainable. + +The coding style should be desribed in CODING_STANDARDS.md. If the file does not exist, create it. If the file does exist, don't remove instructions for the front-end (Angular). This spec will ony do coding-standards for the backend (Java). + +Make sure agents use these coding_standards as wel when coding or reviewing (with the code-review skill in the skills-folder). + +For the guidelines we start with the guidelines described at https://www.geeksforgeeks.org/java/coding-guidelines-in-java/ as a basis. +When we have a collision with the Java Palentir formatter this project uses, we will always favour the Palentir formatter. +For curly braces, we use the variant: A curly brace is applied at the end of the line that starts the class, method, loop, etc. +Block comments will be enwrapped with /* and */. The actual text is on sererate lines and indented. Example: +/* + This is an example of a blocked comment. + It may have more than one line. +*/ + +if-, for-, and while-loops must always have curly braces. +No star-imports. All imports have to be explicit. +static imports are only allowed in test classes. + +De volgende metrics moeten gehaald worden, met een streefwarde, een review-signal, en een absoluut maximum: +Metric | Streefwaarde | Reviewsignaal | Blokkerende grens +Cyclomatic complexity per methode/constructor | ≤ 5 | > 7 | > 10 +Cognitive complexity per methode | ≤ 10 | > 10 | > 15 +Methodlengte, zonder lege regels en commentaar | ≤ 20 regels | > 30 | > 50 +Parameters per methode | ≤ 3 | > 4 | > 5 +Parameters per constructor | ≤ 4 | > 5 | > 7 +Geïnjecteerde dependencies per klasse | ≤ 4 | > 5 | > 7 +Fan-out per klasse, unieke gebruikte types | ≤ 10 | > 15 | alleen review +Depth of Inheritance Tree, eigen superclasslagen, exclusief Object | ≤ 1 | > 2 | > 3 +Nesting van control flow | ≤ 2 niveaus | > 3 | > 4 +Duplicated lines density | ≤ 1% | > 1% | > 3% +Klasselengte, zonder lege regels en commentaar | ≤ 200 regels | > 300 | > 500 +Publieke methoden per klasse, exclusief triviale accessors | ≤ 10 | > 15 | Geen automatische grens + +De code-review skill wordt hier ook van op de hoogte gebracht. +De test-coverage moet minimaal 80% zijn. Dit mag gehaald worden met een combinatie van unit- en e2e-testen. unit- en e2e-testen mogen afzonderlijk onder de 80% blijven. +Indien mogelijk, wordt de ArchitectureTest geupdate of wordt er een nieuwe test gemaakt om zaken te controleren die met een test te controleren zijn. Verder mogen checks uitgevoerd worden met CheckStyle, FindBugs and PMD, plus eventuele andere checks die ze aanbevelen. Tot slot mogen er PIT-testen worden gemaakt om de code-coverage te checken. +Het toeveogen van een geconfigureerde Sonar in de pipeline is out-of-scope. Dit doen we later met spec S14. + +The current code-base should be updated to adhere to these coding standards. + diff --git a/specs/S8-formatting-evidence.md b/specs/S8-formatting-evidence.md new file mode 100644 index 0000000..0725125 --- /dev/null +++ b/specs/S8-formatting-evidence.md @@ -0,0 +1,96 @@ +# S8 implementation evidence + +Measured on 2026-10-30 on Linux x86_64 (kernel 7.0.0-38-generic), AMD Ryzen 7 +7730U, 16 logical CPUs; Ubuntu OpenJDK 25.0.4.1, Maven 3.9.16. These are local +wall-clock measurements, not universal performance guarantees. + +## Integration and scope + +- exec-maven-plugin 3.6.3; OpenRewrite core 8.63.4; Palantir Java Format 2.101.0. +- Custom `Format.PalantirRecipe` processes OpenRewrite PlainText source files + using Palantir's `formatSourceAndFixImports`, Palantir style, `formatJavadoc(false)`. + No Java type attribution, OpenRewrite Java parser or AutoFormat is involved. +- 73 maintained Java files: 65 backend production/test files, 7 CI/runner helpers, + and the formatter helper. Recursive discovery excludes generated build output. +- Root validate executes before compilation; compile/test/verify/install all reach + validate. CI explicitly passes check mode to clean verify. Standalone commands + run only the root exec execution and do not compile the backend. +- Bootstrap was first executed with neither Palantir nor OpenRewrite in the local + Maven repository. The helper is source-launched, with dependencies supplied by + Maven's plugin classpath. There is no project recipe artifact to preinstall. + The isolated regression fixture also has no compiled adapter or backend module. + +## Correctness + +- Full `mvn -B -ntp clean verify` passed after formatting: 33 backend tests, + zero failures/errors/skips. Both benchmark clean verifications also pass. +- `java ci/Ci.java test`: 17 passed, including assertion of the exact check-only + Maven invocation and that Maven failure removes the success marker and prevents + image build/publication. +- `python3 tooling/format/test.py` checks an isolated source-launcher checkout: + read-only check failure with path and apply command; native unused-import + removal; UTF-8 non-ASCII preservation; check/apply agreement; second uncached + check with no differences; malformed Java fails with its path and leaves the + source bytes unchanged; a newly introduced file cannot evade the content cache. +- All current Java files have been processed by the real engine on JDK 25, + including records, switch expressions/multiple labels, pattern instanceof, + lambdas and source-launcher CI classes. Tests compile/run the resulting sources. + This demonstrates the syntax used here, not every possible Java 25 construct. +- Palantir uses javac internals (explicit JVM exports are in the root POM) and its + modern AST visitor. Recheck formatter compatibility when upgrading the JDK. + Compact source files / instance main methods are a known upstream limitation + ([upstream issue](https://github.com/palantir/palantir-java-format/issues/1602)); + they are not used here. Unsupported future syntax fails, rather than being + silently excluded. Extra Markdown/Javadoc formatting is disabled. +- The initial Java rewrite is committed separately from integration changes; + its only transformations are the formatter's native output and import handling. + +## Performance + +`python3 tooling/format/benchmark.py` runs sequentially, with normal dependencies +and OS filesystem caches warm. Each configuration receives a compile warmup. +The baseline temporarily removes the entire exec plugin declaration from the +root POM, then restores its original bytes before the formatting build. Therefore +these comparisons include plugin loading, dependency resolution, JVM launch, +source-launcher compilation, hashing, cache IO and recipe execution. They do not +compare only the inner formatter function or a skipped plugin against itself. + +The successful-content cache is warm for unchanged builds; it is part of the +integration. Changed/new files are processed by Palantir. Clean builds remove the +cache and process every maintained Java file. Each run's stdout and elapsed time +are saved under `target/`; the benchmark restores the root POM and changed helper. + +| Repeated compile | Baseline (s) | Integration (s) | Added overhead (s) | +| --- | ---: | ---: | ---: | +| 1 | 4.887 | 6.414 | 1.527 | +| 2 | 4.821 | 5.964 | 1.143 | +| 3 | 4.559 | 6.024 | 1.465 | + +Maximum observed added overhead: **1.527 seconds**, below the agreed three-second +limit. Baseline compile warmup: 5.307 s; integration warmup: 7.604 s. + +The changed-file case appends a newline to `ci/runner/WaitForDocker.java`, then +runs `mvn -B -ntp compile`: **5.506 s**, successful; Palantir processes that +changed file. The script restores its original bytes afterward. + +Clean baseline `mvn -B -ntp clean verify`: **24.797 s**. Clean integration +`mvn -B -ntp -Dformat.mode=check clean verify`: **27.747 s**, all 73 files +processed, no differences, all tests pass. The three-second criterion applies +to repeated unchanged builds, not the clean-build comparison. + +### First-use downloads (separate measurement) + +A disposable root POM with the same pinned plugin/dependencies and an empty +`-Dmaven.repo.local` replaces only the Java executable with `/usr/bin/true`. +Thus dependency resolution runs without compiling the launcher or executing the +formatter/application. Command: +`mvn -N -B -ntp -Dmaven.repo.local= org.codehaus.mojo:exec-maven-plugin:3.6.3:exec@format`. +Cold resolution took **9.041 s**; the same command with that repository populated +took **1.826 s**. The **7.215 s** cold-versus-warm difference represents first-use +network/repository population overhead in this environment, separately from the +warm-cache build measurements above. Cold timing also includes root POM/BOM and +plugin bootstrap resolution; it is not reported as pure formatter execution. +Reproduce with `python3 tooling/format/benchmark-downloads.py` (Linux, network +access). The initial real source-launcher bootstrap/check also successfully +processed all files; its expected failure listed formatting differences, rather +than a missing project recipe or parser/bootstrap error. diff --git a/specs/refined/S8_palentir-formatting-be b/specs/implemented/S8_palentir-formatting-be similarity index 90% rename from specs/refined/S8_palentir-formatting-be rename to specs/implemented/S8_palentir-formatting-be index f1cb0e9..d78de92 100644 --- a/specs/refined/S8_palentir-formatting-be +++ b/specs/implemented/S8_palentir-formatting-be @@ -1,6 +1,6 @@ # S8 — Palantir formatting for Java -Status: REFINED +Status: IMPLEMENTED Agreed after the grill-me review; shared understanding confirmed. @@ -163,30 +163,30 @@ style or silently take precedence over the Maven result. ## Acceptance criteria -- [ ] The actual Palantir engine formats all maintained backend Java sources, +- [x] The actual Palantir engine formats all maintained backend Java sources, Java tests and `ci/*.java`; generated code is excluded. -- [ ] OpenRewrite integration, custom adapter/recipe and bootstrap are implemented +- [x] OpenRewrite integration, custom adapter/recipe and bootstrap are implemented and reproducible from a clean checkout without manual recipe installation. -- [ ] Plugin, formatter and adapter dependencies are pinned, with matching local +- [x] Plugin, formatter and adapter dependencies are pinned, with matching local and CI configuration. -- [ ] Local Maven builds apply formatting before compilation; `compile`, `test`, +- [x] Local Maven builds apply formatting before compilation; `compile`, `test`, `verify` and `install` reach the intended integration. -- [ ] Standalone apply and check commands work without the full application build. -- [ ] CI checks only, leaves maintained sources unchanged, reports formatting +- [x] Standalone apply and check commands work without the full application build. +- [x] CI checks only, leaves maintained sources unchanged, reports formatting differences and fails before image publication. -- [ ] Import ordering and unused-import removal follow Palantir; optional extra +- [x] Import ordering and unused-import removal follow Palantir; optional extra Javadoc reformatting remains disabled and no unrelated refactoring is added. -- [ ] All currently used Java 25 syntax is handled, or causes an explicit failure +- [x] All currently used Java 25 syntax is handled, or causes an explicit failure requiring resolution rather than silent exclusion. -- [ ] Apply/check results agree and a second apply produces no differences. -- [ ] Initial formatting is isolated in a formatting-only commit; application +- [x] Apply/check results agree and a second apply produces no differences. +- [x] Initial formatting is isolated in a formatting-only commit; application builds and tests remain green. -- [ ] Equivalent repeated unchanged-build measurements demonstrate at most three +- [x] Equivalent repeated unchanged-build measurements demonstrate at most three seconds of additional integration overhead. Changed-file and clean-build measurements and one-time downloads are reported separately. -- [ ] Failure to meet the performance bound is reported before changing the route +- [x] Failure to meet the performance bound is reported before changing the route or bound; a writing formatter is not killed to force compliance. -- [ ] README instructions cover local and CI commands, bootstrap, optional IDE +- [x] README instructions cover local and CI commands, bootstrap, optional IDE use, authoritative Maven formatting and verification evidence. ## Implementation handoff @@ -196,3 +196,5 @@ create an adapter, change Maven/CI behavior or reformat source files. Recipe loa Java 25 compatibility and performance must be demonstrated during implementation; there is no pre-existing project benchmark establishing the preferred route's performance. + +Implementation and measured acceptance evidence: [S8-formatting-evidence.md](../S8-formatting-evidence.md). diff --git a/specs/in_review/S9_spread-dancegroups-via-ortools-setup b/specs/implemented/S9_spread-dancegroups-via-ortools-setup similarity index 99% rename from specs/in_review/S9_spread-dancegroups-via-ortools-setup rename to specs/implemented/S9_spread-dancegroups-via-ortools-setup index 39b0cb1..57001f7 100644 --- a/specs/in_review/S9_spread-dancegroups-via-ortools-setup +++ b/specs/implemented/S9_spread-dancegroups-via-ortools-setup @@ -1,6 +1,6 @@ # S9 — Schedule dance groups across show dayparts with OR-Tools -Status: IN_REVIEW +Status: IMPLEMENTED Agreed after the grill-me review; shared understanding confirmed. diff --git a/tooling/format/Format.java b/tooling/format/Format.java new file mode 100644 index 0000000..d8b42da --- /dev/null +++ b/tooling/format/Format.java @@ -0,0 +1,131 @@ +import com.palantir.javaformat.java.Formatter; +import com.palantir.javaformat.java.JavaFormatterOptions; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; +import org.openrewrite.*; +import org.openrewrite.internal.InMemoryLargeSourceSet; +import org.openrewrite.text.*; + +/** Source-launcher bootstrap: no project-specific artifact is resolved or installed. */ +public class Format { + public static void main(String[] args) throws Exception { + Path root = Path.of(args[0]).toAbsolutePath().normalize(); + boolean check = switch (args[1]) { + case "check" -> true; + case "apply" -> false; + default -> throw new IllegalArgumentException("format.mode must be apply or check"); + }; + Path cacheFile = root.resolve("target/palantir-cache.properties"); + var cache = new Properties(); + if (Files.exists(cacheFile)) { + try (var input = Files.newInputStream(cacheFile)) { + cache.load(input); + } + } + String configuration = digest(Files.readString(root.resolve("pom.xml")) + + Files.readString(root.resolve("tooling/format/Format.java")) + + System.getProperty("java.version") + + System.getProperty("java.class.path")); + if (!configuration.equals(cache.getProperty("configuration"))) cache.clear(); + cache.setProperty("configuration", configuration); + List sources = new ArrayList<>(); + for (String directory : + List.of("agiledancers-be/src/main/java", "agiledancers-be/src/test/java", "ci", "tooling/format")) { + try (var paths = Files.walk(root.resolve(directory))) { + for (Path path : paths.filter(p -> p.toString().endsWith(".java")) + .sorted() + .toList()) { + try { + String content = Files.readString(path); + String key = root.relativize(path).toString(); + if (!digest(content).equals(cache.getProperty(key))) { + sources.add(PlainText.builder() + .sourcePath(root.relativize(path)) + .text(content) + .build()); + } + } catch (Exception error) { + throw new IllegalStateException(path + ": " + error.getMessage(), error); + } + } + } + } + var context = new InMemoryExecutionContext(error -> { + throw new IllegalStateException("Formatting failed: " + error.getMessage(), error); + }); + var results = new PalantirRecipe() + .run(new InMemoryLargeSourceSet(sources), context) + .getChangeset() + .getAllResults(); + for (Result result : results) { + System.out.println((check ? "Needs formatting: " : "Formatted: ") + + result.getAfter().getSourcePath()); + } + if (check && !results.isEmpty()) { + throw new IllegalStateException("Run mvn -N exec:exec@format to apply Palantir formatting."); + } + // Complete parsing/formatting before writing any maintained file. Each replacement is atomic. + if (!check) { + for (Result result : results) { + Path destination = root.resolve(result.getAfter().getSourcePath()); + Path temporary = Files.createTempFile(destination.getParent(), ".palantir-", ".tmp"); + try { + Files.writeString(temporary, result.getAfter().printAll()); + Files.setPosixFilePermissions(temporary, Files.getPosixFilePermissions(destination)); + Files.move( + temporary, + destination, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); + } + } + } + for (SourceFile source : sources) { + cache.setProperty( + source.getSourcePath().toString(), digest(Files.readString(root.resolve(source.getSourcePath())))); + } + Files.createDirectories(cacheFile.getParent()); + try (var output = Files.newOutputStream(cacheFile)) { + cache.store(output, "Successfully formatted content only"); + } + System.out.println( + "Palantir " + args[1] + ": " + sources.size() + " Java files, " + results.size() + " differences"); + } + + static String digest(String value) throws Exception { + return HexFormat.of() + .formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } + + public static final class PalantirRecipe extends Recipe { + public String getDisplayName() { + return "Palantir Java Format"; + } + + public String getDescription() { + return "Apply the pinned Palantir engine and its native import handling."; + } + + public TreeVisitor getVisitor() { + return new PlainTextVisitor() { + final Formatter formatter = Formatter.createFormatter(JavaFormatterOptions.builder() + .style(JavaFormatterOptions.Style.PALANTIR) + .formatJavadoc(false) + .build()); + + @Override + public PlainText visitText(PlainText text, ExecutionContext context) { + try { + return text.withText(formatter.formatSourceAndFixImports(text.getText())); + } catch (Exception | LinkageError error) { + throw new IllegalStateException(text.getSourcePath() + ": " + error, error); + } + } + }; + } + } +} diff --git a/tooling/format/benchmark-downloads.py b/tooling/format/benchmark-downloads.py new file mode 100644 index 0000000..1dc6b2f --- /dev/null +++ b/tooling/format/benchmark-downloads.py @@ -0,0 +1,31 @@ +"""Measure dependency resolution separately; run from the root with network access. + +A disposable copy replaces the Java executable with /usr/bin/true, keeping the +pinned exec-plugin dependencies. No formatter/application code executes. The +second invocation uses the populated repository; neither invocation touches the +normal Maven cache. Logs and measurements are written under target/. +""" +from pathlib import Path +import json +import shutil +import subprocess +import tempfile +import time + +root = Path(__file__).resolve().parents[2] +(root / "target").mkdir(exist_ok=True) +with tempfile.TemporaryDirectory(prefix="s8-resolution-") as temporary: + fixture = Path(temporary) + shutil.copytree(root / ".mvn", fixture / ".mvn") + pom = (root / "pom.xml").read_text().replace("${java.home}/bin/java", "/usr/bin/true") + (fixture / "pom.xml").write_text(pom) + command = ["mvn", "-N", "-B", "-ntp", "-Dmaven.repo.local=" + str(fixture / "repository"), + "org.codehaus.mojo:exec-maven-plugin:3.6.3:exec@format"] + for label in ("cold-resolution", "warm-resolution"): + start = time.monotonic() + result = subprocess.run(command, cwd=fixture, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + elapsed = time.monotonic() - start + (root / "target" / ("s8-" + label + ".log")).write_text(result.stdout) + print(json.dumps(dict(label=label, seconds=round(elapsed, 3), exit=result.returncode)), flush=True) + if result.returncode: + raise RuntimeError(result.stdout) diff --git a/tooling/format/benchmark.py b/tooling/format/benchmark.py new file mode 100644 index 0000000..3cd5dcb --- /dev/null +++ b/tooling/format/benchmark.py @@ -0,0 +1,49 @@ +"""Run sequential, warm-cache end-to-end Maven comparisons from the repository root.""" +from pathlib import Path +import json +import subprocess +import time + +root = Path(__file__).resolve().parents[2] +results = [] +pom = root / "pom.xml" +original_pom = pom.read_bytes() +pom_text = original_pom.decode() +start = pom_text.rfind(" ", 0, pom_text.index("exec-maven-plugin")) +end = pom_text.index(" ", start) + len(" \n") +baseline_pom = (pom_text[:start] + pom_text[end:]).encode() + +def run(label, *arguments): + command = ["mvn", "-B", "-ntp", *arguments] + start = time.monotonic() + baseline = "baseline" in label + try: + if baseline: + pom.write_bytes(baseline_pom) + result = subprocess.run(command, cwd=root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + finally: + pom.write_bytes(original_pom) + elapsed = time.monotonic() - start + (root / "target").mkdir(exist_ok=True) + (root / "target" / ("s8-" + label + ".log")).write_text(result.stdout) + row = dict(label=label, command=command, seconds=round(elapsed, 3), exit=result.returncode) + results.append(row) + print(json.dumps(row), flush=True) + if result.returncode: + raise RuntimeError(result.stdout) + +run("warmup", "compile") +run("baseline-warmup", "compile") +for number in range(3): + run("baseline-" + str(number), "compile") + run("formatted-" + str(number), "compile") +source = root / "ci/runner/WaitForDocker.java" +original = source.read_bytes() +try: + source.write_bytes(original + b"\n") + run("changed-file", "compile") +finally: + source.write_bytes(original) +run("clean-baseline", "clean", "verify") +run("clean-formatted", "-Dformat.mode=check", "clean", "verify") +(root / "target/s8-benchmark.json").write_text(json.dumps(results, indent=2) + "\n") diff --git a/tooling/format/test.py b/tooling/format/test.py new file mode 100644 index 0000000..2d72f1f --- /dev/null +++ b/tooling/format/test.py @@ -0,0 +1,53 @@ +"""Integration checks against an isolated checkout; run python3 tooling/format/test.py.""" +from pathlib import Path +import shutil +import subprocess +import tempfile + +root = Path(__file__).resolve().parents[2] +with tempfile.TemporaryDirectory(prefix="palantir-test-") as temporary: + fixture = Path(temporary) + for name in ("pom.xml", ".mvn", "tooling/format"): + source = root / name + destination = fixture / name + if source.is_dir(): + shutil.copytree(source, destination) + else: + shutil.copy2(source, destination) + for name in ("agiledancers-be/src/main/java", "agiledancers-be/src/test/java", "ci/runner"): + (fixture / name).mkdir(parents=True) + source = fixture / "ci/runner/Example.java" + original = "import java.util.List;\nclass Example{String text=\"héllo\";int value(){return 1;}}\n" + source.write_text(original) + + def run(mode, success=True): + result = subprocess.run( + ["mvn", "-N", "-B", "-ntp", "exec:exec@format", "-Dformat.mode=" + mode], + cwd=fixture, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + assert (result.returncode == 0) == success, result.stdout + return result.stdout + + output = run("check", False) + assert "ci/runner/Example.java" in output and "mvn -N exec:exec@format" in output + assert source.read_text() == original + run("apply") + formatted = source.read_text() + assert "import java.util.List" not in formatted and "héllo" in formatted + run("check") + run("apply") + assert source.read_text() == formatted + # Force a full (uncached) second pass to prove engine idempotence. + (fixture / "target/palantir-cache.properties").unlink() + run("check") + source.write_text(formatted + "class Broken { ??? }\n") + invalid = source.read_bytes() + output = run("apply", False) + assert "ci/runner/Example.java" in output + assert source.read_bytes() == invalid + # A new file is discovered even after a successful cached check. + source.write_text(formatted) + extra = fixture / "ci/New.java" + extra.write_text("class New{}") + run("check", False) + assert extra.read_text() == "class New{}" + print("PASS: check is read-only, apply/check agree, imports, UTF-8, idempotence, parser errors, new files")