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:
@@ -4,6 +4,7 @@ import lombok.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@@ -12,7 +13,9 @@ import java.util.List;
|
||||
public class DayPart extends AbstractEntity {
|
||||
@ToString.Include
|
||||
private final DayPartType type;
|
||||
private final LocalDate date;
|
||||
@ToString.Include
|
||||
@Setter
|
||||
private List<DanceGroup> danceGroups;
|
||||
|
||||
public void removeDanceGroup(DanceGroup group) {
|
||||
|
||||
@@ -3,7 +3,8 @@ package nl.agilecc.agiledancers.model;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@@ -13,7 +14,8 @@ public class Show extends AbstractEntity {
|
||||
@Setter
|
||||
private String name;
|
||||
@Setter
|
||||
private LocalDate date;
|
||||
private Map<DanceGroupType, Integer> appearances;
|
||||
@ToString.Include
|
||||
private final List<DayPart> dayParts;
|
||||
@Setter
|
||||
private List<DayPart> dayParts;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
package nl.agilecc.agiledancers.model.specifications;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
public enum DanceGroupType {
|
||||
SHOW_CASE(3),
|
||||
PRE_EDUCATION(1), // TODO check if this is one group mentioned with different docent each time, or three different groups - and thus if they are required 1 day or 3
|
||||
SELECTION(2),
|
||||
REGULAR(2),
|
||||
PRE_SCHOOLER(1);
|
||||
|
||||
@Getter
|
||||
private int dayPartsRequired;
|
||||
|
||||
DanceGroupType(int dayPartsRequired) {
|
||||
this.dayPartsRequired = dayPartsRequired;
|
||||
}
|
||||
SHOW_CASE, PRE_EDUCATION, SELECTION, REGULAR, PRE_SCHOOLER
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.quarkus.jackson.ObjectMapperCustomizer;
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
@Singleton
|
||||
public class JacksonConfiguration implements ObjectMapperCustomizer {
|
||||
@Override
|
||||
public void customize(ObjectMapper mapper) {
|
||||
mapper.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public class ManagementExceptionMapper implements ExceptionMapper<ManagementExce
|
||||
int status = switch (exception.getKind()) {
|
||||
case NOT_FOUND -> 404;
|
||||
case INVALID_INPUT -> 400;
|
||||
case CONFLICT -> 409;
|
||||
};
|
||||
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE)
|
||||
.entity(new ErrorView(exception.getMessage())).build();
|
||||
|
||||
@@ -6,6 +6,9 @@ import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
import nl.agilecc.agiledancers.service.SchedulingService;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -19,6 +22,9 @@ public class ShowResource {
|
||||
@Inject
|
||||
SchoolManagementService service;
|
||||
|
||||
@Inject
|
||||
SchedulingService scheduling;
|
||||
|
||||
@PathParam("schoolId")
|
||||
long schoolId;
|
||||
|
||||
@@ -35,7 +41,7 @@ public class ShowResource {
|
||||
|
||||
@POST
|
||||
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
|
||||
var entity = service.createShow(schoolId, request.name(), request.date());
|
||||
var entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
|
||||
.entity(ShowView.of(entity)).build();
|
||||
}
|
||||
@@ -43,7 +49,21 @@ public class ShowResource {
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public ShowView update(@PathParam("id") long id, @NotNull @Valid ShowRequest request) {
|
||||
return ShowView.of(service.updateShow(schoolId, id, request.name(), request.date()));
|
||||
return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances()));
|
||||
}
|
||||
|
||||
private static List<DayPart> parts(ShowRequest request) {
|
||||
return request.dayParts().stream().map(p -> DayPart.builder()
|
||||
.date(p.date()).type(p.type()).danceGroups(List.of()).build()).toList();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{id}/schedule")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public SchedulingView schedule(@PathParam("id") long id) {
|
||||
var result = scheduling.schedule(schoolId, id);
|
||||
return new SchedulingView(result.optimal(), result.optimal()
|
||||
? "Optimal balance" : "Valid result; optimal balance not proven");
|
||||
}
|
||||
|
||||
@DELETE
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
import java.time.LocalDate;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
public record DayPartRequest(@NotNull LocalDate date, @NotNull DayPartType type) {}
|
||||
@@ -1,6 +1,5 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
import java.util.List;
|
||||
|
||||
public record DayPartView(DayPartType type, List<Long> danceGroupIds) {}
|
||||
import java.time.LocalDate;
|
||||
public record DayPartView(LocalDate date, DayPartType type, List<Long> danceGroupIds) {}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
public record SchedulingView(boolean optimal, String message) {}
|
||||
@@ -1,7 +1,7 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record ShowRequest(@NotBlank String name, @NotNull LocalDate date) {}
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.util.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
public record ShowRequest(@NotBlank String name, @NotNull List<@NotNull @Valid DayPartRequest> dayParts,
|
||||
Map<DanceGroupType, Integer> appearances) {}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record ShowView(long id, String name, LocalDate date, List<DayPartView> dayParts) {
|
||||
import nl.agilecc.agiledancers.model.*;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import java.util.*;
|
||||
public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) {
|
||||
public static ShowView of(Show show) {
|
||||
return new ShowView(show.getId(), show.getName(), show.getDate(), show.getDayParts().stream()
|
||||
.map(dayPart -> new DayPartView(dayPart.getType(), dayPart.getDanceGroups().stream()
|
||||
.map(DanceGroup::getId).toList())).toList());
|
||||
return new ShowView(show.getId(), show.getName(), show.getDayParts().stream()
|
||||
.map(p -> new DayPartView(p.getDate(), p.getType(), p.getDanceGroups().stream().map(DanceGroup::getId).toList())).toList(), show.getAppearances());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
public class ManagementException extends RuntimeException {
|
||||
public enum Kind { NOT_FOUND, INVALID_INPUT }
|
||||
public enum Kind { NOT_FOUND, INVALID_INPUT, CONFLICT }
|
||||
|
||||
private final Kind kind;
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
public interface SchedulingEngine {
|
||||
SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
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) {
|
||||
var input = schools.beginScheduling(schoolId, showId);
|
||||
try {
|
||||
var result = engine.solve(input);
|
||||
schools.finishScheduling(schoolId, input, result);
|
||||
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.");
|
||||
} finally { schools.endScheduling(showId); }
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,52 @@ public class SchoolManagementService {
|
||||
private final DanceGroupRepository groups = new DanceGroupRepository();
|
||||
private final ShowRepository shows = new ShowRepository();
|
||||
|
||||
private final Map<Long, Long> revisions = new HashMap<>();
|
||||
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.");
|
||||
List<DanceGroup> current = groups(schoolId);
|
||||
if (current.isEmpty() || show.getDayParts().isEmpty()) throw new ManagementException(INVALID_INPUT, "Scheduling requires at least one school group and one daypart.");
|
||||
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());
|
||||
}
|
||||
public synchronized void finishScheduling(long schoolId, SchedulingInput input, SchedulingResult result) {
|
||||
Show show;
|
||||
try { show = show(schoolId, input.showId()); }
|
||||
catch (ManagementException e) { throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again."); }
|
||||
if (revisions.getOrDefault(input.showId(), 0L) != input.revision())
|
||||
throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again.");
|
||||
// Validate the complete solver output before mutating any assignments.
|
||||
if (result == null || result.assignments() == null || result.assignments().size() != input.dayParts())
|
||||
throw new ManagementException(INVALID_INPUT, "Solver returned an invalid result. Existing assignments were preserved.");
|
||||
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))
|
||||
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))
|
||||
throw new ManagementException(INVALID_INPUT, "Solver did not satisfy every required appearance.");
|
||||
}
|
||||
List<DayPart> replacement = new ArrayList<>();
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
DayPart old = show.getDayParts().get(p);
|
||||
replacement.add(DayPart.builder().date(old.getDate()).type(old.getType())
|
||||
.danceGroups(result.assignments().get(p).stream().map(current::get).toList()).build());
|
||||
}
|
||||
show.setDayParts(List.copyOf(replacement));
|
||||
}
|
||||
public synchronized void endScheduling(long showId) { running.remove(showId); }
|
||||
|
||||
public synchronized List<DanceSchool> schools() {
|
||||
return schools.findAll();
|
||||
}
|
||||
@@ -89,6 +135,7 @@ public class SchoolManagementService {
|
||||
DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type)
|
||||
.startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build());
|
||||
school.addDanceGroup(group);
|
||||
school.getShows().forEach(this::touch);
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -97,6 +144,7 @@ public class SchoolManagementService {
|
||||
DanceGroup group = group(schoolId, groupId);
|
||||
validateAgeRange(startAge, endAge);
|
||||
group.setCategory(category);
|
||||
if (group.getType() != type) school(schoolId).getShows().forEach(this::touch);
|
||||
group.setType(type);
|
||||
group.setStartAge(startAge);
|
||||
group.setEndAge(endAge);
|
||||
@@ -107,6 +155,7 @@ public class SchoolManagementService {
|
||||
public synchronized void deleteGroup(long schoolId, long groupId) {
|
||||
DanceGroup group = group(schoolId, groupId);
|
||||
school(schoolId).getShows().forEach(show -> show.getDayParts().forEach(part -> part.removeDanceGroup(group)));
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
school(schoolId).removeDanceGroup(group);
|
||||
groups.delete(groupId);
|
||||
}
|
||||
@@ -129,22 +178,34 @@ public class SchoolManagementService {
|
||||
return member(school(schoolId).getShows(), showId, "Show");
|
||||
}
|
||||
|
||||
public synchronized Show createShow(long schoolId, String name, LocalDate date) {
|
||||
public synchronized Show createShow(long schoolId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
||||
DanceSchool school = school(schoolId);
|
||||
List<DayPart> dayParts = Arrays.stream(DayPartType.values())
|
||||
.map(type -> DayPart.builder().type(type).danceGroups(List.of()).build()).toList();
|
||||
Show show = shows.add(Show.builder().name(name).date(date).dayParts(dayParts).build());
|
||||
if (counts == null) counts = ShowConfiguration.defaults(parts.size());
|
||||
ShowConfiguration.validate(parts, counts);
|
||||
Show show = shows.add(Show.builder().name(name).dayParts(List.copyOf(parts)).appearances(Map.copyOf(counts)).build());
|
||||
school.addShow(show);
|
||||
return show;
|
||||
}
|
||||
|
||||
public synchronized Show updateShow(long schoolId, long showId, String name, LocalDate date) {
|
||||
public synchronized Show updateShow(long schoolId, long showId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
|
||||
Show show = show(schoolId, showId);
|
||||
if (counts == null) counts = show.getAppearances().isEmpty() ? ShowConfiguration.defaults(parts.size()) : show.getAppearances();
|
||||
ShowConfiguration.validate(parts, counts);
|
||||
boolean changed = !counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
||||
List<DayPart> retained = parts.stream().map(p -> DayPart.builder().date(p.getDate()).type(p.getType())
|
||||
.danceGroups(show.getDayParts().stream().filter(old -> old.getDate().equals(p.getDate()) && old.getType() == p.getType())
|
||||
.findFirst().map(DayPart::getDanceGroups).orElse(List.of())).build()).toList();
|
||||
show.setName(name);
|
||||
show.setDate(date);
|
||||
show.setDayParts(retained);
|
||||
show.setAppearances(Map.copyOf(counts));
|
||||
if (changed) touch(show);
|
||||
return show;
|
||||
}
|
||||
|
||||
private static List<String> partKeys(List<DayPart> parts) {
|
||||
return parts.stream().map(p -> p.getDate() + ":" + p.getType()).toList();
|
||||
}
|
||||
|
||||
public synchronized void deleteShow(long schoolId, long showId) {
|
||||
Show show = show(schoolId, showId);
|
||||
school(schoolId).removeShow(show);
|
||||
@@ -191,8 +252,8 @@ public class SchoolManagementService {
|
||||
}
|
||||
List<Show> rebuiltShows = new ArrayList<>();
|
||||
for (Show show : importedShows) {
|
||||
Show rebuilt = Show.builder().name(show.getName()).date(show.getDate()).dayParts(show.getDayParts().stream()
|
||||
.map(p -> DayPart.builder().type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build();
|
||||
Show rebuilt = Show.builder().name(show.getName()).appearances(show.getAppearances()).dayParts(show.getDayParts().stream()
|
||||
.map(p -> DayPart.builder().date(p.getDate()).type(p.getType()).danceGroups(p.getDanceGroups().stream().map(replacements::get).toList()).build()).toList()).build();
|
||||
rebuilt.setId(shows.nextId()); rebuiltShows.add(rebuilt);
|
||||
}
|
||||
DanceSchool imported = DanceSchool.builder().key(staged.getKey()).name(staged.getName())
|
||||
|
||||
@@ -5,31 +5,31 @@ import nl.agilecc.agiledancers.model.specifications.*;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
/** Version 1 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,
|
||||
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(DayPartType type, List<String> groupIds) {}
|
||||
public record Performance(String id, String name, LocalDate date, List<Part> dayParts) {}
|
||||
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());
|
||||
Map<DanceGroup, String> groups = ids(s.getDanceGroups());
|
||||
Map<Show, String> shows = ids(s.getShows());
|
||||
return new SchoolSnapshot(1, s.getKey(), s.getName(),
|
||||
return new SchoolSnapshot(2, s.getKey(), s.getName(),
|
||||
s.getDancers().stream().map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate())).toList(),
|
||||
s.getCoaches().stream().map(c -> new Trainer(coaches.get(c), c.getName())).toList(),
|
||||
s.getDanceGroups().stream().map(g -> new Group(groups.get(g), g.getCategory(), g.getType(),
|
||||
g.getStartAge(), g.getEndAge(), List.copyOf(g.getStyles()),
|
||||
g.getDancers().stream().map(dancers::get).toList(),
|
||||
g.getCoach() == null ? null : coaches.get(g.getCoach()))).toList(),
|
||||
s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(), show.getDate(),
|
||||
show.getDayParts().stream().map(p -> new Part(p.getType(),
|
||||
p.getDanceGroups().stream().map(groups::get).toList())).toList())).toList());
|
||||
s.getShows().stream().map(show -> new Performance(shows.get(show), show.getName(),
|
||||
show.getDayParts().stream().map(p -> new Part(p.getDate(), p.getType(),
|
||||
p.getDanceGroups().stream().map(groups::get).toList())).toList(), show.getAppearances())).toList());
|
||||
}
|
||||
|
||||
private static <T> Map<T, String> ids(Collection<T> values) {
|
||||
@@ -39,7 +39,7 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<
|
||||
}
|
||||
|
||||
public DanceSchool restore() {
|
||||
check(Objects.equals(formatVersion, 1), "Niet-ondersteunde formatVersion: " + formatVersion);
|
||||
check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
|
||||
check(key != null, "key ontbreekt"); text(name, "schoolnaam");
|
||||
DanceSchool school = DanceSchool.builder().key(key).name(name).build();
|
||||
Map<String, Dancer> dancerMap = new LinkedHashMap<>();
|
||||
@@ -73,14 +73,14 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<
|
||||
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.date != null, "Showdatum ontbreekt voor " + show.name);
|
||||
List<DayPart> parts = new ArrayList<>(); Set<DayPartType> types = new HashSet<>();
|
||||
List<DayPart> parts = new ArrayList<>(); Set<String> types = new HashSet<>();
|
||||
for (Part part : list(show.dayParts, "dayParts")) {
|
||||
check(part != null && part.type != null && types.add(part.type), "Ongeldig of dubbel dagdeel voor " + show.name);
|
||||
parts.add(DayPart.builder().type(part.type).danceGroups(unique(part.groupIds, "groupIds").stream()
|
||||
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());
|
||||
}
|
||||
Show entity = Show.builder().name(show.name).date(show.date).dayParts(parts).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;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import java.util.*;
|
||||
|
||||
public final class ShowConfiguration {
|
||||
private ShowConfiguration() {}
|
||||
public static Map<DanceGroupType, Integer> defaults(int n) {
|
||||
Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
|
||||
if (n > 0) for (var type : DanceGroupType.values()) counts.put(type, switch (type) {
|
||||
case PRE_SCHOOLER -> 1;
|
||||
case SHOW_CASE, PRE_EDUCATION -> n;
|
||||
default -> Math.min(2, n);
|
||||
});
|
||||
return 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)
|
||||
|| 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.");
|
||||
if (counts == null) invalidCounts();
|
||||
if (parts.isEmpty() && counts.isEmpty()) return;
|
||||
if (counts.size() != DanceGroupType.values().length
|
||||
|| Arrays.stream(DanceGroupType.values()).anyMatch(t -> counts.get(t) == null || counts.get(t) < 1
|
||||
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts();
|
||||
}
|
||||
|
||||
private static void invalidCounts() {
|
||||
throw new ManagementException(ManagementException.Kind.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,51 @@
|
||||
package nl.agilecc.agiledancers.solver;
|
||||
import com.google.ortools.Loader;
|
||||
import com.google.ortools.sat.*;
|
||||
import jakarta.inject.Singleton;
|
||||
import nl.agilecc.agiledancers.service.*;
|
||||
import java.util.*;
|
||||
@Singleton
|
||||
public class OrToolsSchedulingEngine implements SchedulingEngine {
|
||||
@Override
|
||||
public SchoolManagementService.SchedulingResult solve(SchoolManagementService.SchedulingInput input) {
|
||||
Loader.loadNativeLibraries();
|
||||
CpModel model = new CpModel();
|
||||
int n = input.dayParts(), groups = input.groupIds().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));
|
||||
}
|
||||
IntVar[] loads = new IntVar[n];
|
||||
for (int p = 0; p < n; p++) {
|
||||
BoolVar[] column = new BoolVar[groups];
|
||||
for (int g = 0; g < groups; g++) column[g] = assigned[g][p];
|
||||
loads[p] = model.newIntVar(0, groups, "load" + p);
|
||||
model.addEquality(loads[p], LinearExpr.sum(column));
|
||||
}
|
||||
IntVar max = model.newIntVar(0, groups, "max"), min = model.newIntVar(0, groups, "min");
|
||||
model.addMaxEquality(max, loads);
|
||||
model.addMinEquality(min, loads);
|
||||
model.minimize(LinearExpr.weightedSum(new IntVar[]{max, min}, new long[]{1, -1}));
|
||||
CpSolver solver = new CpSolver();
|
||||
solver.getParameters().setMaxTimeInSeconds(1800);
|
||||
// Quarkus owns process shutdown; do not install a native SIGINT handler.
|
||||
solver.getParameters().setCatchSigintSignal(false);
|
||||
// Single-worker search with a fixed seed makes identical inputs repeatable.
|
||||
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) {
|
||||
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.";
|
||||
});
|
||||
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));
|
||||
parts.add(List.copyOf(ids));
|
||||
}
|
||||
return new SchoolManagementService.SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
quarkus.application.name=agiledancers
|
||||
quarkus.http.host=0.0.0.0
|
||||
|
||||
# Scheduling is synchronous with a 30-minute solver limit.
|
||||
quarkus.http.idle-timeout=31M
|
||||
quarkus.jackson.fail-on-unknown-properties=true
|
||||
|
||||
@@ -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