ortools review on some design issues and fixed flaky test
All checks were successful
Build, test and publish / verify (push) Successful in 1m34s

This commit is contained in:
2026-10-30 15:14:27 +01:00
parent 1b4c7f7f2f
commit 38247d0705
25 changed files with 177 additions and 76 deletions

View File

@@ -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 `service.port` using domain types and implement it in `solver`; services must not
depend on solver implementations or expose OR-Tools types. 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 ## School management REST API

View File

@@ -0,0 +1,3 @@
package nl.agilecc.agiledancers.model.scheduling;
public record GroupRequirement(long groupId, int appearances) {}

View File

@@ -0,0 +1,9 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.List;
public record SchedulingInput(long showId, long revision, int dayParts, List<GroupRequirement> groups) {
public SchedulingInput {
groups = List.copyOf(groups);
}
}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.List;
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {}

View File

@@ -1,5 +1,6 @@
package nl.agilecc.agiledancers.rest; package nl.agilecc.agiledancers.rest;
import nl.agilecc.agiledancers.service.model.*;
import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonParser;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@@ -20,7 +21,7 @@ public class SchoolSnapshotResource {
return Response.ok(json).header("Content-Disposition", "attachment; filename=school-" + id + ".json").build(); return Response.ok(json).header("Content-Disposition", "attachment; filename=school-" + id + ".json").build();
} }
@POST @Path("/import/preview") @Consumes(MediaType.APPLICATION_JSON) @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) @POST @Path("/import") @Consumes(MediaType.APPLICATION_JSON)
public SchoolView apply(String json, @QueryParam("replaceId") Long replaceId) { 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"); if (snapshot == null) throw new IllegalArgumentException("Het bestand bevat geen school");
return snapshot; return snapshot;
} catch (Exception e) { } 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());
} }
} }
} }

View File

@@ -1,16 +1,15 @@
package nl.agilecc.agiledancers.service; package nl.agilecc.agiledancers.service;
public class ManagementException extends RuntimeException { 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); super(message);
this.kind = kind; this.kind = kind;
} }
public Kind getKind() { public ManagementExceptionKind getKind() {
return kind; return kind;
} }
} }

View File

@@ -0,0 +1,3 @@
package nl.agilecc.agiledancers.service;
public enum ManagementExceptionKind { NOT_FOUND, INVALID_INPUT, CONFLICT }

View File

@@ -1,4 +0,0 @@
package nl.agilecc.agiledancers.service;
public interface SchedulingEngine {
SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input);
}

View File

@@ -1,11 +1,14 @@
package nl.agilecc.agiledancers.service; 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.Inject;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
@Singleton @Singleton
public class SchedulingService { public class SchedulingService {
@Inject SchoolManagementService schools; @Inject SchoolManagementService schools;
@Inject SchedulingEngine engine; @Inject SchedulingEngine engine;
public SchoolManagementService.SchedulingResult schedule(long schoolId, long showId) { public SchedulingResult schedule(long schoolId, long showId) {
var input = schools.beginScheduling(schoolId, showId); var input = schools.beginScheduling(schoolId, showId);
try { try {
var result = engine.solve(input); var result = engine.solve(input);
@@ -13,7 +16,7 @@ public class SchedulingService {
return result; return result;
} catch (ManagementException e) { throw e; } } catch (ManagementException e) { throw e; }
catch (RuntimeException | LinkageError 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); } } finally { schools.endScheduling(showId); }
} }
} }

View File

@@ -1,14 +1,16 @@
package nl.agilecc.agiledancers.service; 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 jakarta.inject.Singleton;
import nl.agilecc.agiledancers.model.*; import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*; import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.repository.*; import nl.agilecc.agiledancers.repository.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.*; import java.util.*;
import static nl.agilecc.agiledancers.service.ManagementExceptionKind.*;
import static nl.agilecc.agiledancers.service.ManagementException.Kind.*;
/** Owns the in-memory school aggregates; all writes go through this shared service. */ /** Owns the in-memory school aggregates; all writes go through this shared service. */
@Singleton @Singleton
@@ -22,8 +24,6 @@ public class SchoolManagementService {
private final Set<Long> running = new HashSet<>(); private final Set<Long> running = new HashSet<>();
private void touch(Show show) { revisions.merge(show.getId(), 1L, Long::sum); } private void touch(Show show) { revisions.merge(show.getId(), 1L, Long::sum); }
public record SchedulingInput(long showId, long revision, int dayParts, List<Long> groupIds, List<Integer> required) {}
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {}
public synchronized SchedulingInput beginScheduling(long schoolId, long showId) { public synchronized SchedulingInput beginScheduling(long schoolId, long showId) {
Show show = show(schoolId, showId); Show show = show(schoolId, showId);
if (running.contains(showId)) throw new ManagementException(CONFLICT, "Scheduling is already running for this show."); if (running.contains(showId)) throw new ManagementException(CONFLICT, "Scheduling is already running for this show.");
@@ -32,7 +32,7 @@ public class SchoolManagementService {
ShowConfiguration.validate(show.getDayParts(), show.getAppearances()); ShowConfiguration.validate(show.getDayParts(), show.getAppearances());
running.add(showId); running.add(showId);
return new SchedulingInput(showId, revisions.getOrDefault(showId, 0L), show.getDayParts().size(), 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) { public synchronized void finishScheduling(long schoolId, SchedulingInput input, SchedulingResult result) {
Show show; Show show;
@@ -46,12 +46,12 @@ public class SchoolManagementService {
Map<Long, DanceGroup> current = new HashMap<>(); Map<Long, DanceGroup> current = new HashMap<>();
groups(schoolId).forEach(g -> current.put(g.getId(), g)); groups(schoolId).forEach(g -> current.put(g.getId(), g));
for (var part : result.assignments()) { 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."); throw new ManagementException(INVALID_INPUT, "Solver returned invalid group assignments.");
} }
for (int g = 0; g < input.groupIds().size(); g++) { for (GroupRequirement group : input.groups()) {
long id = input.groupIds().get(g); long id = group.groupId();
if (result.assignments().stream().filter(p -> p.contains(id)).count() != input.required().get(g)) if (result.assignments().stream().filter(p -> p.contains(id)).count() != group.appearances())
throw new ManagementException(INVALID_INPUT, "Solver did not satisfy every required appearance."); throw new ManagementException(INVALID_INPUT, "Solver did not satisfy every required appearance.");
} }
List<DayPart> replacement = new ArrayList<>(); List<DayPart> replacement = new ArrayList<>();
@@ -212,7 +212,6 @@ public class SchoolManagementService {
shows.delete(showId); shows.delete(showId);
} }
public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {}
public synchronized SchoolSnapshot exportSchool(long id) { public synchronized SchoolSnapshot exportSchool(long id) {
return SchoolSnapshot.of(school(id)); return SchoolSnapshot.of(school(id));

View File

@@ -18,7 +18,7 @@ public final class ShowConfiguration {
public static void validate(List<DayPart> parts, Map<DanceGroupType, Integer> counts) { public static void validate(List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
if (parts == null || parts.size() > 6 || parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null) 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()) || 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 (counts == null) invalidCounts();
if (parts.isEmpty() && counts.isEmpty()) return; if (parts.isEmpty() && counts.isEmpty()) return;
if (counts.size() != DanceGroupType.values().length if (counts.size() != DanceGroupType.values().length
@@ -27,7 +27,7 @@ public final class ShowConfiguration {
} }
private static void invalidCounts() { 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."); "Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are retained when dayparts are removed.");
} }
} }

View File

@@ -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<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {}

View File

@@ -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) {}

View File

@@ -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<String> groupIds) {}

View File

@@ -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<Part> dayParts, Map<DanceGroupType, Integer> appearances) {}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.service.model;
import java.time.LocalDate;
public record Person(String id, String name, LocalDate birthDate) {}

View File

@@ -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.*;
import nl.agilecc.agiledancers.model.specifications.*; import nl.agilecc.agiledancers.model.specifications.*;
import java.time.LocalDate; import java.time.LocalDate;
@@ -8,13 +11,6 @@ import java.util.*;
/** Version 2 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<Person> dancers, public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<Person> dancers,
List<Trainer> coaches, List<Group> groups, List<Performance> shows) { List<Trainer> coaches, List<Group> groups, List<Performance> 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<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {}
public record Part(LocalDate date, DayPartType type, List<String> groupIds) {}
public record Performance(String id, String name, List<Part> dayParts, Map<DanceGroupType, Integer> appearances) {}
public static SchoolSnapshot of(DanceSchool s) { public static SchoolSnapshot of(DanceSchool s) {
Map<Dancer, String> dancers = ids(s.getDancers()); Map<Dancer, String> dancers = ids(s.getDancers());
Map<Coach, String> coaches = ids(s.getCoaches()); Map<Coach, String> 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(); DanceSchool school = DanceSchool.builder().key(key).name(name).build();
Map<String, Dancer> dancerMap = new LinkedHashMap<>(); Map<String, Dancer> dancerMap = new LinkedHashMap<>();
for (Person d : list(dancers, "dancers")) { for (Person d : list(dancers, "dancers")) {
check(d != null, "Lege danser"); text(d.name, "dansernaam"); check(d != null, "Lege danser"); text(d.name(), "dansernaam");
check(d.birthDate != null && !d.birthDate.isAfter(LocalDate.now()), "Ongeldige geboortedatum voor " + d.name); 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(); Dancer dancer = Dancer.builder().name(d.name()).birthDate(d.birthDate()).build();
// Assign temporary distinct IDs before inserting entities into sets. // Assign temporary distinct IDs before inserting entities into sets.
dancer.setId((long) dancerMap.size()); dancer.setId((long) dancerMap.size());
put(dancerMap, d.id, dancer); school.addDancer(dancer); put(dancerMap, d.id(), dancer); school.addDancer(dancer);
} }
Map<String, Coach> coachMap = new LinkedHashMap<>(); Map<String, Coach> coachMap = new LinkedHashMap<>();
for (Trainer c : list(coaches, "coaches")) { for (Trainer c : list(coaches, "coaches")) {
check(c != null, "Lege coach"); text(c.name, "coachnaam"); check(c != null, "Lege coach"); text(c.name(), "coachnaam");
Coach coach = Coach.builder().name(c.name).build(); coach.setId((long) coachMap.size()); Coach coach = Coach.builder().name(c.name()).build(); coach.setId((long) coachMap.size());
put(coachMap, c.id, coach); school.addCoach(coach); put(coachMap, c.id(), coach); school.addCoach(coach);
} }
Map<String, DanceGroup> groupMap = new LinkedHashMap<>(); Map<String, DanceGroup> groupMap = new LinkedHashMap<>();
for (Group g : list(groups, "groups")) { for (Group g : list(groups, "groups")) {
check(g != null, "Lege groep"); text(g.category, "groepsnaam"); check(g != null, "Lege groep"); text(g.category(), "groepsnaam");
check(g.type != null && g.startAge != null && g.endAge != null && g.startAge >= 0 && g.endAge >= g.startAge, check(g.type() != null && g.startAge() != null && g.endAge() != null && g.startAge() >= 0 && g.endAge() >= g.startAge(),
"Ongeldig type of leeftijdsbereik voor groep " + g.category); "Ongeldig type of leeftijdsbereik voor groep " + g.category());
DanceGroup group = DanceGroup.builder().category(g.category).type(g.type).startAge(g.startAge).endAge(g.endAge) 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(); .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"); check(!group.getStyles().contains(null), "Ongeldige dansstijl");
group.setId((long) groupMap.size()); 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")); 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); put(groupMap, g.id(), group); school.addDanceGroup(group);
} }
Set<String> showIds = new HashSet<>(); Set<String> showIds = new HashSet<>();
for (Performance show : list(shows, "shows")) { for (Performance show : list(shows, "shows")) {
check(show != null, "Lege show"); text(show.id, "show-id"); 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(showIds.add(show.id()), "Dubbele show-id " + show.id()); text(show.name(), "shownaam");
List<DayPart> parts = new ArrayList<>(); Set<String> types = new HashSet<>(); List<DayPart> parts = new ArrayList<>(); Set<String> types = new HashSet<>();
for (Part part : list(show.dayParts, "dayParts")) { 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); 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() 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()); .map(id -> ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep")).toList()).build());
} }
ShowConfiguration.validate(parts, show.appearances); ShowConfiguration.validate(parts, show.appearances());
Show entity = Show.builder().name(show.name).dayParts(parts).appearances(Map.copyOf(show.appearances)).build(); Show entity = Show.builder().name(show.name()).dayParts(parts).appearances(Map.copyOf(show.appearances())).build();
entity.setId((long) showIds.size()); school.addShow(entity); entity.setId((long) showIds.size()); school.addShow(entity);
} }
return school; return school;
@@ -92,5 +88,5 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<
} }
private static <T> void put(Map<String, T> map, String id, T value) { text(id, "id"); check(map.putIfAbsent(id, value) == null, "Dubbele id " + id); } private static <T> void put(Map<String, T> map, String id, T value) { text(id, "id"); check(map.putIfAbsent(id, value) == null, "Dubbele id " + id); }
private static <T> T ref(Map<String,T> map, String id, String error) { T value = map.get(id); check(value != null, error); return value; } private static <T> T ref(Map<String,T> 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); }
} }

View File

@@ -0,0 +1,3 @@
package nl.agilecc.agiledancers.service.model;
public record Trainer(String id, String name) {}

View File

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

View File

@@ -1,4 +1,8 @@
package nl.agilecc.agiledancers.solver; 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.Loader;
import com.google.ortools.sat.*; import com.google.ortools.sat.*;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
@@ -7,14 +11,14 @@ import java.util.*;
@Singleton @Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine { public class OrToolsSchedulingEngine implements SchedulingEngine {
@Override @Override
public SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input) { public SchedulingResult solve(SchedulingInput input) {
Loader.loadNativeLibraries(); Loader.loadNativeLibraries();
CpModel model = new CpModel(); 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]; BoolVar[][] assigned = new BoolVar[groups][n];
for (int g = 0; g < groups; g++) { for (int g = 0; g < groups; g++) {
for (int p = 0; p < n; p++) assigned[g][p] = model.newBoolVar("g" + g + "p" + p); 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]; IntVar[] loads = new IntVar[n];
for (int p = 0; p < n; p++) { for (int p = 0; p < n; p++) {
@@ -35,7 +39,7 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0); solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
CpSolverStatus status = solver.solve(model); CpSolverStatus status = solver.solve(model);
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) 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 INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
case UNKNOWN -> "The 30-minute limit was reached without a valid result. 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."; default -> "Solver failed (" + status + "). Existing assignments were preserved.";
@@ -43,9 +47,9 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
List<List<Long>> parts = new ArrayList<>(); List<List<Long>> parts = new ArrayList<>();
for (int p = 0; p < n; p++) { for (int p = 0; p < n; p++) {
List<Long> ids = new ArrayList<>(); List<Long> 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)); parts.add(List.copyOf(ids));
} }
return new SchoolManagementService.SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts)); return new SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));
} }
} }

View File

@@ -1,10 +1,10 @@
package nl.agilecc.agiledancers; package nl.agilecc.agiledancers;
import com.tngtech.archunit.core.importer.ImportOption; 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.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture; 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 ROOT = "nl.agilecc.agiledancers";
private static final String API_MODEL = ROOT + ".rest.apimodel"; 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. // API model classes and records use the Request, Response, View, Dto or DTO suffix.
@ArchTest @ArchTest
static final ArchRule api_models_reside_in_the_api_model_package = classes() static final ArchRule api_models_reside_in_the_api_model_package = classes()

View File

@@ -1,5 +1,8 @@
package nl.agilecc.agiledancers; 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.*;
import nl.agilecc.agiledancers.model.specifications.*; import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*; import nl.agilecc.agiledancers.service.*;
@@ -80,11 +83,11 @@ class SchedulingTest {
service.endScheduling(show.getId()); service.endScheduling(show.getId());
var fresh = service.beginScheduling(school, show.getId()); var fresh = service.beginScheduling(school, show.getId());
assertThrows(ManagementException.class, () -> service.finishScheduling(school, fresh, 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()); assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
// Feasible results are accepted without requiring proof of optimality. // Feasible results are accepted without requiring proof of optimality.
var valid = new OrToolsSchedulingEngine().solve(fresh); 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()); service.endScheduling(show.getId());
} }
@Test void staleDaypartCountChangesAndDeletedShowsAreRejected() { @Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {

View File

@@ -3,9 +3,7 @@ package nl.agilecc.agiledancers;
import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.ValidatableResponse; import io.restassured.response.ValidatableResponse;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Map; import java.util.Map;
import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*; import static org.hamcrest.Matchers.*;
@@ -171,7 +169,7 @@ class SchoolManagementResourceTest {
String exported = given().get(school + "/export").then().statusCode(200).extract().asString(); 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)) given().contentType("application/json").body(exported).post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
.then().statusCode(200); .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); } } finally { given().delete(school); }
} }

View File

@@ -1,5 +1,6 @@
package nl.agilecc.agiledancers; package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.service.*; import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.model.specifications.*; import nl.agilecc.agiledancers.model.specifications.*;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -9,12 +10,12 @@ import static org.junit.jupiter.api.Assertions.*;
class SchoolSnapshotTest { class SchoolSnapshotTest {
private SchoolSnapshot complete() { private SchoolSnapshot complete() {
return new SchoolSnapshot(2, 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 Person("a", "Sam", LocalDate.of(2010,1,1))),
List.of(new SchoolSnapshot.Trainer("c", "Alex")), List.of(new Trainer("c", "Alex")),
List.of(new SchoolSnapshot.Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"), List.of(new Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"),
new SchoolSnapshot.Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")), new Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
List.of(new SchoolSnapshot.Performance("s", "Show", List.of(new Performance("s", "Show",
List.of(new SchoolSnapshot.Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1)))); List.of(new Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1))));
} }
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() { @Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
var snapshot = complete(); var snapshot = complete();

View File

@@ -1,5 +1,6 @@
package nl.agilecc.agiledancers.service; package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.model.*; import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*; import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
@@ -23,7 +24,7 @@ class SchedulingServiceTest {
var before = schools.exportSchool(school); var before = schools.exportSchool(school);
for (SchedulingEngine failure : List.<SchedulingEngine>of( for (SchedulingEngine failure : List.<SchedulingEngine>of(
input -> { throw new IllegalStateException("Native failure"); }, 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)) { input -> null)) {
scheduling.engine = failure; scheduling.engine = failure;
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId())); assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()));
@@ -51,7 +52,7 @@ class SchedulingServiceTest {
var first = executor.submit(() -> scheduling.schedule(school, show.getId())); var first = executor.submit(() -> scheduling.schedule(school, show.getId()));
try { try {
assertTrue(entered.await(10, TimeUnit.SECONDS)); 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()); () -> scheduling.schedule(school, show.getId())).getKind());
schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of()); schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of());
} finally { release.countDown(); } } finally { release.countDown(); }