S9 OR Tools basic scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import nl.agilecc.agiledancers.model.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.*;
|
||||
import nl.agilecc.agiledancers.service.*;
|
||||
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SchedulingTest {
|
||||
private final SchoolManagementService service = new SchoolManagementService();
|
||||
private final long school = service.createSchool("Studio").getId();
|
||||
private List<DayPart> parts(int n) {
|
||||
return java.util.stream.IntStream.range(0, n).mapToObj(i -> DayPart.builder()
|
||||
.date(LocalDate.of(2027, 1, 1).plusDays(i)).type(DayPartType.AFTERNOON).danceGroups(List.of()).build()).toList();
|
||||
}
|
||||
private DanceGroup group(DanceGroupType type) {
|
||||
return service.createGroup(school, "Group", type, 0, 99, Set.of());
|
||||
}
|
||||
private void run(Show show) {
|
||||
var input = service.beginScheduling(school, show.getId());
|
||||
try { service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input)); }
|
||||
finally { service.endScheduling(show.getId()); }
|
||||
}
|
||||
@Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
|
||||
group(DanceGroupType.PRE_SCHOOLER);
|
||||
var show = service.createShow(school, "Show", parts(6), null);
|
||||
run(show);
|
||||
var loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics();
|
||||
assertEquals(1, loads.getSum()); assertEquals(0, loads.getMin()); assertEquals(1, loads.getMax());
|
||||
for (var type : DanceGroupType.values()) group(type);
|
||||
run(show);
|
||||
for (var group : service.groups(school)) assertEquals(show.getAppearances().get(group.getType()).longValue(),
|
||||
show.getDayParts().stream().filter(p -> p.getDanceGroups().contains(group)).count());
|
||||
loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics();
|
||||
assertTrue(loads.getMax() - loads.getMin() <= 1);
|
||||
var snapshot = service.exportSchool(school);
|
||||
assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore()));
|
||||
}
|
||||
@Test void defaultsInitialConfigurationAndPreservationOnEdits() {
|
||||
var show = service.createShow(school, "Show", List.of(), null);
|
||||
assertTrue(show.getAppearances().isEmpty());
|
||||
service.updateShow(school, show.getId(), "Show", parts(3), null);
|
||||
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
|
||||
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
|
||||
var counts = new EnumMap<>(show.getAppearances()); counts.put(DanceGroupType.PRE_SCHOOLER, 2);
|
||||
service.updateShow(school, show.getId(), "Show", parts(4), counts);
|
||||
assertEquals(counts, show.getAppearances());
|
||||
assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null));
|
||||
assertEquals(4, show.getDayParts().size());
|
||||
service.updateShow(school, show.getId(), "Show", List.of(), null);
|
||||
assertEquals(counts, show.getAppearances());
|
||||
service.updateShow(school, show.getId(), "Show", parts(4), null);
|
||||
assertEquals(counts, show.getAppearances());
|
||||
}
|
||||
@Test void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
|
||||
var duplicate = List.of(parts(1).getFirst(), parts(1).getFirst());
|
||||
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", duplicate, null));
|
||||
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null));
|
||||
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of()));
|
||||
var counts = ShowConfiguration.defaults(2); counts.put(DanceGroupType.REGULAR, 3);
|
||||
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
|
||||
counts.put(DanceGroupType.REGULAR, 0);
|
||||
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
|
||||
}
|
||||
@Test void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
|
||||
var group = group(DanceGroupType.REGULAR);
|
||||
var show = service.createShow(school, "Show", parts(2), null); run(show);
|
||||
var before = show.getDayParts().stream().map(DayPart::getDanceGroups).toList();
|
||||
var input = service.beginScheduling(school, show.getId());
|
||||
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
|
||||
var result = new OrToolsSchedulingEngine().solve(input);
|
||||
service.updateGroup(school, group.getId(), "Renamed", group.getType(), 0, 99, Set.of());
|
||||
assertDoesNotThrow(() -> service.finishScheduling(school, input, result));
|
||||
group(DanceGroupType.PRE_SCHOOLER);
|
||||
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
|
||||
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
|
||||
service.endScheduling(show.getId());
|
||||
var fresh = service.beginScheduling(school, show.getId());
|
||||
assertThrows(ManagementException.class, () -> service.finishScheduling(school, fresh,
|
||||
new SchoolManagementService.SchedulingResult(false, List.of(List.of(), List.of()))));
|
||||
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
|
||||
// Feasible results are accepted without requiring proof of optimality.
|
||||
var valid = new OrToolsSchedulingEngine().solve(fresh);
|
||||
assertDoesNotThrow(() -> service.finishScheduling(school, fresh, new SchoolManagementService.SchedulingResult(false, valid.assignments())));
|
||||
service.endScheduling(show.getId());
|
||||
}
|
||||
@Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {
|
||||
group(DanceGroupType.REGULAR);
|
||||
var show = service.createShow(school, "Show", parts(2), null);
|
||||
var input = service.beginScheduling(school, show.getId());
|
||||
var result = new OrToolsSchedulingEngine().solve(input);
|
||||
service.updateShow(school, show.getId(), "Show", parts(3), null);
|
||||
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
|
||||
service.deleteShow(school, show.getId());
|
||||
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
|
||||
service.endScheduling(show.getId());
|
||||
}
|
||||
@Test void needsGroupsAndDayparts() {
|
||||
var show = service.createShow(school, "Show", parts(1), null);
|
||||
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
|
||||
group(DanceGroupType.REGULAR);
|
||||
var empty = service.createShow(school, "Empty", List.of(), null);
|
||||
assertThrows(ManagementException.class, () -> service.beginScheduling(school, empty.getId()));
|
||||
assertTrue(show.getAppearances().values().stream().allMatch(n -> n == 1));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class SchoolManagementResourceTest {
|
||||
private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
|
||||
private static final Map<String, Object> GROUP = Map.of("category", "Teens", "type", "REGULAR",
|
||||
"startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"});
|
||||
private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "date", "2027-06-12");
|
||||
private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "dayParts", new Object[]{Map.of("date", "2027-06-12", "type", "AFTERNOON")});
|
||||
|
||||
@Test
|
||||
void managesSchoolAndItsResources() {
|
||||
@@ -46,10 +46,10 @@ class SchoolManagementResourceTest {
|
||||
long showId = create(school + "/shows", SHOW);
|
||||
String show = school + "/shows/" + showId;
|
||||
given().get(show).then().statusCode(200)
|
||||
.body("dayParts.type", contains("MORNING", "AFTERNOON", "EVENING"))
|
||||
.body("dayParts.type", contains("AFTERNOON"))
|
||||
.body("dayParts.danceGroupIds", everyItem(empty()));
|
||||
put(show, Map.of("name", "Summer show", "date", "2027-07-01"))
|
||||
.statusCode(200).body("date", equalTo("2027-07-01"));
|
||||
put(show, Map.of("name", "Summer show", "dayParts", new Object[]{Map.of("date", "2027-07-01", "type", "EVENING")}))
|
||||
.statusCode(200).body("dayParts.date", contains("2027-07-01"));
|
||||
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
|
||||
given().get(school).then().statusCode(200).body("dancerIds", contains((int) dancerId))
|
||||
.body("danceGroupIds", contains((int) groupId)).body("showIds", contains((int) showId));
|
||||
@@ -152,6 +152,29 @@ class SchoolManagementResourceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void schedulesAndValidatesTheNewShowConfiguration() {
|
||||
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Scheduling school"));
|
||||
try {
|
||||
String show = school + "/shows/" + create(school + "/shows", SHOW);
|
||||
given().post(show + "/schedule").then().statusCode(400);
|
||||
long group = create(school + "/dance-groups", GROUP);
|
||||
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
|
||||
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
|
||||
String before = given().get(show).asString();
|
||||
String counts = "{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
|
||||
for (String count : new String[]{"0", "2", "1.5", "null"}) {
|
||||
String body = "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":" + counts.formatted(count) + "}";
|
||||
put(show, body).statusCode(400);
|
||||
given().get(show).then().body(equalTo(before));
|
||||
}
|
||||
String exported = given().get(school + "/export").then().statusCode(200).extract().asString();
|
||||
given().contentType("application/json").body(exported).post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
|
||||
.then().statusCode(200);
|
||||
given().get(school + "/export").then().body(equalTo(exported));
|
||||
} finally { given().delete(school); }
|
||||
}
|
||||
|
||||
private static long create(String path, Object body) {
|
||||
return given().contentType("application/json").body(body).post(path).then()
|
||||
.statusCode(201).header("Location", matchesPattern(".*/[0-9]+$"))
|
||||
|
||||
@@ -12,11 +12,11 @@ class SchoolSnapshotResourceTest {
|
||||
long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE)
|
||||
.then().statusCode(201).extract().jsonPath().getLong("id");
|
||||
String json = given().get(BASE + "/" + id + "/export").then().statusCode(200)
|
||||
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(1)).extract().asString();
|
||||
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(2)).extract().asString();
|
||||
try {
|
||||
given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200)
|
||||
.body("existingId", equalTo((int) id)).body("dancers", equalTo(0));
|
||||
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 1", "\"formatVersion\" : 2"),
|
||||
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
|
||||
json.replace("\"name\" :", "\"unknown\" :"), json + " {}",
|
||||
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) {
|
||||
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id)
|
||||
|
||||
@@ -9,12 +9,12 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SchoolSnapshotTest {
|
||||
private SchoolSnapshot complete() {
|
||||
return new SchoolSnapshot(1, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))),
|
||||
return new SchoolSnapshot(2, UUID.randomUUID(), "Studio", List.of(new SchoolSnapshot.Person("a", "Sam", LocalDate.of(2010,1,1))),
|
||||
List.of(new SchoolSnapshot.Trainer("c", "Alex")),
|
||||
List.of(new SchoolSnapshot.Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"),
|
||||
new SchoolSnapshot.Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
|
||||
List.of(new SchoolSnapshot.Performance("s", "Show", LocalDate.of(2027,1,1),
|
||||
List.of(new SchoolSnapshot.Part(DayPartType.MORNING, List.of("g", "h"))))));
|
||||
List.of(new SchoolSnapshot.Performance("s", "Show",
|
||||
List.of(new SchoolSnapshot.Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1))));
|
||||
}
|
||||
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
|
||||
var snapshot = complete();
|
||||
@@ -56,7 +56,7 @@ class SchoolSnapshotTest {
|
||||
@Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
|
||||
var service = new SchoolManagementService(); var valid = complete();
|
||||
var original = service.importSchool(valid, null);
|
||||
var bad = new SchoolSnapshot(1, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
|
||||
var bad = new SchoolSnapshot(2, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
|
||||
assertThrows(ManagementException.class, () -> service.preview(bad));
|
||||
assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId()));
|
||||
assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId()));
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.*;
|
||||
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SchedulingServiceTest {
|
||||
@Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
|
||||
var schools = new SchoolManagementService();
|
||||
long school = schools.createSchool("School").getId();
|
||||
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
|
||||
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1))
|
||||
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null);
|
||||
var scheduling = new SchedulingService();
|
||||
scheduling.schools = schools;
|
||||
scheduling.engine = new OrToolsSchedulingEngine();
|
||||
scheduling.schedule(school, show.getId());
|
||||
var before = schools.exportSchool(school);
|
||||
for (SchedulingEngine failure : List.<SchedulingEngine>of(
|
||||
input -> { throw new IllegalStateException("Native failure"); },
|
||||
input -> { throw new ManagementException(ManagementException.Kind.INVALID_INPUT, "No valid result before the limit"); },
|
||||
input -> null)) {
|
||||
scheduling.engine = failure;
|
||||
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()));
|
||||
assertEquals(before, schools.exportSchool(school));
|
||||
var next = schools.beginScheduling(school, show.getId());
|
||||
schools.endScheduling(next.showId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
|
||||
var schools = new SchoolManagementService();
|
||||
long school = schools.createSchool("School").getId();
|
||||
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
|
||||
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1))
|
||||
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null);
|
||||
var scheduling = new SchedulingService(); scheduling.schools = schools;
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
scheduling.engine = input -> {
|
||||
entered.countDown();
|
||||
try { if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout"); }
|
||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); }
|
||||
return new OrToolsSchedulingEngine().solve(input);
|
||||
};
|
||||
try (var executor = Executors.newSingleThreadExecutor()) {
|
||||
var first = executor.submit(() -> scheduling.schedule(school, show.getId()));
|
||||
try {
|
||||
assertTrue(entered.await(10, TimeUnit.SECONDS));
|
||||
assertEquals(ManagementException.Kind.CONFLICT, assertThrows(ManagementException.class,
|
||||
() -> scheduling.schedule(school, show.getId())).getKind());
|
||||
schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of());
|
||||
} finally { release.countDown(); }
|
||||
var exception = assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS));
|
||||
assertInstanceOf(ManagementException.class, exception.getCause());
|
||||
assertTrue(exception.getCause().getMessage().contains("inputs changed"));
|
||||
assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty());
|
||||
}
|
||||
scheduling.engine = new OrToolsSchedulingEngine();
|
||||
scheduling.schedule(school, show.getId());
|
||||
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user