S18 Added OR Tools rules for scheduling show
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s
This commit is contained in:
@@ -4,6 +4,7 @@ import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@@ -13,4 +14,7 @@ import lombok.ToString;
|
||||
public class Coach extends AbstractEntity {
|
||||
@ToString.Include
|
||||
private final String name;
|
||||
|
||||
@Setter
|
||||
private Dancer dancer;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ public class Show extends AbstractEntity {
|
||||
@Setter
|
||||
private String name;
|
||||
|
||||
@Setter
|
||||
private Integer preferredDayParts;
|
||||
|
||||
@Setter
|
||||
private boolean finalized;
|
||||
|
||||
@Setter
|
||||
private Map<DanceGroupType, Integer> appearances;
|
||||
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
public record GroupRequirement(long groupId, int appearances) {}
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
|
||||
public record GroupRequirement(
|
||||
long groupId,
|
||||
int appearances,
|
||||
boolean showcase,
|
||||
Set<Long> dancerIds,
|
||||
Set<DanceGroupStyle> styles,
|
||||
double averageAge) {
|
||||
public GroupRequirement {
|
||||
dancerIds = Set.copyOf(dancerIds);
|
||||
styles = Set.copyOf(styles);
|
||||
}
|
||||
|
||||
public GroupRequirement(final long groupId, final int appearances) {
|
||||
this(groupId, appearances, false, Set.of(), Set.of(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Shared hard-rule validation for solver results and manually edited schedules. */
|
||||
public final class ScheduleRules {
|
||||
private ScheduleRules() {}
|
||||
|
||||
public static List<String> violations(final SchedulingInput input, final List<List<Long>> parts) {
|
||||
final List<String> errors = new ArrayList<>();
|
||||
if (input.dayParts() == 0 || input.preferredDayParts() < 1 || input.preferredDayParts() > input.dayParts()) {
|
||||
errors.add("Configure dayparts and x between 1 and the number of dayparts.");
|
||||
}
|
||||
if (parts == null || parts.size() != input.dayParts() || parts.stream().anyMatch(java.util.Objects::isNull)) {
|
||||
errors.add("Assignments must contain one list for every daypart.");
|
||||
return List.copyOf(errors);
|
||||
}
|
||||
final Map<Long, GroupRequirement> groups = new HashMap<>();
|
||||
input.groups().forEach((final GroupRequirement g) -> groups.put(g.groupId(), g));
|
||||
for (int p = 0; p < parts.size(); p++) {
|
||||
validatePart(parts.get(p), groups, p, errors);
|
||||
}
|
||||
appearances(input, parts, errors);
|
||||
attendance(input, parts, errors);
|
||||
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
|
||||
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
|
||||
if (max - min > 2) {
|
||||
errors.add("Daypart performance counts differ by more than two.");
|
||||
}
|
||||
return List.copyOf(errors);
|
||||
}
|
||||
|
||||
private static void validatePart(
|
||||
final List<Long> ids, final Map<Long, GroupRequirement> groups, final int part, final List<String> errors) {
|
||||
if (new HashSet<>(ids).size() != ids.size() || !groups.keySet().containsAll(ids)) {
|
||||
errors.add("Daypart " + (part + 1) + " contains duplicate, unknown or empty groups.");
|
||||
return;
|
||||
}
|
||||
final List<GroupRequirement> ordered = ids.stream().map(groups::get).toList();
|
||||
showcases(ordered, part, errors);
|
||||
gaps(ordered, part, errors);
|
||||
}
|
||||
|
||||
private static void showcases(final List<GroupRequirement> groups, final int part, final List<String> errors) {
|
||||
final long count = groups.stream().filter(GroupRequirement::showcase).count();
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
final boolean first = groups.getFirst().showcase();
|
||||
final boolean last = groups.getLast().showcase();
|
||||
if ((count == 1 && !first && !last) || (count >= 2 && (!first || !last))) {
|
||||
errors.add("Daypart " + (part + 1) + " must place showcases at its opening/closing.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void gaps(final List<GroupRequirement> groups, final int part, final List<String> errors) {
|
||||
final Map<Long, Integer> last = new HashMap<>();
|
||||
for (int i = 0; i < groups.size(); i++) {
|
||||
for (final long dancer : groups.get(i).dancerIds()) {
|
||||
final Integer previous = last.put(dancer, i);
|
||||
if (previous != null && i - previous < 3) {
|
||||
errors.add("Daypart " + (part + 1) + ": dancer " + dancer
|
||||
+ " needs two intervening performances before group "
|
||||
+ groups.get(i).groupId() + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void appearances(
|
||||
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
|
||||
for (final GroupRequirement group : input.groups()) {
|
||||
if (parts.stream()
|
||||
.filter((final List<Long> ids) -> ids.contains(group.groupId()))
|
||||
.count()
|
||||
!= group.appearances()) {
|
||||
errors.add("Group " + group.groupId() + " requires " + group.appearances() + " dayparts.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void attendance(
|
||||
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
|
||||
final Set<Long> exempt = new HashSet<>(input.coachIds());
|
||||
input.groups().stream()
|
||||
.filter(GroupRequirement::showcase)
|
||||
.forEach((final GroupRequirement g) -> exempt.addAll(g.dancerIds()));
|
||||
final Map<Long, Set<Integer>> attendance = new HashMap<>();
|
||||
for (final GroupRequirement group : input.groups()) {
|
||||
recordAttendance(group, parts, attendance);
|
||||
}
|
||||
attendance.forEach((final Long dancer, final Set<Integer> dayparts) -> {
|
||||
if (!exempt.contains(dancer) && dayparts.size() > input.preferredDayParts()) {
|
||||
errors.add("Dancer " + dancer + " exceeds x=" + input.preferredDayParts() + " dayparts.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void recordAttendance(
|
||||
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
|
||||
for (int p = 0; p < parts.size(); p++) {
|
||||
if (parts.get(p).contains(group.groupId())) {
|
||||
for (final long dancer : group.dancerIds()) {
|
||||
attendance
|
||||
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
|
||||
.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public record SchedulingInput(long showId, long revision, int dayParts, List<GroupRequirement> groups) {
|
||||
public record SchedulingInput(
|
||||
long showId,
|
||||
long revision,
|
||||
int dayParts,
|
||||
List<GroupRequirement> groups,
|
||||
int preferredDayParts,
|
||||
Set<Long> coachIds) {
|
||||
public SchedulingInput {
|
||||
groups = List.copyOf(groups);
|
||||
coachIds = Set.copyOf(coachIds);
|
||||
}
|
||||
|
||||
public SchedulingInput(
|
||||
final long showId, final long revision, final int dayParts, final List<GroupRequirement> groups) {
|
||||
this(showId, revision, dayParts, groups, Math.min(2, dayParts), Set.of());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
|
||||
public final class VarietyScore {
|
||||
private VarietyScore() {}
|
||||
|
||||
/** Integer CP-SAT score: each equally weighted component ranges from zero to one million. */
|
||||
public static long penalty(final GroupRequirement a, final GroupRequirement b) {
|
||||
final Set<DanceGroupStyle> union = new HashSet<>(a.styles());
|
||||
union.addAll(b.styles());
|
||||
final Set<DanceGroupStyle> shared = new HashSet<>(a.styles());
|
||||
shared.retainAll(b.styles());
|
||||
final double style = union.isEmpty() ? 0 : (double) shared.size() / union.size();
|
||||
final double age = Math.max(0, 1 - Math.abs(a.averageAge() - b.averageAge()) / 5);
|
||||
return Math.round(1_000_000 * style) + Math.round(1_000_000 * age);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.CoachRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
|
||||
import nl.agilecc.agiledancers.service.CoachLinks;
|
||||
|
||||
@Path("/api/dance-schools/{schoolId}/coaches")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class CoachResource {
|
||||
private @Inject CoachLinks links;
|
||||
private @PathParam("schoolId") long schoolId;
|
||||
|
||||
@GET
|
||||
public List<CoachView> list() {
|
||||
return links.list(schoolId).stream().map(CoachView::of).toList();
|
||||
}
|
||||
|
||||
@POST
|
||||
public CoachView create(final @NotNull @Valid CoachRequest request) {
|
||||
return CoachView.of(links.link(schoolId, null, request.dancerId()));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) {
|
||||
return CoachView.of(links.link(schoolId, id, request.dancerId()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}/dancer")
|
||||
public void unlink(final @PathParam("id") long id) {
|
||||
links.unlink(schoolId, id);
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,13 @@ import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
||||
import nl.agilecc.agiledancers.service.SchedulingService;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||
|
||||
@Path("/api/dance-schools/{schoolId}/shows")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@@ -34,33 +36,60 @@ public class ShowResource {
|
||||
|
||||
private @Inject SchedulingService scheduling;
|
||||
|
||||
private @Inject ShowSchedules schedules;
|
||||
|
||||
private @PathParam("schoolId") long schoolId;
|
||||
|
||||
@GET
|
||||
public List<ShowView> list() {
|
||||
return service.shows(schoolId).stream().map(ShowView::of).toList();
|
||||
return service.shows(schoolId).stream().map(this::view).toList();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
public ShowView get(final @PathParam("id") long id) {
|
||||
return ShowView.of(service.show(schoolId, id));
|
||||
return view(service.show(schoolId, id));
|
||||
}
|
||||
|
||||
@POST
|
||||
public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) {
|
||||
final Show entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
|
||||
final Show entity = schedules.configure(schoolId, null, settings(request));
|
||||
return Response.created(uriInfo.getAbsolutePathBuilder()
|
||||
.path(entity.getId().toString())
|
||||
.build())
|
||||
.entity(ShowView.of(entity))
|
||||
.entity(view(entity))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}")
|
||||
public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) {
|
||||
return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances()));
|
||||
return view(schedules.configure(schoolId, id, settings(request)));
|
||||
}
|
||||
|
||||
private ShowView view(final Show show) {
|
||||
return ShowView.of(show, schedules.violations(schoolId, show.getId()));
|
||||
}
|
||||
|
||||
private static Show settings(final ShowRequest request) {
|
||||
return Show.builder()
|
||||
.name(request.name())
|
||||
.preferredDayParts(request.preferredDayParts())
|
||||
.dayParts(parts(request))
|
||||
.build();
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{id}/assignments")
|
||||
public ShowView assignments(final @PathParam("id") long id, final @NotNull @Valid ScheduleRequest request) {
|
||||
return view(schedules.saveDraft(schoolId, id, request.assignments()));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{id}/finalize")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public ShowView finalizeSchedule(final @PathParam("id") long id) {
|
||||
return view(schedules.finalizeSchedule(schoolId, id));
|
||||
}
|
||||
|
||||
private static List<DayPart> parts(final ShowRequest request) {
|
||||
@@ -79,7 +108,7 @@ public class ShowResource {
|
||||
public SchedulingView schedule(final @PathParam("id") long id) {
|
||||
final SchedulingResult result = scheduling.schedule(schoolId, id);
|
||||
return new SchedulingView(
|
||||
result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven");
|
||||
result.optimal(), result.optimal() ? "Optimal schedule" : "Valid schedule; optimality not proven");
|
||||
}
|
||||
|
||||
@DELETE
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record CoachRequest(@NotNull Long dancerId) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
|
||||
public record CoachView(long id, String name, Long dancerId) {
|
||||
public static CoachView of(final Coach coach) {
|
||||
return new CoachView(
|
||||
coach.getId(),
|
||||
coach.getName(),
|
||||
coach.getDancer() == null ? null : coach.getDancer().getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
public record ScheduleRequest(@NotNull List<List<Long>> assignments) {
|
||||
public ScheduleRequest {
|
||||
assignments = assignments == null
|
||||
? null
|
||||
: assignments.stream()
|
||||
.map((final List<Long> p) ->
|
||||
p == null ? null : java.util.Collections.unmodifiableList(new java.util.ArrayList<>(p)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
public record ShowRequest(
|
||||
@NotBlank String name,
|
||||
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
|
||||
Map<DanceGroupType, Integer> appearances) {
|
||||
Map<DanceGroupType, Integer> appearances,
|
||||
Integer preferredDayParts) {
|
||||
public ShowRequest {
|
||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||
|
||||
@@ -10,13 +10,25 @@ import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) {
|
||||
public record ShowView(
|
||||
long id,
|
||||
String name,
|
||||
List<DayPartView> dayParts,
|
||||
Map<DanceGroupType, Integer> appearances,
|
||||
int preferredDayParts,
|
||||
boolean finalized,
|
||||
List<String> violations) {
|
||||
public ShowView {
|
||||
violations = List.copyOf(violations);
|
||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||
}
|
||||
|
||||
public static ShowView of(final Show show) {
|
||||
return of(show, List.of());
|
||||
}
|
||||
|
||||
public static ShowView of(final Show show, final List<String> violations) {
|
||||
return new ShowView(
|
||||
show.getId(),
|
||||
show.getName(),
|
||||
@@ -28,6 +40,9 @@ public record ShowView(long id, String name, List<DayPartView> dayParts, Map<Dan
|
||||
.map(DanceGroup::getId)
|
||||
.toList()))
|
||||
.toList(),
|
||||
show.getAppearances());
|
||||
show.getAppearances(),
|
||||
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
|
||||
show.isFinalized(),
|
||||
violations);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
|
||||
@Singleton
|
||||
public class CoachLinks {
|
||||
@Inject
|
||||
private SchoolManagementService schools;
|
||||
|
||||
public List<Coach> list(final long schoolId) {
|
||||
return List.copyOf(schools.school(schoolId).getCoaches());
|
||||
}
|
||||
|
||||
public Coach link(final long schoolId, final Long coachId, final long dancerId) {
|
||||
synchronized (schools) {
|
||||
final DanceSchool school = schools.school(schoolId);
|
||||
final Dancer dancer = schools.dancer(schoolId, dancerId);
|
||||
final Coach coach = coachId == null ? create(school, dancer) : required(school, coachId);
|
||||
coach.setDancer(dancer);
|
||||
school.getShows().forEach(schools::touch);
|
||||
return coach;
|
||||
}
|
||||
}
|
||||
|
||||
private static Coach create(final DanceSchool school, final Dancer dancer) {
|
||||
final Coach coach = Coach.builder().name(dancer.getName()).build();
|
||||
coach.setId(school.getCoaches().stream().mapToLong(Coach::getId).max().orElse(0) + 1);
|
||||
school.addCoach(coach);
|
||||
return coach;
|
||||
}
|
||||
|
||||
private static Coach required(final DanceSchool school, final long id) {
|
||||
return school.getCoaches().stream()
|
||||
.filter((final Coach c) -> c.getId() == id)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ManagementException(ManagementExceptionKind.NOT_FOUND, "Unknown coach"));
|
||||
}
|
||||
|
||||
public void unlink(final long schoolId, final long id) {
|
||||
synchronized (schools) {
|
||||
final DanceSchool school = schools.school(schoolId);
|
||||
required(school, id).setDancer(null);
|
||||
school.getShows().forEach(schools::touch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import nl.agilecc.agiledancers.model.Coach;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
public final class SchedulingInputs {
|
||||
private SchedulingInputs() {}
|
||||
|
||||
public static int preferred(final Show show) {
|
||||
if (show.getPreferredDayParts() != null) {
|
||||
return show.getPreferredDayParts();
|
||||
}
|
||||
return (show.getDayParts().size() == 1 ? 1 : 2);
|
||||
}
|
||||
|
||||
public static SchedulingInput of(final DanceSchool school, final Show show, final long revision) {
|
||||
final int x = preferred(show);
|
||||
final LocalDate date = show.getDayParts().stream()
|
||||
.map(DayPart::getDate)
|
||||
.min(LocalDate::compareTo)
|
||||
.orElse(LocalDate.now());
|
||||
final List<GroupRequirement> groups = school.getDanceGroups().stream()
|
||||
.filter((final DanceGroup g) -> !g.getDancers().isEmpty())
|
||||
.map((final DanceGroup g) -> requirement(g, show, date))
|
||||
.toList();
|
||||
final Set<Long> coaches = school.getCoaches().stream()
|
||||
.map(Coach::getDancer)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(Dancer::getId)
|
||||
.collect(Collectors.toSet());
|
||||
return new SchedulingInput(show.getId(), revision, show.getDayParts().size(), groups, x, coaches);
|
||||
}
|
||||
|
||||
private static GroupRequirement requirement(final DanceGroup group, final Show show, final LocalDate date) {
|
||||
final int count = switch (group.getType()) {
|
||||
case PRE_SCHOOLER -> 1;
|
||||
case SHOW_CASE -> show.getDayParts().size();
|
||||
default -> preferred(show);
|
||||
};
|
||||
final double age = group.getDancers().stream()
|
||||
.mapToInt((final Dancer d) ->
|
||||
Period.between(d.getBirthDate(), date).getYears())
|
||||
.average()
|
||||
.orElse(0);
|
||||
return new GroupRequirement(
|
||||
group.getId(),
|
||||
count,
|
||||
group.getType() == DanceGroupType.SHOW_CASE,
|
||||
group.getDancers().stream().map(Dancer::getId).collect(Collectors.toSet()),
|
||||
group.getStyles(),
|
||||
age);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import nl.agilecc.agiledancers.model.DanceSchool;
|
||||
import nl.agilecc.agiledancers.model.Dancer;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
@@ -41,7 +41,8 @@ public class SchoolManagementService {
|
||||
private final Map<Long, Long> revisions = new HashMap<>();
|
||||
private final Set<Long> running = new HashSet<>();
|
||||
|
||||
private void touch(final Show show) {
|
||||
public synchronized void touch(final Show show) {
|
||||
show.setFinalized(false);
|
||||
revisions.merge(show.getId(), 1L, Long::sum);
|
||||
}
|
||||
|
||||
@@ -58,16 +59,11 @@ public class SchoolManagementService {
|
||||
ManagementExceptionKind.INVALID_INPUT,
|
||||
"Scheduling requires at least one school group and one daypart.");
|
||||
}
|
||||
ShowConfiguration.validate(show.getDayParts(), show.getAppearances());
|
||||
if (SchedulingInputs.preferred(show) > show.getDayParts().size()) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Correct x before scheduling.");
|
||||
}
|
||||
running.add(showId);
|
||||
return new SchedulingInput(
|
||||
showId,
|
||||
revisions.getOrDefault(showId, 0L),
|
||||
show.getDayParts().size(),
|
||||
current.stream()
|
||||
.map((final DanceGroup g) -> new GroupRequirement(
|
||||
g.getId(), show.getAppearances().get(g.getType())))
|
||||
.toList());
|
||||
return SchedulingInputs.of(school(schoolId), show, revisions.getOrDefault(showId, 0L));
|
||||
}
|
||||
|
||||
/** Validates the complete result and input revision before replacing any saved assignments. */
|
||||
@@ -97,6 +93,7 @@ public class SchoolManagementService {
|
||||
.build());
|
||||
}
|
||||
show.setDayParts(List.copyOf(replacement));
|
||||
touch(show);
|
||||
}
|
||||
|
||||
private Map<Long, DanceGroup> validateAssignments(
|
||||
@@ -111,29 +108,13 @@ public class SchoolManagementService {
|
||||
}
|
||||
final Map<Long, DanceGroup> current = new HashMap<>();
|
||||
groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g));
|
||||
validateParts(result.assignments(), current.keySet());
|
||||
for (final GroupRequirement group : input.groups()) {
|
||||
final long id = group.groupId();
|
||||
if (result.assignments().stream()
|
||||
.filter((final List<Long> p) -> p.contains(id))
|
||||
.count()
|
||||
!= group.appearances()) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT, "Solver did not satisfy every required appearance.");
|
||||
}
|
||||
final List<String> violations = ScheduleRules.violations(input, result.assignments());
|
||||
if (!violations.isEmpty()) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", violations));
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static void validateParts(final List<List<Long>> assignments, final Set<Long> groupIds) {
|
||||
for (final List<Long> part : assignments) {
|
||||
if (part == null || new HashSet<>(part).size() != part.size() || !groupIds.containsAll(part)) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT, "Solver returned invalid group assignments.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Releases the run slot after success or failure, without changing saved assignments. */
|
||||
public synchronized void endScheduling(final long showId) {
|
||||
running.remove(showId);
|
||||
@@ -186,6 +167,7 @@ public class SchoolManagementService {
|
||||
final Dancer dancer = dancer(schoolId, dancerId);
|
||||
dancer.setName(name);
|
||||
dancer.setBirthDate(birthDate);
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
return dancer;
|
||||
}
|
||||
|
||||
@@ -193,6 +175,10 @@ public class SchoolManagementService {
|
||||
final DanceSchool school = school(schoolId);
|
||||
final Dancer dancer = dancer(schoolId, dancerId);
|
||||
school.getDanceGroups().forEach((final DanceGroup group) -> group.removeDancer(dancer));
|
||||
school.getCoaches().stream()
|
||||
.filter((final nl.agilecc.agiledancers.model.Coach c) -> c.getDancer() == dancer)
|
||||
.forEach((final nl.agilecc.agiledancers.model.Coach c) -> c.setDancer(null));
|
||||
school.getShows().forEach(this::touch);
|
||||
school.removeDancer(dancer);
|
||||
dancers.delete(dancerId);
|
||||
}
|
||||
@@ -224,7 +210,7 @@ public class SchoolManagementService {
|
||||
final DanceGroup group = group(schoolId, groupId);
|
||||
validateAgeRange(details.startAge(), details.endAge());
|
||||
group.setCategory(details.category());
|
||||
if (group.getType() != details.type()) {
|
||||
if (group.getType() != details.type() || !group.getStyles().equals(new HashSet<>(details.styles()))) {
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
}
|
||||
group.setType(details.type());
|
||||
@@ -248,11 +234,13 @@ public class SchoolManagementService {
|
||||
public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
||||
final DanceGroup group = group(schoolId, groupId);
|
||||
group.addDancer(dancer(schoolId, dancerId));
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
}
|
||||
|
||||
public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) {
|
||||
final DanceGroup group = group(schoolId, groupId);
|
||||
group.removeDancer(dancer(schoolId, dancerId));
|
||||
school(schoolId).getShows().forEach(this::touch);
|
||||
}
|
||||
|
||||
public synchronized List<Show> shows(final long schoolId) {
|
||||
@@ -273,6 +261,7 @@ public class SchoolManagementService {
|
||||
final Show show = shows.add(Show.builder()
|
||||
.name(name)
|
||||
.dayParts(List.copyOf(parts))
|
||||
.preferredDayParts((parts.size() == 1 ? 1 : 2))
|
||||
.appearances(Map.copyOf(counts))
|
||||
.build());
|
||||
school.addShow(show);
|
||||
@@ -292,18 +281,7 @@ public class SchoolManagementService {
|
||||
ShowConfiguration.validate(parts, counts);
|
||||
final boolean changed =
|
||||
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
|
||||
final List<DayPart> retained = parts.stream()
|
||||
.map((final DayPart p) -> DayPart.builder()
|
||||
.date(p.getDate())
|
||||
.type(p.getType())
|
||||
.danceGroups(show.getDayParts().stream()
|
||||
.filter((final DayPart old) ->
|
||||
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
|
||||
.findFirst()
|
||||
.map(DayPart::getDanceGroups)
|
||||
.orElse(List.of()))
|
||||
.build())
|
||||
.toList();
|
||||
final List<DayPart> retained = ShowConfiguration.retainAssignments(show, parts);
|
||||
show.setName(name);
|
||||
show.setDayParts(retained);
|
||||
show.setAppearances(Map.copyOf(counts));
|
||||
@@ -413,6 +391,7 @@ public class SchoolManagementService {
|
||||
final Show rebuilt = Show.builder()
|
||||
.name(show.getName())
|
||||
.appearances(show.getAppearances())
|
||||
.preferredDayParts(show.getPreferredDayParts())
|
||||
.dayParts(show.getDayParts().stream()
|
||||
.map((final DayPart p) -> DayPart.builder()
|
||||
.date(p.getDate())
|
||||
|
||||
@@ -5,11 +5,27 @@ import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
|
||||
public final class ShowConfiguration {
|
||||
private ShowConfiguration() {}
|
||||
|
||||
public static List<DayPart> retainAssignments(final Show show, final List<DayPart> parts) {
|
||||
return parts.stream()
|
||||
.map((final DayPart p) -> DayPart.builder()
|
||||
.date(p.getDate())
|
||||
.type(p.getType())
|
||||
.danceGroups(show.getDayParts().stream()
|
||||
.filter((final DayPart old) ->
|
||||
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
|
||||
.findFirst()
|
||||
.map(DayPart::getDanceGroups)
|
||||
.orElse(List.of()))
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
public static Map<DanceGroupType, Integer> defaults(final int n) {
|
||||
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
|
||||
if (n > 0) {
|
||||
@@ -18,7 +34,7 @@ public final class ShowConfiguration {
|
||||
type,
|
||||
switch (type) {
|
||||
case PRE_SCHOOLER -> 1;
|
||||
case SHOW_CASE, PRE_EDUCATION -> n;
|
||||
case SHOW_CASE -> n;
|
||||
default -> Math.min(2, n);
|
||||
});
|
||||
}
|
||||
@@ -43,7 +59,7 @@ public final class ShowConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateParts(final List<DayPart> parts) {
|
||||
public static void validateParts(final List<DayPart> parts) {
|
||||
if (parts == null
|
||||
|| parts.size() > 6
|
||||
|| parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
|
||||
@Singleton
|
||||
public class ShowSchedules {
|
||||
@Inject
|
||||
private SchoolManagementService schools;
|
||||
|
||||
public ShowSchedules() {}
|
||||
|
||||
public ShowSchedules(final SchoolManagementService schools) {
|
||||
this.schools = schools;
|
||||
}
|
||||
|
||||
/** Retains assignments by date/type; removing dayparts may retain an invalid x in the draft. */
|
||||
public Show configure(final long schoolId, final Long showId, final Show settings) {
|
||||
synchronized (schools) {
|
||||
ShowConfiguration.validateParts(settings.getDayParts());
|
||||
final Show existing = showId == null ? null : schools.show(schoolId, showId);
|
||||
final int x = settings.getPreferredDayParts() == null
|
||||
? (existing == null
|
||||
? (settings.getDayParts().size() == 1 ? 1 : 2)
|
||||
: SchedulingInputs.preferred(existing))
|
||||
: settings.getPreferredDayParts();
|
||||
validateX(x, existing, settings.getDayParts().size());
|
||||
final Show show = existing == null
|
||||
? schools.createShow(schoolId, settings.getName(), settings.getDayParts(), null)
|
||||
: existing;
|
||||
final boolean changed = x != SchedulingInputs.preferred(show)
|
||||
|| !keys(show.getDayParts()).equals(keys(settings.getDayParts()));
|
||||
show.setDayParts(ShowConfiguration.retainAssignments(show, settings.getDayParts()));
|
||||
show.setName(settings.getName());
|
||||
show.setPreferredDayParts(x);
|
||||
if (changed) {
|
||||
schools.touch(show);
|
||||
}
|
||||
return show;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> keys(final List<DayPart> parts) {
|
||||
return parts.stream()
|
||||
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static void validateX(final int x, final Show existing, final int parts) {
|
||||
if (x < 1
|
||||
|| x > 6
|
||||
|| (parts > 0 && x > parts && (existing == null || x != SchedulingInputs.preferred(existing)))) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT,
|
||||
"x must be between 1 and the number of dayparts. Retained x must be corrected before scheduling.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Saves even rule-breaking drafts, but rejects malformed lists and foreign group references atomically. */
|
||||
public Show saveDraft(final long schoolId, final long showId, final List<List<Long>> assignments) {
|
||||
synchronized (schools) {
|
||||
final Show show = schools.show(schoolId, showId);
|
||||
if (assignments == null || assignments.size() != show.getDayParts().size()) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Provide every daypart.");
|
||||
}
|
||||
final List<DayPart> replacement = new ArrayList<>();
|
||||
for (int p = 0; p < assignments.size(); p++) {
|
||||
final DayPart old = show.getDayParts().get(p);
|
||||
final List<Long> ids = assignments.get(p);
|
||||
if (ids == null || ids.stream().anyMatch(java.util.Objects::isNull)) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Invalid group IDs.");
|
||||
}
|
||||
final List<DanceGroup> groups = ids.stream()
|
||||
.map((final Long id) -> schools.group(schoolId, id))
|
||||
.toList();
|
||||
replacement.add(DayPart.builder()
|
||||
.date(old.getDate())
|
||||
.type(old.getType())
|
||||
.danceGroups(groups)
|
||||
.build());
|
||||
}
|
||||
show.setDayParts(List.copyOf(replacement));
|
||||
schools.touch(show);
|
||||
return show;
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks a schedule final only after checking all hard rules against current school data. */
|
||||
public Show finalizeSchedule(final long schoolId, final long showId) {
|
||||
synchronized (schools) {
|
||||
final Show show = schools.show(schoolId, showId);
|
||||
final List<String> errors = violations(schoolId, showId);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", errors));
|
||||
}
|
||||
show.setFinalized(true);
|
||||
return show;
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> violations(final long schoolId, final long showId) {
|
||||
synchronized (schools) {
|
||||
final Show show = schools.show(schoolId, showId);
|
||||
final SchedulingInput input = SchedulingInputs.of(schools.school(schoolId), show, 0);
|
||||
return ScheduleRules.violations(
|
||||
input,
|
||||
show.getDayParts().stream()
|
||||
.map((final DayPart p) -> p.getDanceGroups().stream()
|
||||
.map(DanceGroup::getId)
|
||||
.toList())
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,21 @@ 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) {
|
||||
public record Performance(
|
||||
String id,
|
||||
String name,
|
||||
List<Part> dayParts,
|
||||
Map<DanceGroupType, Integer> appearances,
|
||||
Integer preferredDayParts,
|
||||
boolean finalized) {
|
||||
public Performance(
|
||||
final String id,
|
||||
final String name,
|
||||
final List<Part> dayParts,
|
||||
final Map<DanceGroupType, Integer> appearances) {
|
||||
this(id, name, dayParts, appearances, null, false);
|
||||
}
|
||||
|
||||
public Performance {
|
||||
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
|
||||
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
|
||||
|
||||
@@ -23,7 +23,7 @@ import nl.agilecc.agiledancers.service.ManagementException;
|
||||
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
||||
import nl.agilecc.agiledancers.service.ShowConfiguration;
|
||||
|
||||
/** Version 2 file format. IDs are local to the file, never server identifiers. */
|
||||
/** Version 3 file format; version 2 imports remain supported. IDs are local to the file, never server identifiers. */
|
||||
public record SchoolSnapshot(
|
||||
Integer formatVersion,
|
||||
UUID key,
|
||||
@@ -45,14 +45,15 @@ public record SchoolSnapshot(
|
||||
final Map<DanceGroup, String> groups = ids(s.getDanceGroups());
|
||||
final Map<Show, String> shows = ids(s.getShows());
|
||||
return new SchoolSnapshot(
|
||||
2,
|
||||
3,
|
||||
s.getKey(),
|
||||
s.getName(),
|
||||
s.getDancers().stream()
|
||||
.map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
|
||||
.toList(),
|
||||
s.getCoaches().stream()
|
||||
.map((final Coach c) -> new Trainer(coaches.get(c), c.getName()))
|
||||
.map((final Coach c) -> new Trainer(
|
||||
coaches.get(c), c.getName(), c.getDancer() == null ? null : dancers.get(c.getDancer())))
|
||||
.toList(),
|
||||
s.getDanceGroups().stream()
|
||||
.map((final DanceGroup g) -> new Group(
|
||||
@@ -77,7 +78,9 @@ public record SchoolSnapshot(
|
||||
.map(groups::get)
|
||||
.toList()))
|
||||
.toList(),
|
||||
show.getAppearances()))
|
||||
show.getAppearances(),
|
||||
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
|
||||
show.isFinalized()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
@@ -91,12 +94,14 @@ public record SchoolSnapshot(
|
||||
|
||||
/** Validates all references and values, then rebuilds an independent graph with temporary IDs. */
|
||||
public DanceSchool restore() {
|
||||
check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
|
||||
check(
|
||||
Objects.equals(formatVersion, 2) || Objects.equals(formatVersion, 3),
|
||||
"Niet-ondersteunde formatVersion: " + formatVersion);
|
||||
check(key != null, "key ontbreekt");
|
||||
text(name, "schoolnaam");
|
||||
final DanceSchool school = DanceSchool.builder().key(key).name(name).build();
|
||||
final Map<String, Dancer> dancerMap = restoreDancers(school);
|
||||
final Map<String, Coach> coachMap = restoreCoaches(school);
|
||||
final Map<String, Coach> coachMap = restoreCoaches(school, dancerMap);
|
||||
final Map<String, DanceGroup> groupMap = restoreGroups(school, dancerMap, coachMap);
|
||||
restoreShows(school, groupMap);
|
||||
return school;
|
||||
@@ -120,12 +125,15 @@ public record SchoolSnapshot(
|
||||
return dancerMap;
|
||||
}
|
||||
|
||||
private Map<String, Coach> restoreCoaches(final DanceSchool school) {
|
||||
private Map<String, Coach> restoreCoaches(final DanceSchool school, final Map<String, Dancer> dancerMap) {
|
||||
final Map<String, Coach> coachMap = new LinkedHashMap<>();
|
||||
for (final Trainer c : list(coaches, "coaches")) {
|
||||
check(c != null, "Lege coach");
|
||||
text(c.name(), "coachnaam");
|
||||
final Coach coach = Coach.builder().name(c.name()).build();
|
||||
final Coach coach = Coach.builder()
|
||||
.name(c.name())
|
||||
.dancer(c.dancerId() == null ? null : ref(dancerMap, c.dancerId(), "Onbekende coach-danser"))
|
||||
.build();
|
||||
coach.setId((long) coachMap.size());
|
||||
put(coachMap, c.id(), coach);
|
||||
school.addCoach(coach);
|
||||
@@ -190,17 +198,24 @@ public record SchoolSnapshot(
|
||||
parts.add(DayPart.builder()
|
||||
.date(part.date())
|
||||
.type(part.type())
|
||||
.danceGroups(unique(part.groupIds(), "groupIds").stream()
|
||||
.danceGroups(list(part.groupIds(), "groupIds").stream()
|
||||
.map((final String id) ->
|
||||
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
|
||||
.toList())
|
||||
.build());
|
||||
}
|
||||
ShowConfiguration.validate(parts, show.appearances());
|
||||
ShowConfiguration.validateParts(parts);
|
||||
final int x = show.preferredDayParts() == null ? (parts.size() == 1 ? 1 : 2) : show.preferredDayParts();
|
||||
check(x >= 1 && x <= 6, "x moet tussen 1 en 6 liggen");
|
||||
final Show entity = Show.builder()
|
||||
.name(show.name())
|
||||
.dayParts(parts)
|
||||
.appearances(Map.copyOf(show.appearances()))
|
||||
.appearances(
|
||||
show.appearances() == null
|
||||
? ShowConfiguration.defaults(parts.size())
|
||||
: Map.copyOf(show.appearances()))
|
||||
.preferredDayParts(x)
|
||||
.finalized(false)
|
||||
.build();
|
||||
entity.setId((long) showIds.size());
|
||||
school.addShow(entity);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
package nl.agilecc.agiledancers.service.model;
|
||||
|
||||
public record Trainer(String id, String name) {}
|
||||
public record Trainer(String id, String name, String dancerId) {
|
||||
public Trainer(final String id, final String name) {
|
||||
this(id, name, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package nl.agilecc.agiledancers.solver;
|
||||
|
||||
import com.google.ortools.Loader;
|
||||
import com.google.ortools.sat.BoolVar;
|
||||
import com.google.ortools.sat.CpModel;
|
||||
import com.google.ortools.sat.CpSolver;
|
||||
import com.google.ortools.sat.CpSolverStatus;
|
||||
import com.google.ortools.sat.IntVar;
|
||||
import com.google.ortools.sat.LinearExpr;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
@@ -18,50 +15,51 @@ import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||
|
||||
@Singleton
|
||||
public class OrToolsSchedulingEngine implements SchedulingEngine {
|
||||
private final double timeLimitSeconds;
|
||||
|
||||
public OrToolsSchedulingEngine() {
|
||||
this(1800);
|
||||
}
|
||||
|
||||
/** Allows callers to impose a shorter run budget, for example in automated tests. */
|
||||
public OrToolsSchedulingEngine(final double timeLimitSeconds) {
|
||||
this.timeLimitSeconds = timeLimitSeconds;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchedulingResult solve(final SchedulingInput input) {
|
||||
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
|
||||
Loader.loadNativeLibraries();
|
||||
final CpModel model = new CpModel();
|
||||
final BoolVar[][] assigned = assignments(model, input);
|
||||
balance(model, assigned, input.dayParts());
|
||||
final OrderedAssignments vars = OrderedAssignments.create(model, input);
|
||||
final LinearExpr attendance = SchedulingObjectives.attendance(model, input, vars);
|
||||
final LinearExpr variety = SchedulingObjectives.variety(model, input, vars);
|
||||
final 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);
|
||||
final CpSolverStatus status = solver.solve(model);
|
||||
requireSolution(status);
|
||||
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, readAssignments(input, assigned, solver));
|
||||
}
|
||||
|
||||
private static BoolVar[][] assignments(final CpModel model, final SchedulingInput input) {
|
||||
final BoolVar[][] assigned = new BoolVar[input.groups().size()][input.dayParts()];
|
||||
for (int g = 0; g < assigned.length; g++) {
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
assigned[g][p] = model.newBoolVar("g" + g + "p" + p);
|
||||
SchedulingResult best = null;
|
||||
for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) {
|
||||
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
|
||||
if (remaining <= 0) {
|
||||
if (best != null) {
|
||||
return new SchedulingResult(false, best.assignments());
|
||||
}
|
||||
requireSolution(CpSolverStatus.UNKNOWN);
|
||||
}
|
||||
model.addEquality(LinearExpr.sum(assigned[g]), input.groups().get(g).appearances());
|
||||
}
|
||||
return assigned;
|
||||
}
|
||||
|
||||
private static void balance(final CpModel model, final BoolVar[][] assigned, final int dayParts) {
|
||||
final int groups = assigned.length;
|
||||
final IntVar[] loads = new IntVar[dayParts];
|
||||
for (int p = 0; p < dayParts; p++) {
|
||||
final BoolVar[] column = new BoolVar[groups];
|
||||
for (int g = 0; g < groups; g++) {
|
||||
column[g] = assigned[g][p];
|
||||
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
|
||||
model.minimize(objective);
|
||||
final CpSolverStatus status = solver.solve(model);
|
||||
if (status == CpSolverStatus.UNKNOWN && best != null) {
|
||||
return new SchedulingResult(false, best.assignments());
|
||||
}
|
||||
loads[p] = model.newIntVar(0, groups, "load" + p);
|
||||
model.addEquality(loads[p], LinearExpr.sum(column));
|
||||
requireSolution(status);
|
||||
best = new SchedulingResult(status == CpSolverStatus.OPTIMAL, vars.read(input, solver));
|
||||
if (status != CpSolverStatus.OPTIMAL) {
|
||||
return best;
|
||||
}
|
||||
model.addEquality(objective, Math.round(solver.objectiveValue()));
|
||||
}
|
||||
final IntVar max = model.newIntVar(0, groups, "max");
|
||||
final IntVar 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}));
|
||||
return best;
|
||||
}
|
||||
|
||||
private static void requireSolution(final CpSolverStatus status) {
|
||||
@@ -77,19 +75,4 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static List<List<Long>> readAssignments(
|
||||
final SchedulingInput input, final BoolVar[][] assigned, final CpSolver solver) {
|
||||
final List<List<Long>> parts = new ArrayList<>();
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
final List<Long> ids = new ArrayList<>();
|
||||
for (int g = 0; g < assigned.length; g++) {
|
||||
if (solver.booleanValue(assigned[g][p])) {
|
||||
ids.add(input.groups().get(g).groupId());
|
||||
}
|
||||
}
|
||||
parts.add(List.copyOf(ids));
|
||||
}
|
||||
return List.copyOf(parts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package nl.agilecc.agiledancers.solver;
|
||||
|
||||
import com.google.ortools.sat.BoolVar;
|
||||
import com.google.ortools.sat.CpModel;
|
||||
import com.google.ortools.sat.IntVar;
|
||||
import com.google.ortools.sat.LinearExpr;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.IntStream;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
|
||||
/** Solver-owned variables. Negative positions are distinct sentinels for groups absent from a daypart. */
|
||||
public record OrderedAssignments(BoolVar[][] present, IntVar[][] position, IntVar[] loads, IntVar spread) {
|
||||
public static OrderedAssignments create(final CpModel model, final SchedulingInput input) {
|
||||
final int n = input.groups().size();
|
||||
final BoolVar[][] present = new BoolVar[n][input.dayParts()];
|
||||
final IntVar[][] positions = new IntVar[n][input.dayParts()];
|
||||
for (int g = 0; g < n; g++) {
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
present[g][p] = model.newBoolVar("present" + g + ":" + p);
|
||||
positions[g][p] = model.newIntVar(-g - 1, n - 1, "position" + g + ":" + p);
|
||||
model.addGreaterOrEqual(positions[g][p], 0).onlyEnforceIf(present[g][p]);
|
||||
model.addEquality(positions[g][p], -g - 1).onlyEnforceIf(present[g][p].not());
|
||||
}
|
||||
model.addEquality(LinearExpr.sum(present[g]), input.groups().get(g).appearances());
|
||||
}
|
||||
final IntVar[] loads = loads(model, present, positions, input.dayParts());
|
||||
final IntVar max = model.newIntVar(0, n, "max");
|
||||
final IntVar min = model.newIntVar(0, n, "min");
|
||||
final IntVar spread = model.newIntVar(0, 2, "spread");
|
||||
model.addMaxEquality(max, loads);
|
||||
model.addMinEquality(min, loads);
|
||||
model.addEquality(spread, LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1}));
|
||||
final OrderedAssignments result = new OrderedAssignments(present, positions, loads, spread);
|
||||
result.boundaries(model, input);
|
||||
result.changingTime(model, input);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IntVar[] loads(
|
||||
final CpModel model, final BoolVar[][] present, final IntVar[][] positions, final int parts) {
|
||||
final IntVar[] loads = new IntVar[parts];
|
||||
for (int p = 0; p < parts; p++) {
|
||||
final BoolVar[] column = new BoolVar[present.length];
|
||||
final IntVar[] order = new IntVar[present.length];
|
||||
for (int g = 0; g < present.length; g++) {
|
||||
column[g] = present[g][p];
|
||||
order[g] = positions[g][p];
|
||||
}
|
||||
loads[p] = model.newIntVar(0, present.length, "load" + p);
|
||||
model.addEquality(loads[p], LinearExpr.sum(column));
|
||||
model.addAllDifferent(order);
|
||||
for (int g = 0; g < present.length; g++) {
|
||||
model.addLessThan(order[g], loads[p]).onlyEnforceIf(column[g]);
|
||||
}
|
||||
}
|
||||
return loads;
|
||||
}
|
||||
|
||||
private void boundaries(final CpModel model, final SchedulingInput input) {
|
||||
final List<Integer> showcases = IntStream.range(0, input.groups().size())
|
||||
.filter((final int g) -> input.groups().get(g).showcase())
|
||||
.boxed()
|
||||
.toList();
|
||||
if (showcases.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
final List<BoolVar> first = new ArrayList<>();
|
||||
final List<BoolVar> last = new ArrayList<>();
|
||||
for (final int g : showcases) {
|
||||
first.add(equals(model, position[g][p], LinearExpr.constant(0)));
|
||||
last.add(equals(model, position[g][p], LinearExpr.affine(loads[p], 1, -1)));
|
||||
}
|
||||
if (showcases.size() == 1) {
|
||||
model.addBoolOr(new com.google.ortools.sat.Literal[] {first.getFirst(), last.getFirst()});
|
||||
} else {
|
||||
model.addEquality(LinearExpr.sum(first.toArray(BoolVar[]::new)), 1);
|
||||
model.addEquality(LinearExpr.sum(last.toArray(BoolVar[]::new)), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static BoolVar equals(final CpModel model, final IntVar position, final LinearExpr value) {
|
||||
final BoolVar equal = model.newBoolVar("boundary");
|
||||
model.addEquality(position, value).onlyEnforceIf(equal);
|
||||
model.addDifferent(position, value).onlyEnforceIf(equal.not());
|
||||
return equal;
|
||||
}
|
||||
|
||||
private void changingTime(final CpModel model, final SchedulingInput input) {
|
||||
for (int a = 0; a < input.groups().size(); a++) {
|
||||
for (int b = a + 1; b < input.groups().size(); b++) {
|
||||
if (!java.util.Collections.disjoint(
|
||||
input.groups().get(a).dancerIds(), input.groups().get(b).dancerIds())) {
|
||||
separate(model, a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void separate(final CpModel model, final int a, final int b) {
|
||||
for (int p = 0; p < loads.length; p++) {
|
||||
final IntVar gap = model.newIntVar(0, present.length * 2L, "gap");
|
||||
model.addAbsEquality(
|
||||
gap, LinearExpr.weightedSum(new IntVar[] {position[a][p], position[b][p]}, new long[] {1, -1}));
|
||||
model.addGreaterOrEqual(gap, 3).onlyEnforceIf(new BoolVar[] {present[a][p], present[b][p]});
|
||||
}
|
||||
}
|
||||
|
||||
public List<List<Long>> read(final SchedulingInput input, final com.google.ortools.sat.CpSolver solver) {
|
||||
final List<List<Long>> result = new ArrayList<>();
|
||||
for (int p = 0; p < loads.length; p++) {
|
||||
final int part = p;
|
||||
result.add(IntStream.range(0, present.length)
|
||||
.boxed()
|
||||
.filter((final Integer g) -> solver.booleanValue(present[g][part]))
|
||||
.sorted(Comparator.comparingLong((final Integer g) -> solver.value(position[g][part])))
|
||||
.map((final Integer g) -> input.groups().get(g).groupId())
|
||||
.toList());
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package nl.agilecc.agiledancers.solver;
|
||||
|
||||
import com.google.ortools.sat.BoolVar;
|
||||
import com.google.ortools.sat.CpModel;
|
||||
import com.google.ortools.sat.IntVar;
|
||||
import com.google.ortools.sat.LinearExpr;
|
||||
import com.google.ortools.sat.LinearExprBuilder;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.IntStream;
|
||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
|
||||
|
||||
public final class SchedulingObjectives {
|
||||
private SchedulingObjectives() {}
|
||||
|
||||
public static LinearExpr attendance(
|
||||
final CpModel model, final SchedulingInput input, final OrderedAssignments vars) {
|
||||
final Set<Long> dancers = new HashSet<>();
|
||||
final Set<Long> showcases = new HashSet<>();
|
||||
input.groups().forEach((final GroupRequirement g) -> dancers.addAll(g.dancerIds()));
|
||||
input.groups().stream()
|
||||
.filter(GroupRequirement::showcase)
|
||||
.forEach((final GroupRequirement g) -> showcases.addAll(g.dancerIds()));
|
||||
dancers.removeAll(input.coachIds());
|
||||
final LinearExprBuilder penalty = LinearExpr.newBuilder();
|
||||
for (final long dancer : dancers) {
|
||||
final IntVar[] parts = dancerParts(model, input, vars, dancer);
|
||||
if (showcases.contains(dancer)) {
|
||||
final IntVar excess = model.newIntVar(0, input.dayParts(), "excess");
|
||||
model.addMaxEquality(excess, new LinearExpr[] {
|
||||
LinearExpr.affine(LinearExpr.sum(parts), 1, -input.preferredDayParts()), LinearExpr.constant(0)
|
||||
});
|
||||
penalty.add(excess);
|
||||
} else {
|
||||
model.addLessOrEqual(LinearExpr.sum(parts), input.preferredDayParts());
|
||||
}
|
||||
}
|
||||
return penalty.build();
|
||||
}
|
||||
|
||||
private static IntVar[] dancerParts(
|
||||
final CpModel model, final SchedulingInput input, final OrderedAssignments vars, final long dancer) {
|
||||
final List<Integer> groups = IntStream.range(0, input.groups().size())
|
||||
.filter((final int g) -> !input.groups().get(g).showcase()
|
||||
&& input.groups().get(g).dancerIds().contains(dancer))
|
||||
.boxed()
|
||||
.toList();
|
||||
final IntVar[] result = new IntVar[input.dayParts()];
|
||||
for (int p = 0; p < input.dayParts(); p++) {
|
||||
final int part = p;
|
||||
result[p] = model.newBoolVar("dancer" + dancer + ":" + p);
|
||||
if (groups.isEmpty()) {
|
||||
model.addEquality(result[p], 0);
|
||||
} else {
|
||||
model.addMaxEquality(
|
||||
result[p],
|
||||
groups.stream()
|
||||
.map((final Integer g) -> vars.present()[g][part])
|
||||
.toArray(IntVar[]::new));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static LinearExpr variety(final CpModel model, final SchedulingInput input, final OrderedAssignments vars) {
|
||||
final LinearExprBuilder penalty = LinearExpr.newBuilder();
|
||||
// Every nonempty daypart has load - 1 adjacencies. Remove their common score
|
||||
// to avoid exploring equivalent orders when groups have identical penalties.
|
||||
final long baseline = minimumPenalty(input.groups());
|
||||
final long performances =
|
||||
input.groups().stream().mapToLong(GroupRequirement::appearances).sum();
|
||||
penalty.add(performances * baseline);
|
||||
for (final IntVar load : vars.loads()) {
|
||||
final BoolVar active = model.newBoolVar("nonemptyPart");
|
||||
model.addGreaterThan(load, 0).onlyEnforceIf(active);
|
||||
model.addEquality(load, 0).onlyEnforceIf(active.not());
|
||||
penalty.addTerm(active, -baseline);
|
||||
}
|
||||
for (int a = 0; a < input.groups().size(); a++) {
|
||||
for (int b = a + 1; b < input.groups().size(); b++) {
|
||||
final long score = VarietyScore.penalty(
|
||||
input.groups().get(a), input.groups().get(b))
|
||||
- baseline;
|
||||
if (score > 0) {
|
||||
addAdjacencies(model, vars, new int[] {a, b}, penalty, score);
|
||||
}
|
||||
}
|
||||
}
|
||||
return penalty.build();
|
||||
}
|
||||
|
||||
private static long minimumPenalty(final List<GroupRequirement> groups) {
|
||||
long minimum = 2_000_000;
|
||||
for (int a = 0; a < groups.size(); a++) {
|
||||
for (int b = a + 1; b < groups.size(); b++) {
|
||||
minimum = Math.min(minimum, VarietyScore.penalty(groups.get(a), groups.get(b)));
|
||||
}
|
||||
}
|
||||
return groups.size() < 2 ? 0 : minimum;
|
||||
}
|
||||
|
||||
private static void addAdjacencies(
|
||||
final CpModel model,
|
||||
final OrderedAssignments vars,
|
||||
final int[] pair,
|
||||
final LinearExprBuilder penalty,
|
||||
final long score) {
|
||||
for (int p = 0; p < vars.loads().length; p++) {
|
||||
penalty.addTerm(adjacent(model, vars, pair[0], pair[1], p), score);
|
||||
}
|
||||
}
|
||||
|
||||
private static BoolVar adjacent(
|
||||
final CpModel model, final OrderedAssignments vars, final int a, final int b, final int p) {
|
||||
final IntVar gap = model.newIntVar(0, vars.present().length * 2L, "adjacencyGap");
|
||||
model.addAbsEquality(
|
||||
gap,
|
||||
LinearExpr.weightedSum(
|
||||
new IntVar[] {vars.position()[a][p], vars.position()[b][p]}, new long[] {1, -1}));
|
||||
final BoolVar adjacent = model.newBoolVar("adjacent");
|
||||
model.addImplication(adjacent, vars.present()[a][p]);
|
||||
model.addImplication(adjacent, vars.present()[b][p]);
|
||||
model.addEquality(gap, 1).onlyEnforceIf(adjacent);
|
||||
model.addDifferent(gap, 1).onlyEnforceIf(new com.google.ortools.sat.Literal[] {
|
||||
adjacent.not(), vars.present()[a][p], vars.present()[b][p]
|
||||
});
|
||||
return adjacent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.DanceGroup;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
import nl.agilecc.agiledancers.service.ManagementException;
|
||||
import nl.agilecc.agiledancers.service.SchedulingInputs;
|
||||
import nl.agilecc.agiledancers.service.SchedulingService;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
|
||||
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class S18LifecycleTest {
|
||||
private final SchoolManagementService schools = new SchoolManagementService();
|
||||
private final long school = schools.createSchool("Studio").getId();
|
||||
private final ShowSchedules schedules = new ShowSchedules(schools);
|
||||
|
||||
private List<DayPart> parts(final int n) {
|
||||
return java.util.stream.IntStream.range(0, n)
|
||||
.mapToObj((final int i) -> DayPart.builder()
|
||||
.date(LocalDate.of(2027, 1, 1).plusDays(i))
|
||||
.type(DayPartType.MORNING)
|
||||
.danceGroups(List.of())
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Show settings(final int n, final Integer x) {
|
||||
return Show.builder()
|
||||
.name("Show")
|
||||
.dayParts(parts(n))
|
||||
.preferredDayParts(x)
|
||||
.build();
|
||||
}
|
||||
|
||||
private DanceGroup group(final DanceGroupType type) {
|
||||
final DanceGroup group = schools.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
|
||||
final long dancer =
|
||||
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 2)).getId();
|
||||
schools.addGroupDancer(school, group.getId(), dancer);
|
||||
return group;
|
||||
}
|
||||
|
||||
public @Test void draftEditingValidationFinalizationAndImportPreserveOrder() {
|
||||
final DanceGroup group = group(DanceGroupType.REGULAR);
|
||||
final Show show = schedules.configure(school, null, settings(2, 1));
|
||||
assertThrows(ManagementException.class, () -> schedules.finalizeSchedule(school, show.getId()));
|
||||
schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId()), List.of()));
|
||||
assertTrue(schedules.violations(school, show.getId()).isEmpty());
|
||||
schedules.finalizeSchedule(school, show.getId());
|
||||
assertTrue(show.isFinalized());
|
||||
final SchoolSnapshot snapshot = schools.exportSchool(school);
|
||||
assertTrue(snapshot.shows().getFirst().finalized());
|
||||
final Show imported = snapshot.restore().getShows().iterator().next();
|
||||
assertFalse(imported.isFinalized());
|
||||
assertEquals(1, imported.getPreferredDayParts());
|
||||
assertEquals(1, imported.getDayParts().getFirst().getDanceGroups().size());
|
||||
schedules.configure(school, show.getId(), settings(2, 1));
|
||||
assertTrue(show.isFinalized());
|
||||
schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(group.getId())));
|
||||
assertFalse(show.isFinalized());
|
||||
schedules.finalizeSchedule(school, show.getId());
|
||||
schools.removeGroupDancer(
|
||||
school, group.getId(), group.getDancers().iterator().next().getId());
|
||||
assertFalse(show.isFinalized());
|
||||
assertFalse(schedules.violations(school, show.getId()).isEmpty());
|
||||
}
|
||||
|
||||
public @Test void invalidDraftsAreSavedButStructuralErrorsAreAtomic() {
|
||||
final DanceGroup group = group(DanceGroupType.REGULAR);
|
||||
final Show show = schedules.configure(school, null, settings(2, 1));
|
||||
schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId(), group.getId()), List.of()));
|
||||
assertFalse(schedules.violations(school, show.getId()).isEmpty());
|
||||
final SchoolSnapshot before = schools.exportSchool(school);
|
||||
assertThrows(ManagementException.class, () -> schedules.saveDraft(school, show.getId(), List.of()));
|
||||
assertThrows(
|
||||
ManagementException.class,
|
||||
() -> schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(999L))));
|
||||
assertEquals(before, schools.exportSchool(school));
|
||||
}
|
||||
|
||||
public @Test void xDefaultsRetainedOnDaypartReductionAndInvalidConfigurationIsAtomic() {
|
||||
final Show one = schedules.configure(school, null, settings(1, null));
|
||||
assertEquals(1, one.getPreferredDayParts());
|
||||
final Show show = schedules.configure(school, null, settings(3, null));
|
||||
assertEquals(2, show.getPreferredDayParts());
|
||||
schedules.configure(school, show.getId(), settings(1, null));
|
||||
assertEquals(2, show.getPreferredDayParts());
|
||||
assertThrows(ManagementException.class, () -> schools.beginScheduling(school, show.getId()));
|
||||
schedules.configure(school, show.getId(), settings(1, 1));
|
||||
assertTrue(schedules.violations(school, show.getId()).isEmpty());
|
||||
final int before = schools.shows(school).size();
|
||||
assertThrows(ManagementException.class, () -> schedules.configure(school, null, settings(1, 2)));
|
||||
assertEquals(before, schools.shows(school).size());
|
||||
schedules.configure(school, show.getId(), settings(0, null));
|
||||
assertFalse(schedules.violations(school, show.getId()).isEmpty());
|
||||
}
|
||||
|
||||
public @Test void timeoutPreservesAssignmentsAndIsNotReportedAsInfeasible() {
|
||||
group(DanceGroupType.REGULAR);
|
||||
final Show show = schedules.configure(school, null, settings(1, 1));
|
||||
new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
|
||||
final SchoolSnapshot before = schools.exportSchool(school);
|
||||
final ManagementException error = assertThrows(
|
||||
ManagementException.class,
|
||||
() -> new SchedulingService(schools, new OrToolsSchedulingEngine(0)).schedule(school, show.getId()));
|
||||
assertTrue(error.getMessage().contains("limit"));
|
||||
assertFalse(error.getMessage().contains("infeasible"));
|
||||
assertEquals(before, schools.exportSchool(school));
|
||||
}
|
||||
|
||||
public @Test void emptyGroupsSkippedAndAgeUsesFirstShowDateAcrossDates() {
|
||||
final DanceGroup ordinary = group(DanceGroupType.PRE_EDUCATION);
|
||||
group(DanceGroupType.PRE_SCHOOLER);
|
||||
schools.createGroup(school, new GroupDetails("Empty", DanceGroupType.SHOW_CASE, 0, 99, Set.of()));
|
||||
final Show show = schedules.configure(school, null, settings(3, 1));
|
||||
final SchedulingInput input = schools.beginScheduling(school, show.getId());
|
||||
assertEquals(2, input.groups().size());
|
||||
assertEquals(26, input.groups().getFirst().averageAge());
|
||||
assertEquals(1, input.groups().getFirst().appearances());
|
||||
schools.endScheduling(show.getId());
|
||||
new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
|
||||
assertFalse(show.isFinalized());
|
||||
final SchedulingInput stale = schools.beginScheduling(school, show.getId());
|
||||
final SchedulingResult result = new OrToolsSchedulingEngine().solve(stale);
|
||||
schools.updateDancer(
|
||||
school, ordinary.getDancers().iterator().next().getId(), "Dancer", LocalDate.of(2001, 1, 1));
|
||||
assertThrows(ManagementException.class, () -> schools.finishScheduling(school, stale, result));
|
||||
schools.endScheduling(show.getId());
|
||||
assertEquals(
|
||||
2, SchedulingInputs.of(schools.school(school), show, 0).groups().size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.empty;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@QuarkusTest
|
||||
class S18ResourceTest {
|
||||
private static final String ROOT = "/api/dance-schools";
|
||||
|
||||
private static long create(final String url, final Object body) {
|
||||
return given().contentType("application/json")
|
||||
.body(body)
|
||||
.post(url)
|
||||
.then()
|
||||
.statusCode(201)
|
||||
.extract()
|
||||
.jsonPath()
|
||||
.getLong("id");
|
||||
}
|
||||
|
||||
private static long dancer(final String school, final String name) {
|
||||
return create(school + "/dancers", Map.of("name", name, "birthDate", "1990-01-01"));
|
||||
}
|
||||
|
||||
private static long group(final String school, final long dancer) {
|
||||
final long group = create(
|
||||
school + "/dance-groups",
|
||||
Map.of("category", "Group", "type", "REGULAR", "startAge", 0, "endAge", 99, "styles", List.of()));
|
||||
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
|
||||
.then()
|
||||
.statusCode(204);
|
||||
return group;
|
||||
}
|
||||
|
||||
private static String show(final String school) {
|
||||
return school + "/shows/"
|
||||
+ create(
|
||||
school + "/shows",
|
||||
Map.of(
|
||||
"name",
|
||||
"Show",
|
||||
"preferredDayParts",
|
||||
1,
|
||||
"dayParts",
|
||||
List.of(Map.of("date", "2027-01-01", "type", "MORNING"))));
|
||||
}
|
||||
|
||||
public @Test void draftApiFinalizesOnlyValidSchedulesAndRelevantEditsInvalidateThem() {
|
||||
final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 lifecycle"));
|
||||
try {
|
||||
final long dancer = dancer(school, "Dancer");
|
||||
final long group = group(school, dancer);
|
||||
final String show = show(school);
|
||||
given().post(show + "/finalize").then().statusCode(400);
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("assignments", List.of(List.of(group))))
|
||||
.put(show + "/assignments")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("finalized", equalTo(false), "violations", empty());
|
||||
given().post(show + "/finalize").then().statusCode(200).body("finalized", equalTo(true));
|
||||
given().delete(school + "/dance-groups/" + group + "/dancers/" + dancer)
|
||||
.then()
|
||||
.statusCode(204);
|
||||
given().get(show).then().body("finalized", equalTo(false), "violations", hasSize(1));
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("assignments", List.of(List.of(group, group))))
|
||||
.put(show + "/assignments")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
final String snapshot = given().get(school + "/export").asString();
|
||||
given().contentType("application/json")
|
||||
.body(snapshot)
|
||||
.post(ROOT + "/import/preview")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
} finally {
|
||||
given().delete(school).then().statusCode(204);
|
||||
}
|
||||
}
|
||||
|
||||
public @Test void coachIdentityIsExplicitSchoolScopedAndRoundTrips() {
|
||||
final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 coaches"));
|
||||
try {
|
||||
final long dancer = dancer(school, "Coach");
|
||||
group(school, dancer);
|
||||
final String show = show(school);
|
||||
given().post(show + "/schedule").then().statusCode(200);
|
||||
given().post(show + "/finalize").then().statusCode(200);
|
||||
final long coach = given().contentType("application/json")
|
||||
.body(Map.of("dancerId", dancer))
|
||||
.post(school + "/coaches")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.jsonPath()
|
||||
.getLong("id");
|
||||
given().get(show).then().body("finalized", equalTo(false));
|
||||
given().get(school + "/coaches").then().body("[0].dancerId", equalTo((int) dancer));
|
||||
final String exported = given().get(school + "/export").asString();
|
||||
given().contentType("application/json")
|
||||
.body(exported)
|
||||
.post(ROOT + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
|
||||
.then()
|
||||
.statusCode(200);
|
||||
given().get(school + "/export").then().body("coaches[0].dancerId", equalTo("1"));
|
||||
final long importedDancer =
|
||||
given().get(school + "/dancers").jsonPath().getLong("[0].id");
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("dancerId", importedDancer))
|
||||
.put(school + "/coaches/0")
|
||||
.then()
|
||||
.statusCode(200);
|
||||
given().delete(school + "/coaches/0/dancer").then().statusCode(204);
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("dancerId", -1))
|
||||
.post(school + "/coaches")
|
||||
.then()
|
||||
.statusCode(404);
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("dancerId", importedDancer))
|
||||
.put(school + "/coaches/9999")
|
||||
.then()
|
||||
.statusCode(404);
|
||||
} finally {
|
||||
given().delete(school).then().statusCode(204);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import nl.agilecc.agiledancers.service.ManagementException;
|
||||
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class S18SolverTest {
|
||||
private GroupRequirement group(final long id, final int count, final boolean showcase, final long dancer) {
|
||||
return new GroupRequirement(id, count, showcase, Set.of(dancer), Set.of(), 20);
|
||||
}
|
||||
|
||||
private SchedulingResult solve(final SchedulingInput input) {
|
||||
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input);
|
||||
assertTrue(ScheduleRules.violations(input, result.assignments()).isEmpty());
|
||||
assertTrue(result.optimal());
|
||||
return result;
|
||||
}
|
||||
|
||||
public @Test void showcasesBookendEveryPartAndSharedDancersHaveChangingSpace() {
|
||||
final List<GroupRequirement> groups = List.of(
|
||||
group(1, 2, true, 1),
|
||||
group(2, 2, true, 2),
|
||||
group(3, 2, true, 3),
|
||||
group(4, 1, false, 1),
|
||||
group(5, 1, false, 5),
|
||||
group(6, 1, false, 6),
|
||||
group(7, 1, false, 7));
|
||||
final SchedulingInput input = new SchedulingInput(1, 0, 2, groups, 1, Set.of());
|
||||
final SchedulingResult result = solve(input);
|
||||
for (final List<Long> part : result.assignments()) {
|
||||
assertTrue(part.getFirst() <= 3);
|
||||
assertTrue(part.getLast() <= 3);
|
||||
}
|
||||
assertEquals(
|
||||
1,
|
||||
result.assignments().stream()
|
||||
.filter((final List<Long> p) -> p.contains(4L))
|
||||
.count());
|
||||
}
|
||||
|
||||
public @Test void ordinaryDancerMustShareDaypartsAcrossTheirGroups() {
|
||||
final SchedulingInput input = new SchedulingInput(
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
List.of(group(1, 1, false, 1), group(2, 1, false, 1), group(3, 1, false, 3), group(4, 1, false, 4)),
|
||||
1,
|
||||
Set.of());
|
||||
assertThrows(ManagementException.class, () -> solve(input));
|
||||
final SchedulingInput coach = new SchedulingInput(1, 0, 2, input.groups(), 1, Set.of(1L));
|
||||
final SchedulingResult result = solve(coach);
|
||||
assertTrue(result.assignments().stream().allMatch((final List<Long> p) -> p.size() == 2));
|
||||
}
|
||||
|
||||
public @Test void showcaseExcessTakesPriorityOverPerfectBalance() {
|
||||
final SchedulingInput input = new SchedulingInput(
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
List.of(
|
||||
group(1, 2, true, 1),
|
||||
group(2, 1, false, 1),
|
||||
group(3, 1, false, 1),
|
||||
group(4, 1, false, 4),
|
||||
group(5, 1, false, 5),
|
||||
group(6, 1, false, 6),
|
||||
group(7, 1, false, 7),
|
||||
group(8, 1, false, 8),
|
||||
group(9, 1, false, 9),
|
||||
group(10, 1, false, 10),
|
||||
group(11, 1, false, 11)),
|
||||
1,
|
||||
Set.of());
|
||||
final SchedulingResult result = solve(input);
|
||||
assertTrue(result.assignments().stream().anyMatch((final List<Long> p) -> p.containsAll(List.of(2L, 3L))));
|
||||
assertEquals(
|
||||
2,
|
||||
Math.abs(result.assignments().get(0).size()
|
||||
- result.assignments().get(1).size()));
|
||||
}
|
||||
|
||||
public @Test void changingTimeAppliesToCoachesAndShowcasesWithoutExceptions() {
|
||||
final SchedulingInput input =
|
||||
new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 1)), 1, Set.of(1L));
|
||||
assertThrows(ManagementException.class, () -> solve(input));
|
||||
assertTrue(ScheduleRules.violations(input, List.of(List.of(1L, 2L))).stream()
|
||||
.anyMatch((final String s) -> s.contains("two intervening")));
|
||||
}
|
||||
|
||||
public @Test void singleShowcaseCanUseEitherBoundaryAndNoShowcaseHasNoBoundaryRule() {
|
||||
solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 2)), 1, Set.of()));
|
||||
solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of()));
|
||||
}
|
||||
|
||||
public @Test void sharedValidatorRejectsMalformedAndIncompleteDrafts() {
|
||||
final SchedulingInput input = new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of());
|
||||
assertFalse(ScheduleRules.violations(input, null).isEmpty());
|
||||
assertFalse(ScheduleRules.violations(input, List.of()).isEmpty());
|
||||
assertFalse(ScheduleRules.violations(input, List.of(List.of())).isEmpty());
|
||||
assertFalse(ScheduleRules.violations(input, List.of(List.of(1L, 1L))).isEmpty());
|
||||
assertFalse(ScheduleRules.violations(input, List.of(List.of(99L))).isEmpty());
|
||||
final SchedulingInput empty = new SchedulingInput(1, 0, 0, List.of(), 1, Set.of());
|
||||
assertFalse(ScheduleRules.violations(empty, List.of()).isEmpty());
|
||||
}
|
||||
|
||||
public @Test void varietyUsesStyleOverlapAndFiveYearLinearAgePenalty() {
|
||||
final GroupRequirement hiphop =
|
||||
new GroupRequirement(1, 1, false, Set.of(1L), Set.of(DanceGroupStyle.HIPHOP), 10);
|
||||
final GroupRequirement mixed = new GroupRequirement(
|
||||
2, 1, false, Set.of(2L), Set.of(DanceGroupStyle.HIPHOP, DanceGroupStyle.MODERN), 12.5);
|
||||
final GroupRequirement modern =
|
||||
new GroupRequirement(3, 1, false, Set.of(3L), Set.of(DanceGroupStyle.MODERN), 15);
|
||||
assertEquals(1_000_000, VarietyScore.penalty(hiphop, mixed));
|
||||
assertEquals(0, VarietyScore.penalty(hiphop, modern));
|
||||
final List<Long> order = solve(new SchedulingInput(1, 0, 1, List.of(hiphop, mixed, modern), 1, Set.of()))
|
||||
.assignments()
|
||||
.getFirst();
|
||||
assertEquals(1, Math.abs(order.indexOf(1L) - order.indexOf(3L)));
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,12 @@ class SchedulingTest {
|
||||
}
|
||||
|
||||
private DanceGroup group(final DanceGroupType type) {
|
||||
return service.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
|
||||
final DanceGroup group = service.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
|
||||
service.addGroupDancer(
|
||||
school,
|
||||
group.getId(),
|
||||
service.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
|
||||
return group;
|
||||
}
|
||||
|
||||
private void run(final Show show) {
|
||||
@@ -86,7 +91,7 @@ class SchedulingTest {
|
||||
final Show 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(2, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
|
||||
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
|
||||
final var counts = new EnumMap<>(show.getAppearances());
|
||||
counts.put(DanceGroupType.PRE_SCHOOLER, 2);
|
||||
|
||||
@@ -171,15 +171,17 @@ class SchoolManagementResourceTest {
|
||||
final String show = school + "/shows/" + create(school + "/shows", SHOW);
|
||||
given().post(show + "/schedule").then().statusCode(400);
|
||||
final long group = create(school + "/dance-groups", GROUP);
|
||||
final long dancer = create(school + "/dancers", DANCER);
|
||||
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
|
||||
.then()
|
||||
.statusCode(204);
|
||||
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
|
||||
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
|
||||
final String before = given().get(show).asString();
|
||||
final String counts =
|
||||
"{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
|
||||
for (final String count : new String[] {"0", "2", "1.5", "null"}) {
|
||||
for (final String count : new String[] {"0", "2", "1.5", "7"}) {
|
||||
final String body =
|
||||
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":"
|
||||
+ counts.formatted(count) + "}";
|
||||
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"preferredDayParts\":"
|
||||
+ count + "}";
|
||||
put(show, body).statusCode(400);
|
||||
given().get(show).then().body(equalTo(before));
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class SchoolSnapshotResourceTest {
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header("Content-Disposition", containsString(".json"))
|
||||
.body("formatVersion", equalTo(2))
|
||||
.body("formatVersion", equalTo(3))
|
||||
.extract()
|
||||
.asString();
|
||||
try {
|
||||
@@ -58,7 +58,7 @@ class SchoolSnapshotResourceTest {
|
||||
for (final String bad : new String[] {
|
||||
"{",
|
||||
"null",
|
||||
json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
|
||||
json.replace("\"formatVersion\" : 3", "\"formatVersion\" : 1"),
|
||||
json.replace("\"name\" :", "\"unknown\" :"),
|
||||
json + " {}",
|
||||
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")
|
||||
|
||||
@@ -58,7 +58,7 @@ class SnapshotCollectionsTest {
|
||||
@Test
|
||||
public void requestCollectionsRetainTheirJsonValuesAndCannotBeMutatedThroughAliases() {
|
||||
final Map<DanceGroupType, Integer> counts = new LinkedHashMap<>(Map.of(DanceGroupType.REGULAR, 1));
|
||||
final ShowRequest show = new ShowRequest("Show", List.of(), counts);
|
||||
final ShowRequest show = new ShowRequest("Show", List.of(), counts, null);
|
||||
counts.clear();
|
||||
assertEquals(1, show.appearances().get(DanceGroupType.REGULAR));
|
||||
assertThrows(
|
||||
|
||||
@@ -29,7 +29,7 @@ class SchedulingServiceTest {
|
||||
public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
|
||||
final var schools = new SchoolManagementService();
|
||||
final long school = schools.createSchool("School").getId();
|
||||
schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()));
|
||||
populatedGroup(schools, school);
|
||||
final Show show = schools.createShow(
|
||||
school,
|
||||
"Show",
|
||||
@@ -62,7 +62,7 @@ class SchedulingServiceTest {
|
||||
public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
|
||||
final var schools = new SchoolManagementService();
|
||||
final long school = schools.createSchool("School").getId();
|
||||
schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()));
|
||||
populatedGroup(schools, school);
|
||||
final Show show = schools.createShow(
|
||||
school,
|
||||
"Show",
|
||||
@@ -84,7 +84,7 @@ class SchedulingServiceTest {
|
||||
ManagementExceptionKind.CONFLICT,
|
||||
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
|
||||
.getKind());
|
||||
schools.createGroup(school, new GroupDetails("New group", DanceGroupType.REGULAR, 0, 99, Set.of()));
|
||||
populatedGroup(schools, school);
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
@@ -98,6 +98,16 @@ class SchedulingServiceTest {
|
||||
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size());
|
||||
}
|
||||
|
||||
private static void populatedGroup(final SchoolManagementService schools, final long school) {
|
||||
final long group = schools.createGroup(
|
||||
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
|
||||
.getId();
|
||||
schools.addGroupDancer(
|
||||
school,
|
||||
group,
|
||||
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
|
||||
}
|
||||
|
||||
private static SchedulingEngine blockingEngine(final CountDownLatch entered, final CountDownLatch release) {
|
||||
final SchedulingEngine engine = (final SchedulingInput input) -> {
|
||||
entered.countDown();
|
||||
|
||||
Reference in New Issue
Block a user