ortools review on some design issues and fixed flaky test
All checks were successful
Build, test and publish / verify (push) Successful in 1m34s
All checks were successful
Build, test and publish / verify (push) Successful in 1m34s
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
public record GroupRequirement(long groupId, int appearances) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
public enum ManagementExceptionKind { NOT_FOUND, INVALID_INPUT, CONFLICT }
|
||||
@@ -1,4 +0,0 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
public interface SchedulingEngine {
|
||||
SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Long> 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<Long> groupIds, List<Integer> required) {}
|
||||
public record SchedulingResult(boolean optimal, List<List<Long>> 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<Long, DanceGroup> 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<DayPart> 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));
|
||||
|
||||
@@ -18,7 +18,7 @@ public final class ShowConfiguration {
|
||||
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)
|
||||
|| 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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package nl.agilecc.agiledancers.service.model;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record Person(String id, String name, LocalDate birthDate) {}
|
||||
@@ -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<Person> dancers,
|
||||
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) {
|
||||
Map<Dancer, String> dancers = ids(s.getDancers());
|
||||
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();
|
||||
Map<String, Dancer> 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<String, Coach> 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<String, DanceGroup> 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<String> 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<DayPart> parts = new ArrayList<>(); Set<String> 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 <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 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); }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package nl.agilecc.agiledancers.service.model;
|
||||
|
||||
public record Trainer(String id, String name) {}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<List<Long>> parts = new ArrayList<>();
|
||||
for (int p = 0; p < n; p++) {
|
||||
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));
|
||||
}
|
||||
return new SchoolManagementService.SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));
|
||||
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.<SchedulingEngine>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(); }
|
||||
|
||||
Reference in New Issue
Block a user