From 7e8b3a2cc797d167510ef535ef03eb43fabf1a0f Mon Sep 17 00:00:00 2001 From: Wanja Krah Date: Fri, 30 Oct 2026 15:50:51 +0100 Subject: [PATCH] S8 apply Palantir formatting to maintained Java sources --- .../agiledancers/model/AbstractEntity.java | 7 +- .../agiledancers/model/DanceGroup.java | 11 +- .../agiledancers/model/DanceSchool.java | 10 +- .../nl/agilecc/agiledancers/model/Dancer.java | 4 +- .../agilecc/agiledancers/model/DayPart.java | 7 +- .../nl/agilecc/agiledancers/model/Show.java | 5 +- .../model/specifications/DanceGroupStyle.java | 7 +- .../model/specifications/DanceGroupType.java | 6 +- .../model/specifications/DayPartType.java | 4 +- .../repository/AbstractRepository.java | 4 +- .../repository/CoachRepository.java | 4 +- .../repository/DanceGroupRepository.java | 3 +- .../repository/DanceSchoolRepository.java | 3 +- .../repository/DancerRepository.java | 3 +- .../repository/DayPartRepository.java | 3 +- .../repository/ShowRepository.java | 3 +- .../agiledancers/rest/DanceGroupResource.java | 23 +- .../rest/DanceSchoolResource.java | 11 +- .../agiledancers/rest/DancerResource.java | 11 +- .../rest/ManagementExceptionMapper.java | 8 +- .../rest/SchoolSnapshotResource.java | 40 +- .../agiledancers/rest/ShowResource.java | 28 +- .../rest/apimodel/DancerRequest.java | 3 +- .../rest/apimodel/DancerView.java | 2 +- .../rest/apimodel/DayPartRequest.java | 7 +- .../rest/apimodel/DayPartView.java | 6 +- .../rest/apimodel/GroupRequest.java | 13 +- .../agiledancers/rest/apimodel/GroupView.java | 27 +- .../rest/apimodel/SchedulingView.java | 1 + .../rest/apimodel/SchoolView.java | 11 +- .../rest/apimodel/ShowRequest.java | 8 +- .../agiledancers/rest/apimodel/ShowView.java | 18 +- .../service/DanceGroupService.java | 15 +- .../agiledancers/service/DancerService.java | 3 +- .../service/ManagementExceptionKind.java | 6 +- .../service/SchedulingService.java | 26 +- .../service/SchoolManagementService.java | 200 ++++++--- .../service/ShowConfiguration.java | 44 +- .../agiledancers/service/model/Group.java | 13 +- .../service/model/SchoolSnapshot.java | 195 ++++++--- .../solver/OrToolsSchedulingEngine.java | 29 +- .../agiledancers/ArchitectureTest.java | 134 +++--- .../nl/agilecc/agiledancers/HealthTest.java | 11 +- .../agilecc/agiledancers/SchedulingTest.java | 105 +++-- .../SchoolManagementResourceTest.java | 179 ++++++--- .../SchoolSnapshotResourceTest.java | 70 +++- .../agiledancers/SchoolSnapshotTest.java | 91 +++-- .../service/SchedulingServiceTest.java | 74 +++- .../src/test/java/sit/DanceSchoolSit.java | 4 +- ci/Ci.java | 19 +- ci/CiTests.java | 296 ++++++++++---- ci/Commands.java | 45 ++- ci/Json.java | 110 +++-- ci/Pipeline.java | 380 ++++++++++++++---- ci/Setup.java | 270 +++++++++---- 55 files changed, 1907 insertions(+), 713 deletions(-) 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 adfc81a..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,151 +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(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")); + 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 a26a91a..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", "-Dformat.mode=check", "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); } }