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

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

View File

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

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

View File

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

View File

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

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

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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