From 1b4c7f7f2fa3f8888ad672372c8a02987ee29f96 Mon Sep 17 00:00:00 2001 From: Wanja Krah Date: Fri, 30 Oct 2026 15:00:45 +0100 Subject: [PATCH 1/2] S9 OR Tools basic scheduling --- AGENTS.md | 2 + README.md | 24 ++- .../agilecc/agiledancers/model/DayPart.java | 3 + .../nl/agilecc/agiledancers/model/Show.java | 8 +- .../model/specifications/DanceGroupType.java | 15 +- .../rest/JacksonConfiguration.java | 14 ++ .../rest/ManagementExceptionMapper.java | 1 + .../agiledancers/rest/ShowResource.java | 24 ++- .../rest/apimodel/DayPartRequest.java | 5 + .../rest/apimodel/DayPartView.java | 5 +- .../rest/apimodel/SchedulingView.java | 2 + .../rest/apimodel/ShowRequest.java | 12 +- .../agiledancers/rest/apimodel/ShowView.java | 16 +- .../service/ManagementException.java | 2 +- .../service/SchedulingEngine.java | 4 + .../service/SchedulingService.java | 19 ++ .../service/SchoolManagementService.java | 77 ++++++++- .../agiledancers/service/SchoolSnapshot.java | 26 +-- .../service/ShowConfiguration.java | 33 ++++ .../solver/OrToolsSchedulingEngine.java | 51 ++++++ .../src/main/resources/application.properties | 4 + .../agilecc/agiledancers/SchedulingTest.java | 109 ++++++++++++ .../SchoolManagementResourceTest.java | 31 +++- .../SchoolSnapshotResourceTest.java | 4 +- .../agiledancers/SchoolSnapshotTest.java | 8 +- .../service/SchedulingServiceTest.java | 67 +++++++ agiledancers-fe/e2e/school-management.spec.ts | 19 +- agiledancers-fe/nginx/default.conf.template | 2 + agiledancers-fe/proxy.conf.json | 2 + agiledancers-fe/src/app/core/api-models.ts | 13 +- .../src/app/core/school-api.spec.ts | 7 + agiledancers-fe/src/app/core/school-api.ts | 7 + .../src/app/schools/school-page.html | 42 ++++- .../src/app/schools/school-page.ts | 89 +++++++++- .../src/app/shared/entity-editor.spec.ts | 108 +++++++++++- .../src/app/shared/entity-editor.ts | 163 +++++++++++++++++- skills/README.md | 3 +- skills/code-review/SKILL.md | 89 ++++++++++ skills/code-review/agents/openai.yaml | 3 + skills/upstream.json | 7 +- specs/S3_define-coding-standards-to-fe | 1 + specs/S7_define-coding-standards-to-be | 1 + .../S9_spread-dancegroups-via-ortools-setup | 38 ++-- 43 files changed, 1042 insertions(+), 118 deletions(-) create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/JacksonConfiguration.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartRequest.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingView.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingEngine.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingService.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowConfiguration.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java create mode 100644 agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java create mode 100644 agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingServiceTest.java create mode 100644 skills/code-review/SKILL.md create mode 100644 skills/code-review/agents/openai.yaml rename specs/{refined => in_review}/S9_spread-dancegroups-via-ortools-setup (83%) diff --git a/AGENTS.md b/AGENTS.md index 546b2e4..1a549ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ # Project skills and specifications - Project skills are stored in `skills/`; specifications belong in `specs/`. +- For code reviews, read and follow `skills/code-review/SKILL.md`. If no + dedicated Skill tool is available, load the file directly. - Use `grill-me` only when the user explicitly requests it, for example `Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`. - Before using it, read `skills/grill-me/SKILL.md` and its dependency diff --git a/README.md b/README.md index 910e4ee..367022c 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Request bodies (dates use ISO `yyyy-MM-dd`): ``` ```json -{"name": "Annual show", "date": "2027-06-12"} +{"name": "Annual show", "dayParts": [{"date": "2027-06-12", "type": "AFTERNOON"}]} ``` Names and categories must be nonblank. Birth dates cannot be in the future. @@ -138,9 +138,25 @@ Both operations return `204` and are idempotent for existing groups and dancers. Deleting a dancer removes all their memberships; deleting a group retains the dancers; deleting a school also deletes its dancers, groups and shows. -Shows have a name, a date and three automatically created day parts: `MORNING`, -`AFTERNOON`, `EVENING`. Each day part has an empty `danceGroupIds` list; assigning -groups is not exposed yet. Multiple shows per year are allowed. +Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`), +with unique date/type combinations. The displayed date range comes from these +dayparts. Each show stores `appearances` for every group type. Omitting counts +initializes defaults on creation or the initial daypart configuration; later +edits preserve saved counts and reject counts outside 1..N. With no dayparts, +shows remain unscheduled and saved counts are retained. + +`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` uses OR-Tools to +assign every current school group to its required number of distinct dayparts, +minimizing the busiest/quietest appearance gap. It replaces assignments only on +a valid, current result. The response distinguishes optimal balance from a valid +result whose optimality is unproven. Concurrent runs return `409`; changed inputs +also reject the result with `409`. Failed runs preserve existing assignments. +Scheduling runs synchronously with a 30-minute solver limit; development and +production proxies allow 31 minutes. The UI warns before edits that may invalidate +existing schedules and offers explicit scheduling again afterward. + +School JSON exports use format version **2**, including dated dayparts, assignments, +and all appearance counts. Version 1 files are intentionally unsupported. Run the HTTP integration tests with: 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 1cef8c1..ce65985 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 @@ -4,6 +4,7 @@ import lombok.*; import nl.agilecc.agiledancers.model.specifications.DayPartType; import java.util.List; +import java.time.LocalDate; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -12,7 +13,9 @@ import java.util.List; public class DayPart extends AbstractEntity { @ToString.Include private final DayPartType type; + private final LocalDate date; @ToString.Include + @Setter private List danceGroups; public void removeDanceGroup(DanceGroup group) { 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 59cac67..8d50f62 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 @@ -3,7 +3,8 @@ package nl.agilecc.agiledancers.model; import lombok.*; import java.util.List; -import java.time.LocalDate; +import java.util.Map; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -13,7 +14,8 @@ public class Show extends AbstractEntity { @Setter private String name; @Setter - private LocalDate date; + private Map appearances; @ToString.Include - private final List dayParts; + @Setter + private List dayParts; } 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 0c494ae..77039c7 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,18 +1,5 @@ package nl.agilecc.agiledancers.model.specifications; -import lombok.Getter; - public enum DanceGroupType { - SHOW_CASE(3), - PRE_EDUCATION(1), // TODO check if this is one group mentioned with different docent each time, or three different groups - and thus if they are required 1 day or 3 - SELECTION(2), - REGULAR(2), - PRE_SCHOOLER(1); - - @Getter - private int dayPartsRequired; - - DanceGroupType(int dayPartsRequired) { - this.dayPartsRequired = dayPartsRequired; - } + SHOW_CASE, PRE_EDUCATION, SELECTION, REGULAR, PRE_SCHOOLER } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/JacksonConfiguration.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/JacksonConfiguration.java new file mode 100644 index 0000000..83ee000 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/JacksonConfiguration.java @@ -0,0 +1,14 @@ +package nl.agilecc.agiledancers.rest; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.jackson.ObjectMapperCustomizer; +import jakarta.inject.Singleton; + +@Singleton +public class JacksonConfiguration implements ObjectMapperCustomizer { + @Override + public void customize(ObjectMapper mapper) { + mapper.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT); + } +} 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 07d52e0..32e76cb 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 @@ -14,6 +14,7 @@ public class ManagementExceptionMapper implements ExceptionMapper 404; case INVALID_INPUT -> 400; + case CONFLICT -> 409; }; 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/ShowResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java index 22208d6..3865d97 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 @@ -6,6 +6,9 @@ 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 nl.agilecc.agiledancers.model.DayPart; +import nl.agilecc.agiledancers.rest.apimodel.SchedulingView; import java.util.List; @@ -19,6 +22,9 @@ public class ShowResource { @Inject SchoolManagementService service; + @Inject + SchedulingService scheduling; + @PathParam("schoolId") long schoolId; @@ -35,7 +41,7 @@ public class ShowResource { @POST public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) { - var entity = service.createShow(schoolId, request.name(), request.date()); + 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(); } @@ -43,7 +49,21 @@ public class ShowResource { @PUT @Path("/{id}") public ShowView update(@PathParam("id") long id, @NotNull @Valid ShowRequest request) { - return ShowView.of(service.updateShow(schoolId, id, request.name(), request.date())); + return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances())); + } + + 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(); + } + + @POST + @Path("/{id}/schedule") + @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"); } @DELETE 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 new file mode 100644 index 0000000..f44e044 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartRequest.java @@ -0,0 +1,5 @@ +package nl.agilecc.agiledancers.rest.apimodel; +import java.time.LocalDate; +import jakarta.validation.constraints.NotNull; +import nl.agilecc.agiledancers.model.specifications.DayPartType; +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 6c93251..8c8006d 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,6 +1,5 @@ package nl.agilecc.agiledancers.rest.apimodel; - import nl.agilecc.agiledancers.model.specifications.DayPartType; import java.util.List; - -public record DayPartView(DayPartType type, List danceGroupIds) {} +import java.time.LocalDate; +public record DayPartView(LocalDate date, DayPartType type, List danceGroupIds) {} 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 new file mode 100644 index 0000000..e17c30b --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchedulingView.java @@ -0,0 +1,2 @@ +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/ShowRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java index ef921ef..012e2b6 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,7 @@ package nl.agilecc.agiledancers.rest.apimodel; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.time.LocalDate; - -public record ShowRequest(@NotBlank String name, @NotNull LocalDate date) {} +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) {} 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 fde7d1a..1f9c7f4 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,14 +1,10 @@ package nl.agilecc.agiledancers.rest.apimodel; - -import nl.agilecc.agiledancers.model.Show; -import nl.agilecc.agiledancers.model.DanceGroup; -import java.time.LocalDate; -import java.util.List; - -public record ShowView(long id, String name, LocalDate date, List dayParts) { +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.getDate(), show.getDayParts().stream() - .map(dayPart -> new DayPartView(dayPart.getType(), dayPart.getDanceGroups().stream() - .map(DanceGroup::getId).toList())).toList()); + 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/ManagementException.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementException.java index cecedc7..e6df6e4 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementException.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementException.java @@ -1,7 +1,7 @@ package nl.agilecc.agiledancers.service; public class ManagementException extends RuntimeException { - public enum Kind { NOT_FOUND, INVALID_INPUT } + public enum Kind { NOT_FOUND, INVALID_INPUT, CONFLICT } private final Kind kind; diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingEngine.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingEngine.java new file mode 100644 index 0000000..1cbc6ba --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingEngine.java @@ -0,0 +1,4 @@ +package nl.agilecc.agiledancers.service; +public interface SchedulingEngine { + SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input); +} 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 new file mode 100644 index 0000000..1d09075 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingService.java @@ -0,0 +1,19 @@ +package nl.agilecc.agiledancers.service; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +@Singleton +public class SchedulingService { + @Inject SchoolManagementService schools; + @Inject SchedulingEngine engine; + public SchoolManagementService.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(ManagementException.Kind.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 f6401f1..1abc27a 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 @@ -18,6 +18,52 @@ public class SchoolManagementService { private final DanceGroupRepository groups = new DanceGroupRepository(); private final ShowRepository shows = new ShowRepository(); + private final Map revisions = new HashMap<>(); + private final Set running = new HashSet<>(); + private void touch(Show show) { revisions.merge(show.getId(), 1L, Long::sum); } + + public record SchedulingInput(long showId, long revision, int dayParts, List groupIds, List required) {} + public record SchedulingResult(boolean optimal, List> assignments) {} + 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."); + 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."); + ShowConfiguration.validate(show.getDayParts(), show.getAppearances()); + running.add(showId); + return new SchedulingInput(showId, revisions.getOrDefault(showId, 0L), show.getDayParts().size(), + current.stream().map(DanceGroup::getId).toList(), current.stream().map(g -> 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."); } + 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."); + 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() || !input.groupIds().containsAll(part)) + throw new ManagementException(INVALID_INPUT, "Solver returned invalid group assignments."); + } + for (int g = 0; g < input.groupIds().size(); g++) { + long id = input.groupIds().get(g); + if (result.assignments().stream().filter(p -> p.contains(id)).count() != input.required().get(g)) + throw new ManagementException(INVALID_INPUT, "Solver did not satisfy every required appearance."); + } + 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()); + } + show.setDayParts(List.copyOf(replacement)); + } + public synchronized void endScheduling(long showId) { running.remove(showId); } + public synchronized List schools() { return schools.findAll(); } @@ -89,6 +135,7 @@ public class SchoolManagementService { 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; } @@ -97,6 +144,7 @@ public class SchoolManagementService { DanceGroup group = group(schoolId, groupId); validateAgeRange(startAge, endAge); group.setCategory(category); + if (group.getType() != type) school(schoolId).getShows().forEach(this::touch); group.setType(type); group.setStartAge(startAge); group.setEndAge(endAge); @@ -107,6 +155,7 @@ public class SchoolManagementService { public synchronized void deleteGroup(long schoolId, long groupId) { DanceGroup group = group(schoolId, groupId); school(schoolId).getShows().forEach(show -> show.getDayParts().forEach(part -> part.removeDanceGroup(group))); + school(schoolId).getShows().forEach(this::touch); school(schoolId).removeDanceGroup(group); groups.delete(groupId); } @@ -129,22 +178,34 @@ public class SchoolManagementService { return member(school(schoolId).getShows(), showId, "Show"); } - public synchronized Show createShow(long schoolId, String name, LocalDate date) { + public synchronized Show createShow(long schoolId, String name, List parts, Map counts) { DanceSchool school = school(schoolId); - List dayParts = Arrays.stream(DayPartType.values()) - .map(type -> DayPart.builder().type(type).danceGroups(List.of()).build()).toList(); - Show show = shows.add(Show.builder().name(name).date(date).dayParts(dayParts).build()); + 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()); school.addShow(show); return show; } - public synchronized Show updateShow(long schoolId, long showId, String name, LocalDate date) { + 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(); + 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(); show.setName(name); - show.setDate(date); + show.setDayParts(retained); + show.setAppearances(Map.copyOf(counts)); + if (changed) touch(show); return show; } + private static List partKeys(List parts) { + return parts.stream().map(p -> p.getDate() + ":" + p.getType()).toList(); + } + public synchronized void deleteShow(long schoolId, long showId) { Show show = show(schoolId, showId); school(schoolId).removeShow(show); @@ -191,8 +252,8 @@ public class SchoolManagementService { } List rebuiltShows = new ArrayList<>(); for (Show show : importedShows) { - Show rebuilt = Show.builder().name(show.getName()).date(show.getDate()).dayParts(show.getDayParts().stream() - .map(p -> DayPart.builder().type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build(); + 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()) diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolSnapshot.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolSnapshot.java index 37d1755..2cdbc6b 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolSnapshot.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolSnapshot.java @@ -5,31 +5,31 @@ import nl.agilecc.agiledancers.model.specifications.*; import java.time.LocalDate; import java.util.*; -/** Version 1 file format. IDs are local to the file, never server identifiers. */ +/** 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 Person(String id, String name, LocalDate birthDate) {} public record Trainer(String id, String name) {} public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge, List styles, List dancerIds, String coachId) {} - public record Part(DayPartType type, List groupIds) {} - public record Performance(String id, String name, LocalDate date, List dayParts) {} + public record Part(LocalDate date, DayPartType type, List groupIds) {} + public record Performance(String id, String name, List dayParts, Map appearances) {} 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(1, s.getKey(), s.getName(), + 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.getDate(), - show.getDayParts().stream().map(p -> new Part(p.getType(), - p.getDanceGroups().stream().map(groups::get).toList())).toList())).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) { @@ -39,7 +39,7 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List< } public DanceSchool restore() { - check(Objects.equals(formatVersion, 1), "Niet-ondersteunde formatVersion: " + formatVersion); + check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion); check(key != null, "key ontbreekt"); text(name, "schoolnaam"); DanceSchool school = DanceSchool.builder().key(key).name(name).build(); Map dancerMap = new LinkedHashMap<>(); @@ -73,14 +73,14 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List< 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"); - check(show.date != null, "Showdatum ontbreekt voor " + show.name); - List parts = new ArrayList<>(); Set types = new HashSet<>(); + List parts = new ArrayList<>(); Set types = new HashSet<>(); for (Part part : list(show.dayParts, "dayParts")) { - check(part != null && part.type != null && types.add(part.type), "Ongeldig of dubbel dagdeel voor " + show.name); - parts.add(DayPart.builder().type(part.type).danceGroups(unique(part.groupIds, "groupIds").stream() + 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()); } - Show entity = Show.builder().name(show.name).date(show.date).dayParts(parts).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); } return 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 new file mode 100644 index 0000000..a346678 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ShowConfiguration.java @@ -0,0 +1,33 @@ +package nl.agilecc.agiledancers.service; + +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); + }); + 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(ManagementException.Kind.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(); + } + + private static void invalidCounts() { + throw new ManagementException(ManagementException.Kind.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/solver/OrToolsSchedulingEngine.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java new file mode 100644 index 0000000..51649f8 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/solver/OrToolsSchedulingEngine.java @@ -0,0 +1,51 @@ +package nl.agilecc.agiledancers.solver; +import com.google.ortools.Loader; +import com.google.ortools.sat.*; +import jakarta.inject.Singleton; +import nl.agilecc.agiledancers.service.*; +import java.util.*; +@Singleton +public class OrToolsSchedulingEngine implements SchedulingEngine { + @Override + public SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input) { + Loader.loadNativeLibraries(); + CpModel model = new CpModel(); + int n = input.dayParts(), groups = input.groupIds().size(); + BoolVar[][] assigned = new BoolVar[groups][n]; + for (int g = 0; g < groups; g++) { + for (int p = 0; p < n; p++) assigned[g][p] = model.newBoolVar("g" + g + "p" + p); + model.addEquality(LinearExpr.sum(assigned[g]), input.required().get(g)); + } + IntVar[] loads = new IntVar[n]; + for (int p = 0; p < n; p++) { + BoolVar[] column = new BoolVar[groups]; + for (int g = 0; g < groups; g++) column[g] = assigned[g][p]; + loads[p] = model.newIntVar(0, groups, "load" + p); + model.addEquality(loads[p], LinearExpr.sum(column)); + } + 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})); + CpSolver solver = new CpSolver(); + solver.getParameters().setMaxTimeInSeconds(1800); + // Quarkus owns process shutdown; do not install a native SIGINT handler. + solver.getParameters().setCatchSigintSignal(false); + // Single-worker search with a fixed seed makes identical inputs repeatable. + solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0); + CpSolverStatus status = solver.solve(model); + if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) + throw new ManagementException(ManagementException.Kind.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.groupIds().get(g)); + parts.add(List.copyOf(ids)); + } + return new SchoolManagementService.SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts)); + } +} diff --git a/agiledancers-be/src/main/resources/application.properties b/agiledancers-be/src/main/resources/application.properties index 5265d44..d59cafd 100644 --- a/agiledancers-be/src/main/resources/application.properties +++ b/agiledancers-be/src/main/resources/application.properties @@ -1,2 +1,6 @@ quarkus.application.name=agiledancers quarkus.http.host=0.0.0.0 + +# Scheduling is synchronous with a 30-minute solver limit. +quarkus.http.idle-timeout=31M +quarkus.jackson.fail-on-unknown-properties=true diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java new file mode 100644 index 0000000..f650500 --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java @@ -0,0 +1,109 @@ +package nl.agilecc.agiledancers; + +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 java.time.LocalDate; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +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(); + } + 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()); } + } + @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()); + 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(); + assertTrue(loads.getMax() - loads.getMin() <= 1); + var snapshot = service.exportSchool(school); + assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore())); + } + @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); + 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)); + assertEquals(4, show.getDayParts().size()); + service.updateShow(school, show.getId(), "Show", List.of(), null); + assertEquals(counts, show.getAppearances()); + service.updateShow(school, show.getId(), "Show", parts(4), null); + assertEquals(counts, show.getAppearances()); + } + @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); + 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() { + var group = group(DanceGroupType.REGULAR); + 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())); + var result = new OrToolsSchedulingEngine().solve(input); + service.updateGroup(school, group.getId(), "Renamed", group.getType(), 0, 99, Set.of()); + 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()); + service.endScheduling(show.getId()); + var fresh = service.beginScheduling(school, show.getId()); + assertThrows(ManagementException.class, () -> service.finishScheduling(school, fresh, + new SchoolManagementService.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 SchoolManagementService.SchedulingResult(false, valid.assignments()))); + service.endScheduling(show.getId()); + } + @Test void staleDaypartCountChangesAndDeletedShowsAreRejected() { + group(DanceGroupType.REGULAR); + var show = service.createShow(school, "Show", parts(2), null); + var input = service.beginScheduling(school, show.getId()); + var result = new OrToolsSchedulingEngine().solve(input); + service.updateShow(school, show.getId(), "Show", parts(3), null); + assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result)); + service.deleteShow(school, show.getId()); + assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result)); + service.endScheduling(show.getId()); + } + @Test void needsGroupsAndDayparts() { + var show = service.createShow(school, "Show", parts(1), null); + assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId())); + group(DanceGroupType.REGULAR); + var empty = service.createShow(school, "Empty", List.of(), null); + assertThrows(ManagementException.class, () -> service.beginScheduling(school, empty.getId())); + assertTrue(show.getAppearances().values().stream().allMatch(n -> n == 1)); + } +} 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 eb815c8..ba2e621 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java @@ -15,7 +15,7 @@ class SchoolManagementResourceTest { 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", "date", "2027-06-12"); + private static final Map SHOW = Map.of("name", "Annual show", "dayParts", new Object[]{Map.of("date", "2027-06-12", "type", "AFTERNOON")}); @Test void managesSchoolAndItsResources() { @@ -46,10 +46,10 @@ class SchoolManagementResourceTest { long showId = create(school + "/shows", SHOW); String show = school + "/shows/" + showId; given().get(show).then().statusCode(200) - .body("dayParts.type", contains("MORNING", "AFTERNOON", "EVENING")) + .body("dayParts.type", contains("AFTERNOON")) .body("dayParts.danceGroupIds", everyItem(empty())); - put(show, Map.of("name", "Summer show", "date", "2027-07-01")) - .statusCode(200).body("date", equalTo("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)); @@ -152,6 +152,29 @@ class SchoolManagementResourceTest { } } + @Test + void schedulesAndValidatesTheNewShowConfiguration() { + String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Scheduling school")); + try { + String show = school + "/shows/" + create(school + "/shows", SHOW); + given().post(show + "/schedule").then().statusCode(400); + long group = create(school + "/dance-groups", GROUP); + given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true)); + 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) + "}"; + 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(exported)); + } 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]+$")) 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 0530fff..bfa2f9b 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotResourceTest.java @@ -12,11 +12,11 @@ class SchoolSnapshotResourceTest { 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(1)).extract().asString(); + .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\" : 1", "\"formatVersion\" : 2"), + 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) 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 5da1f07..1862b1f 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotTest.java @@ -9,12 +9,12 @@ import static org.junit.jupiter.api.Assertions.*; class SchoolSnapshotTest { private SchoolSnapshot complete() { - return new SchoolSnapshot(1, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))), + return new SchoolSnapshot(2, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))), List.of(new SchoolSnapshot.Trainer("c", "Alex")), List.of(new SchoolSnapshot.Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"), new SchoolSnapshot.Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")), - List.of(new SchoolSnapshot.Performance("s", "Show", LocalDate.of(2027,1,1), - List.of(new SchoolSnapshot.Part(DayPartType.MORNING, List.of("g", "h")))))); + List.of(new SchoolSnapshot.Performance("s", "Show", + List.of(new SchoolSnapshot.Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1)))); } @Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() { var snapshot = complete(); @@ -56,7 +56,7 @@ class SchoolSnapshotTest { @Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() { var service = new SchoolManagementService(); var valid = complete(); var original = service.importSchool(valid, null); - var bad = new SchoolSnapshot(1, 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())); 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 new file mode 100644 index 0000000..254966f --- /dev/null +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/service/SchedulingServiceTest.java @@ -0,0 +1,67 @@ +package nl.agilecc.agiledancers.service; + +import nl.agilecc.agiledancers.model.*; +import nl.agilecc.agiledancers.model.specifications.*; +import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; +import org.junit.jupiter.api.Test; +import java.time.LocalDate; +import java.util.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class SchedulingServiceTest { + @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 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(ManagementException.Kind.INVALID_INPUT, "No valid result before the limit"); }, + input -> null)) { + scheduling.engine = failure; + assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId())); + assertEquals(before, schools.exportSchool(school)); + var next = schools.beginScheduling(school, show.getId()); + schools.endScheduling(next.showId()); + } + } + + @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); + 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); } + 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(ManagementException.Kind.CONFLICT, assertThrows(ManagementException.class, + () -> scheduling.schedule(school, show.getId())).getKind()); + schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of()); + } 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")); + assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty()); + } + scheduling.engine = new OrToolsSchedulingEngine(); + scheduling.schedule(school, show.getId()); + assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size()); + } +} diff --git a/agiledancers-fe/e2e/school-management.spec.ts b/agiledancers-fe/e2e/school-management.spec.ts index e1efe94..0764762 100644 --- a/agiledancers-fe/e2e/school-management.spec.ts +++ b/agiledancers-fe/e2e/school-management.spec.ts @@ -110,15 +110,28 @@ test('manages a school, shared memberships and shows through the real REST API', await tab(page, 'Shows'); await page.getByRole('button', { name: 'Show toevoegen' }).click(); await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow'); + await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click(); await page.getByLabel('Datum', { exact: true }).fill('2027-06-12'); await save(page); - await expect(page.getByText('Ochtend', { exact: true })).toBeVisible(); - await expect(page.getByText('Middag', { exact: true })).toBeVisible(); - await expect(page.getByText('Avond', { exact: true })).toBeVisible(); + await expect(page.getByText('2027-06-12 · AFTERNOON:', { exact: false })).toBeVisible(); await page.getByRole('button', { name: 'Zomershow bewerken' }).click(); await page.getByLabel('Datum', { exact: true }).fill('2027-07-01'); await save(page); await expect(page.locator('.date-tile')).toContainText('jul 2027'); + page.once('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: /Schedule groups/ }).click(); + await expect(page.getByText('Planning voldoet aan alle optredensaantallen.')).toBeVisible(); + await expect(page.getByRole('status').filter({ hasText: 'Optimale balans.' })).toBeVisible(); + await page.getByRole('button', { name: 'Zomershow bewerken' }).click(); + await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click(); + await page.getByLabel('Datum', { exact: true }).nth(1).fill('2027-07-02'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toContain('Zomershow'); + await dialog.dismiss(); + }); + await page.getByRole('button', { name: 'Opslaan', exact: true }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByRole('button', { name: 'Annuleren', exact: true }).click(); await tab(page, 'Overzicht'); await page.setViewportSize({ width: 390, height: 844 }); diff --git a/agiledancers-fe/nginx/default.conf.template b/agiledancers-fe/nginx/default.conf.template index f616435..082052e 100644 --- a/agiledancers-fe/nginx/default.conf.template +++ b/agiledancers-fe/nginx/default.conf.template @@ -5,6 +5,8 @@ server { index index.html; location /api/ { + proxy_read_timeout 1860s; + proxy_send_timeout 1860s; proxy_pass http://${BACKEND_UPSTREAM}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/agiledancers-fe/proxy.conf.json b/agiledancers-fe/proxy.conf.json index a39fea6..389fe50 100644 --- a/agiledancers-fe/proxy.conf.json +++ b/agiledancers-fe/proxy.conf.json @@ -2,6 +2,8 @@ "/api/**": { "target": "http://127.0.0.1:8080", "secure": false, + "timeout": 1860000, + "proxyTimeout": 1860000, "changeOrigin": true } } diff --git a/agiledancers-fe/src/app/core/api-models.ts b/agiledancers-fe/src/app/core/api-models.ts index d918b77..d643150 100644 --- a/agiledancers-fe/src/app/core/api-models.ts +++ b/agiledancers-fe/src/app/core/api-models.ts @@ -21,11 +21,17 @@ export interface DanceGroup { styles: GroupStyle[]; dancerIds: number[]; } +export type DayPartType = 'MORNING' | 'AFTERNOON' | 'EVENING'; +export interface DayPart { + date: string; + type: DayPartType; + danceGroupIds: number[]; +} export interface Show { id: number; name: string; - date: string; - dayParts: { type: 'MORNING' | 'AFTERNOON' | 'EVENING'; danceGroupIds: number[] }[]; + dayParts: DayPart[]; + appearances: Partial>; } export interface SchoolRequest { name: string; @@ -43,7 +49,8 @@ export interface GroupRequest { } export interface ShowRequest { name: string; - date: string; + dayParts: { date: string; type: DayPartType }[]; + appearances?: Partial>; } export type ResourceKind = 'school' | 'dancer' | 'group' | 'show'; export type EditorTarget = diff --git a/agiledancers-fe/src/app/core/school-api.spec.ts b/agiledancers-fe/src/app/core/school-api.spec.ts index 34c9f88..f540a1d 100644 --- a/agiledancers-fe/src/app/core/school-api.spec.ts +++ b/agiledancers-fe/src/app/core/school-api.spec.ts @@ -66,4 +66,11 @@ describe('SchoolApi contract', () => { .flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' }); expect(failure).toHaveBeenCalledOnce(); }); + it('schedules synchronously with room for the 30-minute solver limit', () => { + api.schedule(7, 3).subscribe(); + const request = http.expectOne('/api/dance-schools/7/shows/3/schedule'); + expect(request.request.method).toBe('POST'); + expect(request.request.timeout).toBe(1860000); + request.flush({ optimal: false, message: 'Valid result; optimal balance not proven' }); + }); }); diff --git a/agiledancers-fe/src/app/core/school-api.ts b/agiledancers-fe/src/app/core/school-api.ts index 04b9b0c..a49064c 100644 --- a/agiledancers-fe/src/app/core/school-api.ts +++ b/agiledancers-fe/src/app/core/school-api.ts @@ -45,6 +45,13 @@ export class SchoolApi { saveShow(schoolId: number, body: ShowRequest, id?: number): Observable { return this.save(`${this.base}/${schoolId}/shows`, body, id); } + schedule(schoolId: number, showId: number): Observable<{ optimal: boolean; message: string }> { + return this.http.post<{ optimal: boolean; message: string }>( + `${this.base}/${schoolId}/shows/${showId}/schedule`, + null, + { timeout: 1860000 }, + ); + } delete(kind: ResourceKind, schoolId: number, id: number): Observable { const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[ kind diff --git a/agiledancers-fe/src/app/schools/school-page.html b/agiledancers-fe/src/app/schools/school-page.html index 2736626..cfb2282 100644 --- a/agiledancers-fe/src/app/schools/school-page.html +++ b/agiledancers-fe/src/app/schools/school-page.html @@ -114,7 +114,7 @@

IN DE SPOTLIGHT

@if (upcoming(); as show) {

{{ show.name }}

-

{{ show.date | date: 'd MMMM yyyy' }}

+

{{ dateRange(show) }}

Drie dagdelen. Eén gedeelde passie.

Shows bekijken
- {{ show.date | date: 'dd' }}{{ show.date | date: 'MMM yyyy' }} + {{ firstDate(show) | date: 'dd' }}{{ firstDate(show) | date: 'MMM yyyy' }}

{{ show.name }}

-
- OchtendMiddagAvond -
-

Groepen indelen over dagdelen is nog niet beschikbaar.

+

{{ dateRange(show) }}

+ @for (part of show.dayParts; track $index) { +

{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}

+ } +

+ {{ + scheduleValid(show) + ? 'Planning voldoet aan alle optredensaantallen.' + : 'Geen geldige planning. Plan de groepen opnieuw in.' + }} +

+ + @if (!context.groups.length || !show.dayParts.length) { +

Inplannen vereist minstens één dansgroep en één dagdeel.

+ }
@@ -398,6 +421,7 @@ diff --git a/agiledancers-fe/src/app/schools/school-page.ts b/agiledancers-fe/src/app/schools/school-page.ts index 5ac8f39..463e713 100644 --- a/agiledancers-fe/src/app/schools/school-page.ts +++ b/agiledancers-fe/src/app/schools/school-page.ts @@ -11,6 +11,7 @@ import { GROUP_TYPES, ResourceKind, SchoolData, + Show, } from '../core/api-models'; import { apiError } from '../core/api-error'; import { SchoolApi } from '../core/school-api'; @@ -51,6 +52,7 @@ export class SchoolPage implements OnInit { readonly editor = signal(null); readonly members = signal(null); readonly deleting = signal(null); + readonly scheduling = signal([]); readonly deleteBusy = signal(false); readonly deleteError = signal(''); readonly search = signal(''); @@ -60,10 +62,14 @@ export class SchoolPage implements OnInit { .sort((a, b) => a.name.localeCompare(b.name, 'nl')), ); readonly shows = computed(() => - [...(this.data()?.shows ?? [])].sort((a, b) => a.date.localeCompare(b.date)), + [...(this.data()?.shows ?? [])].sort((a, b) => + this.firstDate(a).localeCompare(this.firstDate(b)), + ), ); readonly today = new Date().toLocaleDateString('en-CA'); - readonly upcoming = computed(() => this.shows().find((show) => show.date >= this.today)); + readonly upcoming = computed(() => + this.shows().find((show) => this.lastDate(show) >= this.today), + ); readonly tabs = [ { value: 'overview', label: 'Overzicht' }, { value: 'dancers', label: 'Dansers' }, @@ -98,6 +104,74 @@ export class SchoolPage implements OnInit { this.loading.set(false); }); } + firstDate(show: Show): string { + return show.dayParts.map((p) => p.date).sort()[0] ?? ''; + } + lastDate(show: Show): string { + return ( + show.dayParts + .map((p) => p.date) + .sort() + .at(-1) ?? '' + ); + } + dateRange(show: Show): string { + const first = this.firstDate(show), + last = this.lastDate(show); + return !first ? 'No dates configured' : first === last ? first : `${first} – ${last}`; + } + scheduleValid(show: Show): boolean { + const groups = this.data()?.groups ?? []; + return ( + !!groups.length && + !!show.dayParts.length && + groups.every( + (g) => + show.dayParts.filter((p) => p.danceGroupIds.includes(g.id)).length === + show.appearances[g.type], + ) && + show.dayParts.every( + (p) => + new Set(p.danceGroupIds).size === p.danceGroupIds.length && + p.danceGroupIds.every((id) => groups.some((g) => g.id === id)), + ) + ); + } + groupNamesFor(ids: number[]): string { + return ( + ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') || + 'Geen groepen' + ); + } + async schedule(show: Show): Promise { + const schoolId = this.data()?.school.id; + if ( + schoolId === undefined || + this.scheduling().includes(show.id) || + !show.dayParts.length || + !this.data()?.groups.length + ) + return; + if ( + !window.confirm( + `Groepen inplannen voor ${show.name}? Dit vervangt alle huidige toewijzingen.`, + ) + ) + return; + this.scheduling.update((ids) => [...ids, show.id]); + this.error.set(''); + try { + const result = await firstValueFrom(this.api.schedule(schoolId, show.id)); + this.notice.set( + result.optimal ? 'Optimale balans.' : 'Geldige planning; optimale balans niet bewezen.', + ); + await this.reload(); + } catch (error: unknown) { + this.error.set(apiError(error)); + } finally { + this.scheduling.update((ids) => ids.filter((id) => id !== show.id)); + } + } async reload(): Promise { const id = this.data()?.school.id; if (id === undefined) return; @@ -133,6 +207,17 @@ export class SchoolPage implements OnInit { } askDelete(target: DeleteTarget): void { this.deleteError.set(''); + if (target.kind === 'group') { + const names = + this.data() + ?.shows.filter((s) => s.dayParts.some((p) => p.danceGroupIds.length)) + .map((s) => s.name) ?? []; + if (names.length) + target = { + ...target, + explanation: `${target.explanation} De planning kan ongeldig worden van: ${names.join(', ')}.`, + }; + } this.deleting.set(target); } membershipChanged(ids: number[]): void { diff --git a/agiledancers-fe/src/app/shared/entity-editor.spec.ts b/agiledancers-fe/src/app/shared/entity-editor.spec.ts index a279d9a..d94f419 100644 --- a/agiledancers-fe/src/app/shared/entity-editor.spec.ts +++ b/agiledancers-fe/src/app/shared/entity-editor.spec.ts @@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { SchoolApi } from '../core/school-api'; import { EntityEditor } from './entity-editor'; -import { EditorTarget } from '../core/api-models'; +import { EditorTarget, Show } from '../core/api-models'; describe('EntityEditor', () => { const api = { saveSchool: vi.fn(), saveGroup: vi.fn(), saveDancer: vi.fn(), saveShow: vi.fn() }; @@ -14,10 +14,11 @@ describe('EntityEditor', () => { providers: [{ provide: SchoolApi, useValue: api }], }); }); - function editor(target: EditorTarget) { + function editor(target: EditorTarget, shows: Show[] = []) { const fixture = TestBed.createComponent(EntityEditor); fixture.componentRef.setInput('target', target); fixture.componentRef.setInput('schoolId', 7); + fixture.componentRef.setInput('shows', shows); fixture.componentInstance.ngOnInit(); return fixture.componentInstance; } @@ -66,4 +67,107 @@ describe('EntityEditor', () => { expect(component.error()).toContain('niet bereikbaar'); expect(component.busy()).toBe(false); }); + const show: Show = { + id: 3, + name: 'Summer', + dayParts: [ + { date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] }, + { date: '2027-06-02', type: 'AFTERNOON', danceGroupIds: [4] }, + ], + appearances: { REGULAR: 2, SELECTION: 2, PRE_SCHOOLER: 1, SHOW_CASE: 2, PRE_EDUCATION: 2 }, + }; + + it('initializes defaults for every type and rejects duplicate dated dayparts and fractional counts', () => { + const component = editor({ kind: 'show' }); + component.name = 'Show'; + expect(component.validDetails()).toBe(true); + component.addPart(); + component.dayParts[0].date = '2027-06-01'; + component.addPart(); + component.dayParts[1].date = '2027-06-02'; + component.addPart(); + component.dayParts[2].date = '2027-06-03'; + expect(component.appearances).toEqual({ + REGULAR: 2, + SELECTION: 2, + PRE_SCHOOLER: 1, + SHOW_CASE: 3, + PRE_EDUCATION: 3, + }); + expect(component.validDetails()).toBe(true); + component.dayParts[2].date = '2027-06-02'; + expect(component.validDetails()).toBe(false); + component.dayParts[2].date = '2027-06-03'; + component.appearances.PRE_SCHOOLER = 1.5; + expect(component.validDetails()).toBe(false); + }); + + it('preserves saved counts and requires explicit correction when reducing to one daypart', () => { + const component = editor({ kind: 'show', value: show }); + component.addPart(); + component.dayParts[2].date = '2027-06-03'; + expect(component.appearances.SHOW_CASE).toBe(2); + component.removePart(2); + component.removePart(1); + expect(component.appearances.REGULAR).toBe(2); + expect(component.validDetails()).toBe(false); + component.setSingleCounts(); + expect(component.validDetails()).toBe(true); + }); + + it('allows cancelling an invalidating edit and skips warnings for name-only edits', async () => { + api.saveShow.mockReturnValue(of(show)); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + try { + const component = editor({ kind: 'show', value: show }); + component.appearances.REGULAR = 1; + await component.save(); + expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer')); + expect(api.saveShow).not.toHaveBeenCalled(); + component.appearances.REGULAR = 2; + confirm.mockClear(); + component.name = 'Renamed'; + await component.save(); + expect(confirm).not.toHaveBeenCalled(); + expect(api.saveShow).toHaveBeenCalledOnce(); + } finally { + confirm.mockRestore(); + } + }); + it('names affected shows before adding a group and allows cancellation', async () => { + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + try { + const component = editor({ kind: 'group' }, [show]); + component.name = 'New group'; + await component.save(); + expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer')); + expect(api.saveGroup).not.toHaveBeenCalled(); + } finally { + confirm.mockRestore(); + } + }); + + it('displays one disabled appearance field for every type with one daypart', async () => { + // jsdom does not implement native dialog opening. + const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal'); + Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { + configurable: true, + value: () => undefined, + }); + try { + const fixture = TestBed.createComponent(EntityEditor); + fixture.componentRef.setInput('target', { kind: 'show' }); + fixture.componentInstance.addPart(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + const inputs = Array.from(element.querySelectorAll('input[type="number"]')); + expect(inputs).toHaveLength(5); + expect(inputs.every((input) => input.disabled && input.value === '1')).toBe(true); + } finally { + if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor); + else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal'); + } + }); }); diff --git a/agiledancers-fe/src/app/shared/entity-editor.ts b/agiledancers-fe/src/app/shared/entity-editor.ts index 901b5a1..ec73894 100644 --- a/agiledancers-fe/src/app/shared/entity-editor.ts +++ b/agiledancers-fe/src/app/shared/entity-editor.ts @@ -1,7 +1,15 @@ import { Component, inject, input, OnInit, output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { firstValueFrom, Observable } from 'rxjs'; -import { EditorTarget, GROUP_STYLES, GROUP_TYPES, GroupStyle, GroupType } from '../core/api-models'; +import { + EditorTarget, + GROUP_STYLES, + GROUP_TYPES, + GroupStyle, + GroupType, + DayPart, + Show, +} from '../core/api-models'; import { SchoolApi } from '../core/school-api'; import { apiError } from '../core/api-error'; import { DialogFrame } from './dialog-frame'; @@ -89,11 +97,69 @@ import { DialogFrame } from './dialog-frame'; /> } @if (target().kind === 'show') { - -

- De show krijgt automatisch een ochtend-, middag- en avonddeel. De groepsindeling volgt - later. -

+
+ Gedateerde dagdelen (maximaal zes) + @for (part of dayParts; track $index; let i = $index) { +
+ + + +
+ } + +
+ @if (dayParts.length) { +
+ Optredens per groepstype + @for (option of types; track option.value) { + + } +

+ Bestaande aantallen blijven behouden bij wijzigingen. Corrigeer te hoge aantallen + vóór opslaan. +

+ @if (dayParts.length === 1 && !validCounts()) { + + } +
+ } @else { +

Zonder dagdelen blijft deze show ongepland.

+ } } }
@@ -120,6 +186,7 @@ import { DialogFrame } from './dialog-frame'; export class EntityEditor implements OnInit { readonly target = input.required(); readonly schoolId = input(); + readonly shows = input([]); readonly closed = output(); readonly saved = output(); readonly busy = signal(false); @@ -128,6 +195,9 @@ export class EntityEditor implements OnInit { readonly styles = GROUP_STYLES; readonly today = new Date().toLocaleDateString('en-CA'); private readonly api = inject(SchoolApi); + dayParts: DayPart[] = []; + appearances: Partial> = {}; + private countsInitialized = false; name = ''; date = ''; type: GroupType = 'REGULAR'; @@ -151,9 +221,48 @@ export class EntityEditor implements OnInit { } else { this.name = target.value.name; if (target.kind === 'dancer') this.date = target.value.birthDate; - if (target.kind === 'show') this.date = target.value.date; + if (target.kind === 'show') { + this.dayParts = target.value.dayParts.map((p) => ({ ...p })); + this.appearances = { ...target.value.appearances }; + this.countsInitialized = Object.keys(this.appearances).length > 0; + } } } + addPart(): void { + if (this.dayParts.length >= 6) return; + const previous = this.dayParts.length; + this.dayParts.push({ date: '', type: 'AFTERNOON', danceGroupIds: [] }); + if (!this.countsInitialized) { + for (const { value } of this.types) { + const defaultCount = (n: number) => + value === 'PRE_SCHOOLER' + ? 1 + : value === 'SHOW_CASE' || value === 'PRE_EDUCATION' + ? n + : Math.min(2, n); + if ( + this.appearances[value] === undefined || + this.appearances[value] === defaultCount(previous) + ) + this.appearances[value] = defaultCount(this.dayParts.length); + } + } + } + removePart(index: number): void { + this.dayParts.splice(index, 1); + if (!this.countsInitialized && !this.dayParts.length) this.appearances = {}; + } + setSingleCounts(): void { + for (const option of this.types) this.appearances[option.value] = 1; + } + validCounts(): boolean { + return this.types.every( + (o) => + Number.isInteger(this.appearances[o.value]) && + this.appearances[o.value]! >= 1 && + this.appearances[o.value]! <= this.dayParts.length, + ); + } toggleStyle(style: GroupStyle, checked: boolean): void { this.selectedStyles = checked ? [...this.selectedStyles, style] @@ -162,7 +271,13 @@ export class EntityEditor implements OnInit { validDetails(): boolean { if (!this.name.trim()) return false; if (this.target().kind === 'dancer') return !!this.date && this.date <= this.today; - if (this.target().kind === 'show') return !!this.date; + if (this.target().kind === 'show') + return ( + this.dayParts.length <= 6 && + this.dayParts.every((p) => !!p.date && !!p.type) && + new Set(this.dayParts.map((p) => p.date + ':' + p.type)).size === this.dayParts.length && + (!this.dayParts.length || this.validCounts()) + ); if (this.target().kind === 'group') return ( this.startAge !== null && @@ -176,6 +291,28 @@ export class EntityEditor implements OnInit { } async save(): Promise { if (this.busy() || !this.validDetails()) return; + const current = this.target(); + let affected: Show[] = []; + if (current.kind === 'group' && (!current.value || current.value.type !== this.type)) + affected = this.shows(); + if ( + current.kind === 'show' && + current.value && + (JSON.stringify(current.value.dayParts.map((p) => ({ date: p.date, type: p.type }))) !== + JSON.stringify(this.dayParts.map((p) => ({ date: p.date, type: p.type }))) || + JSON.stringify(current.value.appearances) !== JSON.stringify(this.appearances)) + ) + affected = [current.value]; + const names = affected + .filter((s) => s.dayParts.some((p) => p.danceGroupIds.length)) + .map((s) => s.name); + if ( + names.length && + !window.confirm( + `Deze wijziging kan de planning ongeldig maken van: ${names.join(', ')}. Doorgaan?`, + ) + ) + return; this.busy.set(true); this.error.set(''); const target = this.target(); @@ -207,7 +344,15 @@ export class EntityEditor implements OnInit { ); break; case 'show': - request = this.api.saveShow(schoolId!, { name: this.name.trim(), date: this.date }, id); + request = this.api.saveShow( + schoolId!, + { + name: this.name.trim(), + dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })), + appearances: this.appearances, + }, + id, + ); break; } try { diff --git a/skills/README.md b/skills/README.md index d747095..3d27842 100644 --- a/skills/README.md +++ b/skills/README.md @@ -9,12 +9,13 @@ conversation, for example: | Skill | Purpose | | --- | --- | +| [code-review](code-review/SKILL.md) | Review changes against documented coding standards and the originating spec. | | [grill-me](grill-me/SKILL.md) | Entry point for reviewing a plan or design through questions. | | [grilling](grilling/SKILL.md) | Interview instructions required by `grill-me`. | The skills are copied from [Matt Pocock's skills.sh listing](https://skills.sh/mattpocock/skills/grill-me) and its [upstream repository](https://github.com/mattpocock/skills). -The exact upstream commit and paths are recorded in [upstream.json](upstream.json). +The exact upstream revisions and paths are recorded in [upstream.json](upstream.json). The upstream [MIT license](LICENSE.mattpocock) is included. These project-local files are available to the agent from the next conversation diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md new file mode 100644 index 0000000..83183e0 --- /dev/null +++ b/skills/code-review/SKILL.md @@ -0,0 +1,89 @@ +--- +name: code-review +description: "Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes: Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/spec asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to \"review since X\"." +--- + +Two-axis review of the diff between `HEAD` and a fixed point the user supplies: + +- **Standards**: does the code conform to this repo's documented coding standards? +- **Spec**: does the code faithfully implement the originating issue / spec? + +Both axes run as **parallel sub-agents** so they don't pollute each other's context, then this skill aggregates their findings. Start every sub-agent with a fresh context window. + +The issue tracker should have been provided to you. If not, tell the user to run `/setup-matt-pocock-skills`. + +## Process + +### 1. Pin the fixed point + +Whatever the user said is the fixed point (a commit SHA, branch name, tag, `main`, `HEAD~5`, etc.). If they didn't specify one, ask for it. + +Capture the diff command once: `git diff ...HEAD` (three-dot, so the comparison is against the merge-base). Also note the list of commits via `git log ..HEAD --oneline`. + +Before going further, confirm the fixed point resolves (`git rev-parse `) and the diff is non-empty. A bad ref or empty diff should fail here, not inside two parallel sub-agents. + +### 2. Identify the spec source + +Look for the originating spec, in this order: + +1. Issue references in the commit messages (`#123`, `Closes #45`, GitLab `!67`, etc.), fetched via the workflow in the tracker doc. +2. A path the user passed as an argument. +3. A spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature. +4. If nothing is found, ask the user where the spec is. If they say there isn't one, the **Spec** sub-agent will skip and report "no spec available". + +### 3. Identify the standards sources + +Search the repo for every file that documents how code should be written. When `CODING_STANDARDS.md` or `CONTRIBUTING.md` exists, it must be on the list. + +On top of whatever the repo documents, the Standards axis always carries the **smell baseline** below: a fixed set of Fowler code smells (_Refactoring_, ch.3) that applies even when a repo documents nothing. Two rules bind it: + +- **The repo overrides.** A documented repo standard always wins; where it endorses something the baseline would flag, suppress the smell. +- **Always a judgement call.** Each smell is a labelled heuristic ("possible Feature Envy"), never a hard violation. Like any standard here, skip anything tooling already enforces. + +Each smell reads *what it is* → *how to fix*; match it against the diff: + +- **Mysterious Name**: a function, variable, or type whose name doesn't reveal what it does or holds. → rename it; if no honest name comes, the design's murky. +- **Duplicated Code**: the same logic shape appears in more than one hunk or file in the change. → extract the shared shape, call it from both. +- **Feature Envy**: a method that reaches into another object's data more than its own. → move the method onto the data it envies. +- **Data Clumps**: the same few fields or params keep travelling together (a type wanting to be born). → bundle them into one type, pass that. +- **Primitive Obsession**: a primitive or string standing in for a domain concept that deserves its own type. → give the concept its own small type. +- **Repeated Switches**: the same `switch`/`if`-cascade on the same type recurs across the change. → replace with polymorphism, or one map both sites share. +- **Shotgun Surgery**: one logical change forces scattered edits across many files in the diff. → gather what changes together into one module. +- **Divergent Change**: one file or module is edited for several unrelated reasons. → split so each module changes for one reason. +- **Speculative Generality**: abstraction, parameters, or hooks added for needs the spec doesn't have. → delete it; inline back until a real need shows. +- **Message Chains**: long `a.b().c().d()` navigation the caller shouldn't depend on. → hide the walk behind one method on the first object. +- **Middle Man**: a class or function that mostly just delegates onward. → cut it, call the real target direct. +- **Refused Bequest**: a subclass or implementer that ignores or overrides most of what it inherits. → drop the inheritance, use composition. + +### 4. Spawn both sub-agents in parallel + +Issue both sub-agent calls together, in the foreground, and aggregate the reports they return. + +**Standards sub-agent prompt** should include: + +- The full diff command and commit list. +- The list of standards-source files you found in step 3, **plus the smell baseline from step 3** pasted in full (the sub-agent has no other access to it). +- The brief: "Report, per file/hunk where relevant, (a) every place the diff violates a documented standard: cite the standard (file + the rule); and (b) any baseline smell you spot: name it and quote the hunk. Distinguish hard violations from judgement calls: documented-standard breaches can be hard, but baseline smells are always judgement calls, and a documented repo standard overrides the baseline. Skip anything tooling enforces. Under 400 words." + +**Spec sub-agent prompt** should include: + +- The diff command and commit list. +- The path or fetched contents of the spec. +- The brief: "Report: (a) requirements the spec asked for that are missing or partial; (b) behaviour in the diff that wasn't asked for (scope creep); (c) requirements that look implemented but where the implementation looks wrong. Quote the spec line for each finding. Under 400 words." + +If the spec is missing, skip the Spec sub-agent and note this in the final report. + +### 5. Aggregate + +Present the two reports under `## Standards` and `## Spec` headings, verbatim or lightly cleaned. Do **not** merge or rerank findings, because the two axes are deliberately separate (see _Why two axes_). + +End with a one-line summary: total findings per axis, and the worst issue _within each axis_ (if any). Don't pick a single winner across axes: that's the reranking the separation exists to prevent. + +## Why two axes + +A change can pass one axis and fail the other: + +- Code that follows every standard but implements the wrong thing → **Standards pass, Spec fail.** +- Code that does exactly what the issue asked but breaks the project's conventions → **Spec pass, Standards fail.** + +Reporting them separately stops one axis from masking the other. diff --git a/skills/code-review/agents/openai.yaml b/skills/code-review/agents/openai.yaml new file mode 100644 index 0000000..9076774 --- /dev/null +++ b/skills/code-review/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Code Review" + short_description: "Review a diff on standards and spec" diff --git a/skills/upstream.json b/skills/upstream.json index d3781de..67230b0 100644 --- a/skills/upstream.json +++ b/skills/upstream.json @@ -4,6 +4,11 @@ "source": "https://skills.sh/mattpocock/skills/grill-me", "skills": { "grill-me": "skills/productivity/grill-me", - "grilling": "skills/productivity/grilling" + "grilling": "skills/productivity/grilling", + "code-review": { + "path": "skills/engineering/code-review", + "commit": "a496f5513d4a4cf889bc48e189e579f4b0bdf6aa", + "source": "https://www.skills.sh/mattpocock/skills/code-review" + } } } diff --git a/specs/S3_define-coding-standards-to-fe b/specs/S3_define-coding-standards-to-fe index e69de29..6e788cb 100644 --- a/specs/S3_define-coding-standards-to-fe +++ b/specs/S3_define-coding-standards-to-fe @@ -0,0 +1 @@ +Create a file (or append to it) CODING_STANDARDS.md and have the coding standards in there, for the review skill. \ No newline at end of file diff --git a/specs/S7_define-coding-standards-to-be b/specs/S7_define-coding-standards-to-be index e69de29..6e788cb 100644 --- a/specs/S7_define-coding-standards-to-be +++ b/specs/S7_define-coding-standards-to-be @@ -0,0 +1 @@ +Create a file (or append to it) CODING_STANDARDS.md and have the coding standards in there, for the review skill. \ No newline at end of file diff --git a/specs/refined/S9_spread-dancegroups-via-ortools-setup b/specs/in_review/S9_spread-dancegroups-via-ortools-setup similarity index 83% rename from specs/refined/S9_spread-dancegroups-via-ortools-setup rename to specs/in_review/S9_spread-dancegroups-via-ortools-setup index d3ffaab..39b0cb1 100644 --- a/specs/refined/S9_spread-dancegroups-via-ortools-setup +++ b/specs/in_review/S9_spread-dancegroups-via-ortools-setup @@ -1,6 +1,6 @@ # S9 — Schedule dance groups across show dayparts with OR-Tools -Status: REFINED +Status: IN_REVIEW Agreed after the grill-me review; shared understanding confirmed. @@ -121,33 +121,43 @@ S10–S13 are intentionally empty placeholders and are to be refined separately. ## Acceptance criteria -- [ ] Configure up to six dated dayparts, reject duplicate date/type combinations +- [x] Configure up to six dated dayparts, reject duplicate date/type combinations and derive the displayed show date range from them. -- [ ] Persist editable per-type counts with the agreed defaults, remove the +- [x] Persist editable per-type counts with the agreed defaults, remove the backend type-level property and validate counts in frontend and backend. -- [ ] With one daypart, every count is one and frontend fields are disabled. -- [ ] Daypart edits preserve configured counts and require correction of counts +- [x] With one daypart, every count is one and frontend fields are disabled. +- [x] Daypart edits preserve configured counts and require correction of counts that exceed the new number of dayparts. -- [ ] OR-Tools assigns every school group to exactly its type's required number +- [x] OR-Tools assigns every school group to exactly its type's required number of distinct dayparts and minimizes the busiest/quietest appearance gap. -- [ ] Scheduling requires groups and dayparts and replaces assignments only after +- [x] Scheduling requires groups and dayparts and replaces assignments only after a successful, current result. -- [ ] The synchronous request supports a 30-minute solver limit, accepts valid +- [x] The synchronous request supports a 30-minute solver limit, accepts valid unproven results and communicates whether optimality was proven. -- [ ] Failed, infeasible, stale and no-result runs preserve existing assignments +- [x] Failed, infeasible, stale and no-result runs preserve existing assignments and explain why no replacement was saved. -- [ ] Duplicate concurrent runs for a show are prevented in frontend and backend. -- [ ] Potentially invalidating edits warn beforehand, identify affected shows, +- [x] Duplicate concurrent runs for a show are prevented in frontend and backend. +- [x] Potentially invalidating edits warn beforehand, identify affected shows, permit cancellation and expose invalid schedules afterward without automatically rescheduling. -- [ ] New-format JSON round-trips dates, assignments and all type settings; +- [x] New-format JSON round-trips dates, assignments and all type settings; legacy import support is not required. -- [ ] Any repeatability work respects both the three-minute investigation limit +- [x] Any repeatability work respects both the three-minute investigation limit and the ten-additional-statement implementation limit. ## Implementation handoff -This specification records agreed behavior; it does not implement scheduling. +This specification records the agreed behavior implemented on the current branch. The current application uses in-memory storage with JSON import/export for transfer. S9 adds the scheduling model, frontend controls, backend operation and JSON changes within that existing persistence approach. + +## Implementation validation + +- Backend: 29 tests, including real OR-Tools balancing, input validation, JSON + round-trips, concurrent scheduling, stale input rejection and failure preservation. +- Frontend: 20 tests covering count validation, disabled single-daypart fields, edit cancellation, + API timeout contract, production build and lint checks. +- Playwright: real REST workflow for dated dayparts, scheduling and cancellation. +- Repeatability uses one solver-parameter statement (single worker, fixed seed); + no extended investigation was needed. From 38247d070507c3c5445f6236d15bc0ff9c60e51f Mon Sep 17 00:00:00 2001 From: Wanja Krah Date: Fri, 30 Oct 2026 15:14:27 +0100 Subject: [PATCH 2/2] ortools review on some design issues and fixed flaky test --- README.md | 14 +++++ .../model/scheduling/GroupRequirement.java | 3 + .../model/scheduling/SchedulingInput.java | 9 +++ .../model/scheduling/SchedulingResult.java | 5 ++ .../rest/SchoolSnapshotResource.java | 5 +- .../service/ManagementException.java | 7 +-- .../service/ManagementExceptionKind.java | 3 + .../service/SchedulingEngine.java | 4 -- .../service/SchedulingService.java | 7 ++- .../service/SchoolManagementService.java | 21 ++++--- .../service/ShowConfiguration.java | 4 +- .../agiledancers/service/model/Group.java | 8 +++ .../service/model/ImportPreview.java | 3 + .../agiledancers/service/model/Part.java | 7 +++ .../service/model/Performance.java | 7 +++ .../agiledancers/service/model/Person.java | 5 ++ .../service/{ => model}/SchoolSnapshot.java | 58 +++++++++---------- .../agiledancers/service/model/Trainer.java | 3 + .../service/port/SchedulingEngine.java | 8 +++ .../solver/OrToolsSchedulingEngine.java | 16 +++-- .../agiledancers/ArchitectureTest.java | 27 ++++++++- .../agilecc/agiledancers/SchedulingTest.java | 7 ++- .../SchoolManagementResourceTest.java | 4 +- .../agiledancers/SchoolSnapshotTest.java | 13 +++-- .../service/SchedulingServiceTest.java | 5 +- 25 files changed, 177 insertions(+), 76 deletions(-) create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/GroupRequirement.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingInput.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingResult.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java delete mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingEngine.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Group.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/ImportPreview.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Part.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Performance.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Person.java rename agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/{ => model}/SchoolSnapshot.java (60%) create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Trainer.java create mode 100644 agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/port/SchedulingEngine.java diff --git a/README.md b/README.md index 367022c..2eb1c23 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,20 @@ implementations in an outer adapter. For future calculations, define a port in `service.port` using domain types and implement it in `solver`; services must not depend on solver implementations or expose OR-Tools types. +Handwritten production types each have their own file and must be top-level. +Service records live in `service.model`; the school JSON snapshot records and +import preview are service-owned data models. Scheduling records shared with +the solver live in `model.scheduling`. Each group requirement combines its group +ID and appearance count in one record. + +`service.port.SchedulingEngine` is the application-owned interface for solving a +scheduling problem. `SchedulingService` calls this interface; dependency injection +supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain +types, so neither the caller nor the contract depends on OR-Tools or the concrete +school-management service. Architecture tests enforce the port's location and +dependencies, the location of service records, and the top-level type rule +(excluding generated Lombok builders and compiler helpers). + ## School management REST API diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/GroupRequirement.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/GroupRequirement.java new file mode 100644 index 0000000..643fdf9 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/GroupRequirement.java @@ -0,0 +1,3 @@ +package nl.agilecc.agiledancers.model.scheduling; + +public record GroupRequirement(long groupId, int appearances) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingInput.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingInput.java new file mode 100644 index 0000000..9542e08 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingInput.java @@ -0,0 +1,9 @@ +package nl.agilecc.agiledancers.model.scheduling; + +import java.util.List; + +public record SchedulingInput(long showId, long revision, int dayParts, List groups) { + public SchedulingInput { + groups = List.copyOf(groups); + } +} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingResult.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingResult.java new file mode 100644 index 0000000..f99ecc7 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/scheduling/SchedulingResult.java @@ -0,0 +1,5 @@ +package nl.agilecc.agiledancers.model.scheduling; + +import java.util.List; + +public record SchedulingResult(boolean optimal, List> assignments) {} 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 e797eb0..4121884 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,5 +1,6 @@ package nl.agilecc.agiledancers.rest; +import nl.agilecc.agiledancers.service.model.*; import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.core.JsonParser; import jakarta.inject.Inject; @@ -20,7 +21,7 @@ public class SchoolSnapshotResource { return Response.ok(json).header("Content-Disposition", "attachment; filename=school-" + id + ".json").build(); } @POST @Path("/import/preview") @Consumes(MediaType.APPLICATION_JSON) - public SchoolManagementService.ImportPreview preview(String json) { return service.preview(parse(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) { @@ -37,7 +38,7 @@ public class SchoolSnapshotResource { if (snapshot == null) throw new IllegalArgumentException("Het bestand bevat geen school"); return snapshot; } catch (Exception e) { - throw new ManagementException(ManagementException.Kind.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/service/ManagementException.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementException.java index e6df6e4..ffa9ecf 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementException.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementException.java @@ -1,16 +1,15 @@ package nl.agilecc.agiledancers.service; public class ManagementException extends RuntimeException { - public enum Kind { NOT_FOUND, INVALID_INPUT, CONFLICT } - private final Kind kind; + private final ManagementExceptionKind kind; - public ManagementException(Kind kind, String message) { + public ManagementException(ManagementExceptionKind kind, String message) { super(message); this.kind = kind; } - public Kind getKind() { + public ManagementExceptionKind getKind() { return kind; } } 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 new file mode 100644 index 0000000..e7ff49d --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementExceptionKind.java @@ -0,0 +1,3 @@ +package nl.agilecc.agiledancers.service; + +public enum ManagementExceptionKind { NOT_FOUND, INVALID_INPUT, CONFLICT } diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingEngine.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingEngine.java deleted file mode 100644 index 1cbc6ba..0000000 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchedulingEngine.java +++ /dev/null @@ -1,4 +0,0 @@ -package nl.agilecc.agiledancers.service; -public interface SchedulingEngine { - SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input); -} 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 1d09075..b2ef7f7 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,11 +1,14 @@ 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; @Singleton public class SchedulingService { @Inject SchoolManagementService schools; @Inject SchedulingEngine engine; - public SchoolManagementService.SchedulingResult schedule(long schoolId, long showId) { + public SchedulingResult schedule(long schoolId, long showId) { var input = schools.beginScheduling(schoolId, showId); try { var result = engine.solve(input); @@ -13,7 +16,7 @@ public class SchedulingService { return result; } catch (ManagementException e) { throw e; } catch (RuntimeException | LinkageError e) { - throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "Solver failed. Existing assignments were preserved. Please run scheduling again."); + 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 1abc27a..5cb37e0 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,14 +1,16 @@ package nl.agilecc.agiledancers.service; +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.ManagementException.Kind.*; +import static nl.agilecc.agiledancers.service.ManagementExceptionKind.*; /** Owns the in-memory school aggregates; all writes go through this shared service. */ @Singleton @@ -22,8 +24,6 @@ public class SchoolManagementService { private final Set running = new HashSet<>(); private void touch(Show show) { revisions.merge(show.getId(), 1L, Long::sum); } - public record SchedulingInput(long showId, long revision, int dayParts, List groupIds, List required) {} - public record SchedulingResult(boolean optimal, List> assignments) {} 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."); @@ -32,7 +32,7 @@ public class SchoolManagementService { ShowConfiguration.validate(show.getDayParts(), show.getAppearances()); running.add(showId); return new SchedulingInput(showId, revisions.getOrDefault(showId, 0L), show.getDayParts().size(), - current.stream().map(DanceGroup::getId).toList(), current.stream().map(g -> show.getAppearances().get(g.getType())).toList()); + 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; @@ -46,12 +46,12 @@ public class SchoolManagementService { 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() || !input.groupIds().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 (int g = 0; g < input.groupIds().size(); g++) { - long id = input.groupIds().get(g); - if (result.assignments().stream().filter(p -> p.contains(id)).count() != input.required().get(g)) + for (GroupRequirement group : input.groups()) { + long id = group.groupId(); + if (result.assignments().stream().filter(p -> p.contains(id)).count() != group.appearances()) throw new ManagementException(INVALID_INPUT, "Solver did not satisfy every required appearance."); } List replacement = new ArrayList<>(); @@ -212,7 +212,6 @@ public class SchoolManagementService { shows.delete(showId); } - public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {} public synchronized SchoolSnapshot exportSchool(long id) { return SchoolSnapshot.of(school(id)); 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 a346678..73b31d2 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 @@ -18,7 +18,7 @@ public final class ShowConfiguration { 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(ManagementException.Kind.INVALID_INPUT, "Configure zero to six dayparts with unique date/type combinations."); + 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 @@ -27,7 +27,7 @@ public final class ShowConfiguration { } private static void invalidCounts() { - throw new ManagementException(ManagementException.Kind.INVALID_INPUT, + 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 new file mode 100644 index 0000000..92bdb7a --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Group.java @@ -0,0 +1,8 @@ +package nl.agilecc.agiledancers.service.model; + +import java.util.List; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; +import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle; + +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/ImportPreview.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/ImportPreview.java new file mode 100644 index 0000000..915cb69 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/ImportPreview.java @@ -0,0 +1,3 @@ +package nl.agilecc.agiledancers.service.model; + +public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Part.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Part.java new file mode 100644 index 0000000..2068cb0 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Part.java @@ -0,0 +1,7 @@ +package nl.agilecc.agiledancers.service.model; + +import java.time.LocalDate; +import java.util.List; +import nl.agilecc.agiledancers.model.specifications.DayPartType; + +public record Part(LocalDate date, DayPartType type, List groupIds) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Performance.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Performance.java new file mode 100644 index 0000000..0cd8641 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Performance.java @@ -0,0 +1,7 @@ +package nl.agilecc.agiledancers.service.model; + +import java.util.List; +import java.util.Map; +import nl.agilecc.agiledancers.model.specifications.DanceGroupType; + +public record Performance(String id, String name, List dayParts, Map appearances) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Person.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Person.java new file mode 100644 index 0000000..7f58a62 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Person.java @@ -0,0 +1,5 @@ +package nl.agilecc.agiledancers.service.model; + +import java.time.LocalDate; + +public record Person(String id, String name, LocalDate birthDate) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolSnapshot.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolSnapshot.java similarity index 60% rename from agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolSnapshot.java rename to agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolSnapshot.java index 2cdbc6b..f3bd199 100644 --- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolSnapshot.java +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/SchoolSnapshot.java @@ -1,5 +1,8 @@ -package nl.agilecc.agiledancers.service; +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; @@ -8,13 +11,6 @@ import java.util.*; /** 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 Person(String id, String name, LocalDate birthDate) {} - public record Trainer(String id, String name) {} - public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge, - List styles, List dancerIds, String coachId) {} - public record Part(LocalDate date, DayPartType type, List groupIds) {} - public record Performance(String id, String name, List dayParts, Map appearances) {} - public static SchoolSnapshot of(DanceSchool s) { Map dancers = ids(s.getDancers()); Map coaches = ids(s.getCoaches()); @@ -44,43 +40,43 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List< 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, - "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(); + 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(); 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"); + 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()); + 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()); } - ShowConfiguration.validate(parts, show.appearances); - Show entity = Show.builder().name(show.name).dayParts(parts).appearances(Map.copyOf(show.appearances)).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); } return school; @@ -92,5 +88,5 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List< } 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(ManagementException.Kind.INVALID_INPUT, message); } + 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/service/model/Trainer.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Trainer.java new file mode 100644 index 0000000..89ece7e --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/model/Trainer.java @@ -0,0 +1,3 @@ +package nl.agilecc.agiledancers.service.model; + +public record Trainer(String id, String name) {} diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/port/SchedulingEngine.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/port/SchedulingEngine.java new file mode 100644 index 0000000..6921771 --- /dev/null +++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/port/SchedulingEngine.java @@ -0,0 +1,8 @@ +package nl.agilecc.agiledancers.service.port; + +import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; +import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; + +public interface SchedulingEngine { + SchedulingResult solve(SchedulingInput input); +} 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 51649f8..58fbd75 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,4 +1,8 @@ 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; @@ -7,14 +11,14 @@ import java.util.*; @Singleton public class OrToolsSchedulingEngine implements SchedulingEngine { @Override - public SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input) { + public SchedulingResult solve(SchedulingInput input) { Loader.loadNativeLibraries(); CpModel model = new CpModel(); - int n = input.dayParts(), groups = input.groupIds().size(); + int n = input.dayParts(), groups = input.groups().size(); BoolVar[][] assigned = new BoolVar[groups][n]; for (int g = 0; g < groups; g++) { for (int p = 0; p < n; p++) assigned[g][p] = model.newBoolVar("g" + g + "p" + p); - model.addEquality(LinearExpr.sum(assigned[g]), input.required().get(g)); + model.addEquality(LinearExpr.sum(assigned[g]), input.groups().get(g).appearances()); } IntVar[] loads = new IntVar[n]; for (int p = 0; p < n; p++) { @@ -35,7 +39,7 @@ 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(ManagementException.Kind.INVALID_INPUT, switch (status) { + 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."; @@ -43,9 +47,9 @@ public class OrToolsSchedulingEngine implements SchedulingEngine { 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.groupIds().get(g)); + 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 SchoolManagementService.SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts)); + 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 e5afd7f..c375152 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/ArchitectureTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/ArchitectureTest.java @@ -1,10 +1,10 @@ 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; @@ -15,6 +15,31 @@ class ArchitectureTest { private static final String ROOT = "nl.agilecc.agiledancers"; private static final String API_MODEL = ROOT + ".rest.apimodel"; + // 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(); + + @ArchTest + static final ArchRule service_records_reside_in_service_model = classes() + .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"); + + @ArchTest + static final ArchRule scheduling_port_uses_domain_types = classes() + .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() 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 f650500..d7836d1 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchedulingTest.java @@ -1,5 +1,8 @@ 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.*; @@ -80,11 +83,11 @@ class SchedulingTest { service.endScheduling(show.getId()); var fresh = service.beginScheduling(school, show.getId()); assertThrows(ManagementException.class, () -> service.finishScheduling(school, fresh, - new SchoolManagementService.SchedulingResult(false, List.of(List.of(), List.of())))); + 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 SchoolManagementService.SchedulingResult(false, valid.assignments()))); + assertDoesNotThrow(() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments()))); service.endScheduling(show.getId()); } @Test void staleDaypartCountChangesAndDeletedShowsAreRejected() { 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 ba2e621..33f0218 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java @@ -3,9 +3,7 @@ package nl.agilecc.agiledancers; 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.*; @@ -171,7 +169,7 @@ class SchoolManagementResourceTest { 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(exported)); + given().get(school + "/export").then().body("$", equalTo(io.restassured.path.json.JsonPath.from(exported).getMap("$"))); } finally { given().delete(school); } } 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 1862b1f..56aba70 100644 --- a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotTest.java +++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolSnapshotTest.java @@ -1,5 +1,6 @@ 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; @@ -9,12 +10,12 @@ import static org.junit.jupiter.api.Assertions.*; class SchoolSnapshotTest { private SchoolSnapshot complete() { - return new SchoolSnapshot(2, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))), - List.of(new SchoolSnapshot.Trainer("c", "Alex")), - List.of(new SchoolSnapshot.Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"), - new SchoolSnapshot.Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")), - List.of(new SchoolSnapshot.Performance("s", "Show", - List.of(new SchoolSnapshot.Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(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"), + 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)))); } @Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() { var snapshot = complete(); 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 254966f..4caa7fc 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,5 +1,6 @@ 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; @@ -23,7 +24,7 @@ class SchedulingServiceTest { var before = schools.exportSchool(school); for (SchedulingEngine failure : List.of( input -> { throw new IllegalStateException("Native failure"); }, - input -> { throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "No valid result before the limit"); }, + 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())); @@ -51,7 +52,7 @@ class SchedulingServiceTest { var first = executor.submit(() -> scheduling.schedule(school, show.getId())); try { assertTrue(entered.await(10, TimeUnit.SECONDS)); - assertEquals(ManagementException.Kind.CONFLICT, assertThrows(ManagementException.class, + 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(); }