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(); }