S8 apply Palantir formatting to maintained Java sources
All checks were successful
Build, test and publish / verify (push) Successful in 1m39s

This commit is contained in:
2026-10-30 15:50:51 +01:00
parent 327a365550
commit 7e8b3a2cc7
55 changed files with 1907 additions and 713 deletions

View File

@@ -1,10 +1,9 @@
package nl.agilecc.agiledancers.model;
import lombok.*;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import lombok.*;
@Getter
@Setter
@@ -19,9 +18,7 @@ public abstract class AbstractEntity {
}
private <T> Set<T> emptyIfNull(Set<T> set) {
return set == null ?
new HashSet<>() :
set;
return set == null ? new HashSet<>() : set;
}
protected <T> Set<T> unmodifiableNullSafe(Set<T> set) {

View File

@@ -1,12 +1,11 @@
package nl.agilecc.agiledancers.model;
import java.util.LinkedHashSet;
import java.util.Set;
import lombok.*;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import java.util.Set;
import java.util.LinkedHashSet;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@@ -15,21 +14,27 @@ public class DanceGroup extends AbstractEntity {
@ToString.Include
@Setter
private String category;
@ToString.Include
@Setter
private DanceGroupType type;
@ToString.Include
@Setter
private int startAge;
@ToString.Include
@Setter
private int endAge;
@ToString.Include
@Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>();
@ToString.Include
@Builder.Default
private final Set<DanceGroupStyle> styles = new LinkedHashSet<>();
@ToString.Include
private Coach coach;

View File

@@ -1,9 +1,8 @@
package nl.agilecc.agiledancers.model;
import lombok.*;
import java.util.Set;
import java.util.LinkedHashSet;
import java.util.Set;
import lombok.*;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -12,14 +11,19 @@ import java.util.LinkedHashSet;
public class DanceSchool extends AbstractEntity {
@Builder.Default
private final java.util.UUID key = java.util.UUID.randomUUID();
@Setter
private String name;
@Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>();
@Builder.Default
private final Set<DanceGroup> danceGroups = new LinkedHashSet<>();
@Builder.Default
private final Set<Show> shows = new LinkedHashSet<>();
@Builder.Default
private final Set<Coach> coaches = new LinkedHashSet<>();

View File

@@ -1,9 +1,8 @@
package nl.agilecc.agiledancers.model;
import lombok.*;
import java.time.LocalDate;
import java.time.Period;
import lombok.*;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -13,6 +12,7 @@ public class Dancer extends AbstractEntity {
@ToString.Include
@Setter
private String name;
@ToString.Include
@Setter
private LocalDate birthDate;

View File

@@ -1,11 +1,10 @@
package nl.agilecc.agiledancers.model;
import java.time.LocalDate;
import java.util.List;
import lombok.*;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import java.util.List;
import java.time.LocalDate;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@@ -13,7 +12,9 @@ import java.time.LocalDate;
public class DayPart extends AbstractEntity {
@ToString.Include
private final DayPartType type;
private final LocalDate date;
@ToString.Include
@Setter
private List<DanceGroup> danceGroups;

View File

@@ -1,9 +1,8 @@
package nl.agilecc.agiledancers.model;
import lombok.*;
import java.util.List;
import java.util.Map;
import lombok.*;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@Getter
@@ -13,8 +12,10 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public class Show extends AbstractEntity {
@Setter
private String name;
@Setter
private Map<DanceGroupType, Integer> appearances;
@ToString.Include
@Setter
private List<DayPart> dayParts;

View File

@@ -1,5 +1,10 @@
package nl.agilecc.agiledancers.model.specifications;
public enum DanceGroupStyle {
HIPHOP, JAZZ, MODERN, STREETDANCE, CLASSIC, BREAKDANCE
HIPHOP,
JAZZ,
MODERN,
STREETDANCE,
CLASSIC,
BREAKDANCE
}

View File

@@ -1,5 +1,9 @@
package nl.agilecc.agiledancers.model.specifications;
public enum DanceGroupType {
SHOW_CASE, PRE_EDUCATION, SELECTION, REGULAR, PRE_SCHOOLER
SHOW_CASE,
PRE_EDUCATION,
SELECTION,
REGULAR,
PRE_SCHOOLER
}

View File

@@ -1,5 +1,7 @@
package nl.agilecc.agiledancers.model.specifications;
public enum DayPartType {
MORNING, AFTERNOON, EVENING;
MORNING,
AFTERNOON,
EVENING;
}

View File

@@ -1,8 +1,7 @@
package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.AbstractEntity;
import java.util.*;
import nl.agilecc.agiledancers.model.AbstractEntity;
public abstract class AbstractRepository<T extends AbstractEntity> {
private long sequence = 0L;
@@ -28,6 +27,7 @@ public abstract class AbstractRepository<T extends AbstractEntity> {
public T findById(long id) {
return entities.get(id);
}
public void delete(long id) {
entities.remove(id);
}

View File

@@ -2,6 +2,4 @@ package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.Coach;
public class CoachRepository extends AbstractRepository<Coach> {
}
public class CoachRepository extends AbstractRepository<Coach> {}

View File

@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.DanceGroup;
public class DanceGroupRepository extends AbstractRepository<DanceGroup> {
}
public class DanceGroupRepository extends AbstractRepository<DanceGroup> {}

View File

@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.DanceSchool;
public class DanceSchoolRepository extends AbstractRepository<DanceSchool> {
}
public class DanceSchoolRepository extends AbstractRepository<DanceSchool> {}

View File

@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.Dancer;
public class DancerRepository extends AbstractRepository<Dancer> {
}
public class DancerRepository extends AbstractRepository<Dancer> {}

View File

@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.DayPart;
public class DayPartRepository extends AbstractRepository<DayPart> {
}
public class DayPartRepository extends AbstractRepository<DayPart> {}

View File

@@ -2,5 +2,4 @@ package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.Show;
public class ShowRepository extends AbstractRepository<Show> {
}
public class ShowRepository extends AbstractRepository<Show> {}

View File

@@ -5,12 +5,10 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@Path("/api/dance-schools/{schoolId}/dance-groups")
@Produces(MediaType.APPLICATION_JSON)
@@ -35,15 +33,26 @@ public class DanceGroupResource {
@POST
public Response create(@NotNull @Valid GroupRequest request, @Context UriInfo uriInfo) {
var entity = service.createGroup(schoolId, request.category(), request.type(), request.startAge(), request.endAge(), request.styles());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(GroupView.of(entity)).build();
var entity = service.createGroup(
schoolId, request.category(), request.type(), request.startAge(), request.endAge(), request.styles());
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(GroupView.of(entity))
.build();
}
@PUT
@Path("/{id}")
public GroupView update(@PathParam("id") long id, @NotNull @Valid GroupRequest request) {
return GroupView.of(service.updateGroup(schoolId, id, request.category(), request.type(), request.startAge(), request.endAge(), request.styles()));
return GroupView.of(service.updateGroup(
schoolId,
id,
request.category(),
request.type(),
request.startAge(),
request.endAge(),
request.styles()));
}
@DELETE

View File

@@ -5,12 +5,10 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
@@ -33,8 +31,11 @@ public class DanceSchoolResource {
@POST
public Response create(@NotNull @Valid SchoolRequest request, @Context UriInfo uriInfo) {
var entity = service.createSchool(request.name());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(SchoolView.of(entity)).build();
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(SchoolView.of(entity))
.build();
}
@PUT

View File

@@ -5,12 +5,10 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@Path("/api/dance-schools/{schoolId}/dancers")
@Produces(MediaType.APPLICATION_JSON)
@@ -36,8 +34,11 @@ public class DancerResource {
@POST
public Response create(@NotNull @Valid DancerRequest request, @Context UriInfo uriInfo) {
var entity = service.createDancer(schoolId, request.name(), request.birthDate());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(DancerView.of(entity)).build();
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(DancerView.of(entity))
.build();
}
@PUT

View File

@@ -4,8 +4,8 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.rest.apimodel.ErrorView;
import nl.agilecc.agiledancers.service.ManagementException;
@Provider
public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> {
@@ -16,7 +16,9 @@ public class ManagementExceptionMapper implements ExceptionMapper<ManagementExce
case INVALID_INPUT -> 400;
case CONFLICT -> 409;
};
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE)
.entity(new ErrorView(exception.getMessage())).build();
return Response.status(status)
.type(MediaType.APPLICATION_JSON_TYPE)
.entity(new ErrorView(exception.getMessage()))
.build();
}
}

View File

@@ -1,35 +1,50 @@
package nl.agilecc.agiledancers.rest;
import nl.agilecc.agiledancers.service.model.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.*;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.model.*;
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
public class SchoolSnapshotResource {
@Inject SchoolManagementService service;
@Inject ObjectMapper mapper;
@Inject
SchoolManagementService service;
@GET @Path("/{id}/export")
@Inject
ObjectMapper mapper;
@GET
@Path("/{id}/export")
public Response export(@PathParam("id") long id) throws Exception {
String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(service.exportSchool(id));
return Response.ok(json).header("Content-Disposition", "attachment; filename=school-" + id + ".json").build();
return Response.ok(json)
.header("Content-Disposition", "attachment; filename=school-" + id + ".json")
.build();
}
@POST @Path("/import/preview") @Consumes(MediaType.APPLICATION_JSON)
public ImportPreview preview(String json) { return service.preview(parse(json)); }
@POST @Path("/import") @Consumes(MediaType.APPLICATION_JSON)
@POST
@Path("/import/preview")
@Consumes(MediaType.APPLICATION_JSON)
public ImportPreview preview(String json) {
return service.preview(parse(json));
}
@POST
@Path("/import")
@Consumes(MediaType.APPLICATION_JSON)
public SchoolView apply(String json, @QueryParam("replaceId") Long replaceId) {
return SchoolView.of(service.importSchool(parse(json), replaceId));
}
private SchoolSnapshot parse(String json) {
try {
SchoolSnapshot snapshot = mapper.copy().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
SchoolSnapshot snapshot = mapper.copy()
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
@@ -38,7 +53,8 @@ public class SchoolSnapshotResource {
if (snapshot == null) throw new IllegalArgumentException("Het bestand bevat geen school");
return snapshot;
} catch (Exception e) {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
}
}
}

View File

@@ -5,15 +5,13 @@ import jakarta.validation.Valid;
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 java.util.List;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
import java.util.List;
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;
@Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON)
@@ -42,8 +40,11 @@ public class ShowResource {
@POST
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
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();
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(ShowView.of(entity))
.build();
}
@PUT
@@ -53,8 +54,13 @@ public class ShowResource {
}
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();
return request.dayParts().stream()
.map(p -> DayPart.builder()
.date(p.date())
.type(p.type())
.danceGroups(List.of())
.build())
.toList();
}
@POST
@@ -62,8 +68,8 @@ public class ShowResource {
@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");
return new SchedulingView(
result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven");
}
@DELETE

View File

@@ -5,4 +5,5 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import java.time.LocalDate;
public record DancerRequest(@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {}
public record DancerRequest(
@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {}

View File

@@ -1,7 +1,7 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.Dancer;
import java.time.LocalDate;
import nl.agilecc.agiledancers.model.Dancer;
public record DancerView(long id, String name, LocalDate birthDate) {
public static DancerView of(Dancer dancer) {

View File

@@ -1,5 +1,8 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.time.LocalDate;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
public record DayPartRequest(@NotNull LocalDate date, @NotNull DayPartType type) {}
public record DayPartRequest(
@NotNull LocalDate date, @NotNull DayPartType type) {}

View File

@@ -1,5 +1,7 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import java.util.List;
import java.time.LocalDate;
import java.util.List;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
public record DayPartView(LocalDate date, DayPartType type, List<Long> danceGroupIds) {}

View File

@@ -3,10 +3,13 @@ package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import java.util.Set;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record GroupRequest(@NotBlank String category, @NotNull DanceGroupType type,
@NotNull @Min(0) Integer startAge, @NotNull @Min(0) Integer endAge,
@NotNull Set<@NotNull DanceGroupStyle> styles) {}
public record GroupRequest(
@NotBlank String category,
@NotNull DanceGroupType type,
@NotNull @Min(0) Integer startAge,
@NotNull @Min(0) Integer endAge,
@NotNull Set<@NotNull DanceGroupStyle> styles) {}

View File

@@ -1,17 +1,28 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record GroupView(long id, String category, DanceGroupType type, int startAge, int endAge,
Set<DanceGroupStyle> styles, List<Long> dancerIds) {
public record GroupView(
long id,
String category,
DanceGroupType type,
int startAge,
int endAge,
Set<DanceGroupStyle> styles,
List<Long> dancerIds) {
public static GroupView of(DanceGroup group) {
return new GroupView(group.getId(), group.getCategory(), group.getType(), group.getStartAge(),
group.getEndAge(), Set.copyOf(group.getStyles()),
return new GroupView(
group.getId(),
group.getCategory(),
group.getType(),
group.getStartAge(),
group.getEndAge(),
Set.copyOf(group.getStyles()),
group.getDancers().stream().map(Dancer::getId).toList());
}
}

View File

@@ -1,2 +1,3 @@
package nl.agilecc.agiledancers.rest.apimodel;
public record SchedulingView(boolean optimal, String message) {}

View File

@@ -1,15 +1,16 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.List;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Show;
import java.util.List;
public record SchoolView(long id, String name, List<Long> dancerIds,
List<Long> danceGroupIds, List<Long> showIds) {
public record SchoolView(long id, String name, List<Long> dancerIds, List<Long> danceGroupIds, List<Long> showIds) {
public static SchoolView of(DanceSchool school) {
return new SchoolView(school.getId(), school.getName(),
return new SchoolView(
school.getId(),
school.getName(),
school.getDancers().stream().map(Dancer::getId).toList(),
school.getDanceGroups().stream().map(DanceGroup::getId).toList(),
school.getShows().stream().map(Show::getId).toList());

View File

@@ -1,7 +1,11 @@
package nl.agilecc.agiledancers.rest.apimodel;
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) {}
public record ShowRequest(
@NotBlank String name,
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
Map<DanceGroupType, Integer> appearances) {}

View File

@@ -1,10 +1,22 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.*;
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.getDayParts().stream()
.map(p -> new DayPartView(p.getDate(), p.getType(), p.getDanceGroups().stream().map(DanceGroup::getId).toList())).toList(), show.getAppearances());
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());
}
}

View File

@@ -1,5 +1,6 @@
package nl.agilecc.agiledancers.service;
import java.util.Set;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Dancer;
@@ -7,12 +8,17 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.repository.DanceGroupRepository;
import java.util.Set;
public class DanceGroupService {
private final DanceGroupRepository danceGroupRepository = new DanceGroupRepository();
public DanceGroup add(String category, DanceGroupType type, int startAge, int endAge, Set<Dancer> dancers, Set<DanceGroupStyle> styles, Coach coach) {
public DanceGroup add(
String category,
DanceGroupType type,
int startAge,
int endAge,
Set<Dancer> dancers,
Set<DanceGroupStyle> styles,
Coach coach) {
DanceGroup danceGroup = DanceGroup.builder()
.category(category)
.type(type)
@@ -20,7 +26,8 @@ public class DanceGroupService {
.endAge(endAge)
.dancers(dancers)
.styles(styles)
.coach(coach).build();
.coach(coach)
.build();
return danceGroupRepository.add(danceGroup);
}

View File

@@ -1,10 +1,9 @@
package nl.agilecc.agiledancers.service;
import java.time.LocalDate;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.repository.DancerRepository;
import java.time.LocalDate;
public class DancerService {
private final DancerRepository dancerRepository = new DancerRepository();

View File

@@ -1,3 +1,7 @@
package nl.agilecc.agiledancers.service;
public enum ManagementExceptionKind { NOT_FOUND, INVALID_INPUT, CONFLICT }
public enum ManagementExceptionKind {
NOT_FOUND,
INVALID_INPUT,
CONFLICT
}

View File

@@ -1,22 +1,32 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton
public class SchedulingService {
@Inject SchoolManagementService schools;
@Inject SchedulingEngine engine;
@Inject
SchoolManagementService schools;
@Inject
SchedulingEngine engine;
public 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(ManagementExceptionKind.INVALID_INPUT, "Solver failed. Existing assignments were preserved. Please run scheduling again.");
} finally { schools.endScheduling(showId); }
} catch (ManagementException e) {
throw e;
} catch (RuntimeException | LinkageError e) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"Solver failed. Existing assignments were preserved. Please run scheduling again.");
} finally {
schools.endScheduling(showId);
}
}
}

View File

@@ -1,16 +1,17 @@
package nl.agilecc.agiledancers.service;
import static nl.agilecc.agiledancers.service.ManagementExceptionKind.*;
import jakarta.inject.Singleton;
import java.time.LocalDate;
import java.util.*;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.model.*;
import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.repository.*;
import java.time.LocalDate;
import java.util.*;
import static nl.agilecc.agiledancers.service.ManagementExceptionKind.*;
import nl.agilecc.agiledancers.service.model.*;
/** Owns the in-memory school aggregates; all writes go through this shared service. */
@Singleton
@@ -22,31 +23,52 @@ public class SchoolManagementService {
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); }
private void touch(Show show) {
revisions.merge(show.getId(), 1L, Long::sum);
}
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.");
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.");
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(g -> new GroupRequirement(g.getId(), show.getAppearances().get(g.getType()))).toList());
return new SchedulingInput(
showId,
revisions.getOrDefault(showId, 0L),
show.getDayParts().size(),
current.stream()
.map(g -> new GroupRequirement(
g.getId(), show.getAppearances().get(g.getType())))
.toList());
}
public synchronized void finishScheduling(long schoolId, SchedulingInput input, SchedulingResult result) {
Show show;
try { show = show(schoolId, input.showId()); }
catch (ManagementException e) { throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again."); }
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.");
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() || !current.keySet().containsAll(part))
if (part == null
|| new HashSet<>(part).size() != part.size()
|| !current.keySet().containsAll(part))
throw new ManagementException(INVALID_INPUT, "Solver returned invalid group assignments.");
}
for (GroupRequirement group : input.groups()) {
@@ -57,12 +79,20 @@ public class SchoolManagementService {
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());
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 void endScheduling(long showId) {
running.remove(showId);
}
public synchronized List<DanceSchool> schools() {
return schools.findAll();
@@ -100,7 +130,8 @@ public class SchoolManagementService {
public synchronized Dancer createDancer(long schoolId, String name, LocalDate birthDate) {
DanceSchool school = school(schoolId);
Dancer dancer = dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
Dancer dancer =
dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
school.addDancer(dancer);
return dancer;
}
@@ -128,19 +159,35 @@ public class SchoolManagementService {
return member(school(schoolId).getDanceGroups(), groupId, "Dance group");
}
public synchronized DanceGroup createGroup(long schoolId, String category, DanceGroupType type,
int startAge, int endAge, Set<DanceGroupStyle> styles) {
public synchronized DanceGroup createGroup(
long schoolId,
String category,
DanceGroupType type,
int startAge,
int endAge,
Set<DanceGroupStyle> styles) {
DanceSchool school = school(schoolId);
validateAgeRange(startAge, endAge);
DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type)
.startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build());
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;
}
public synchronized DanceGroup updateGroup(long schoolId, long groupId, String category, DanceGroupType type,
int startAge, int endAge, Set<DanceGroupStyle> styles) {
public synchronized DanceGroup updateGroup(
long schoolId,
long groupId,
String category,
DanceGroupType type,
int startAge,
int endAge,
Set<DanceGroupStyle> styles) {
DanceGroup group = group(schoolId, groupId);
validateAgeRange(startAge, endAge);
group.setCategory(category);
@@ -178,23 +225,39 @@ public class SchoolManagementService {
return member(school(schoolId).getShows(), showId, "Show");
}
public synchronized Show createShow(long schoolId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
public synchronized Show createShow(
long schoolId, String name, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
DanceSchool school = school(schoolId);
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());
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, List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
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();
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();
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.setDayParts(retained);
show.setAppearances(Map.copyOf(counts));
@@ -212,16 +275,23 @@ public class SchoolManagementService {
shows.delete(showId);
}
public synchronized SchoolSnapshot exportSchool(long id) {
return SchoolSnapshot.of(school(id));
}
public synchronized ImportPreview preview(SchoolSnapshot snapshot) {
snapshot.restore();
DanceSchool existing = schools.findAll().stream().filter(s -> s.getKey().equals(snapshot.key())).findFirst().orElse(null);
return new ImportPreview(snapshot.name(), snapshot.dancers().size(), snapshot.groups().size(), snapshot.shows().size(),
existing == null ? null : existing.getId(), existing == null ? null : existing.getName());
DanceSchool existing = schools.findAll().stream()
.filter(s -> s.getKey().equals(snapshot.key()))
.findFirst()
.orElse(null);
return new ImportPreview(
snapshot.name(),
snapshot.dancers().size(),
snapshot.groups().size(),
snapshot.shows().size(),
existing == null ? null : existing.getId(),
existing == null ? null : existing.getName());
}
public synchronized DanceSchool importSchool(SchoolSnapshot snapshot, Long replaceId) {
@@ -244,21 +314,49 @@ public class SchoolManagementService {
List<DanceGroup> rebuiltGroups = new ArrayList<>();
Map<DanceGroup, DanceGroup> replacements = new IdentityHashMap<>();
for (DanceGroup g : importedGroups) {
DanceGroup rebuilt = DanceGroup.builder().category(g.getCategory()).type(g.getType())
.startAge(g.getStartAge()).endAge(g.getEndAge()).styles(new LinkedHashSet<>(g.getStyles()))
.coach(g.getCoach()).dancers(new LinkedHashSet<>(memberships.get(g))).build();
rebuilt.setId(g.getId()); rebuiltGroups.add(rebuilt); replacements.put(g, rebuilt);
DanceGroup rebuilt = DanceGroup.builder()
.category(g.getCategory())
.type(g.getType())
.startAge(g.getStartAge())
.endAge(g.getEndAge())
.styles(new LinkedHashSet<>(g.getStyles()))
.coach(g.getCoach())
.dancers(new LinkedHashSet<>(memberships.get(g)))
.build();
rebuilt.setId(g.getId());
rebuiltGroups.add(rebuilt);
replacements.put(g, rebuilt);
}
List<Show> rebuiltShows = new ArrayList<>();
for (Show show : importedShows) {
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);
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())
.dancers(new LinkedHashSet<>(importedDancers))
.danceGroups(new LinkedHashSet<>(rebuiltGroups))
.shows(new LinkedHashSet<>(rebuiltShows))
.coaches(new LinkedHashSet<>(staged.getCoaches()))
.build();
if (replaceId != null) {
deleteSchool(replaceId);
imported.setId(replaceId);
}
DanceSchool imported = DanceSchool.builder().key(staged.getKey()).name(staged.getName())
.dancers(new LinkedHashSet<>(importedDancers)).danceGroups(new LinkedHashSet<>(rebuiltGroups))
.shows(new LinkedHashSet<>(rebuiltShows)).coaches(new LinkedHashSet<>(staged.getCoaches())).build();
if (replaceId != null) { deleteSchool(replaceId); imported.setId(replaceId); }
importedDancers.forEach(dancers::add);
rebuiltGroups.forEach(groups::add);
rebuiltShows.forEach(shows::add);
@@ -272,7 +370,9 @@ public class SchoolManagementService {
}
private static <T extends AbstractEntity> T member(Collection<T> entities, long id, String type) {
return entities.stream().filter(entity -> entity.getId() == id).findFirst()
return entities.stream()
.filter(entity -> entity.getId() == id)
.findFirst()
.orElseThrow(() -> new ManagementException(NOT_FOUND, type + " " + id + " not found in this school"));
}

View File

@@ -1,33 +1,51 @@
package nl.agilecc.agiledancers.service;
import java.util.*;
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);
});
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(ManagementExceptionKind.INVALID_INPUT, "Configure zero to six dayparts with unique date/type combinations.");
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(
ManagementExceptionKind.INVALID_INPUT,
"Configure zero to six dayparts with unique date/type combinations.");
if (counts == null) invalidCounts();
if (parts.isEmpty() && counts.isEmpty()) return;
if (counts.size() != DanceGroupType.values().length
|| Arrays.stream(DanceGroupType.values()).anyMatch(t -> counts.get(t) == null || counts.get(t) < 1
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts();
|| 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(ManagementExceptionKind.INVALID_INPUT,
"Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are retained when dayparts are removed.");
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are"
+ " retained when dayparts are removed.");
}
}

View File

@@ -1,8 +1,15 @@
package nl.agilecc.agiledancers.service.model;
import java.util.List;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record Group(String id, String category, DanceGroupType type, Integer startAge, Integer endAge,
List<DanceGroupStyle> styles, List<String> dancerIds, String coachId) {}
public record Group(
String id,
String category,
DanceGroupType type,
Integer startAge,
Integer endAge,
List<DanceGroupStyle> styles,
List<String> dancerIds,
String coachId) {}

View File

@@ -1,31 +1,62 @@
package nl.agilecc.agiledancers.service.model;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ShowConfiguration;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import java.time.LocalDate;
import java.util.*;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
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. */
public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<Person> dancers,
List<Trainer> coaches, List<Group> groups, List<Performance> shows) {
public record SchoolSnapshot(
Integer formatVersion,
UUID key,
String name,
List<Person> dancers,
List<Trainer> coaches,
List<Group> groups,
List<Performance> shows) {
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(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.getDayParts().stream().map(p -> new Part(p.getDate(), p.getType(),
p.getDanceGroups().stream().map(groups::get).toList())).toList(), show.getAppearances())).toList());
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.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) {
@@ -36,57 +67,129 @@ public record SchoolSnapshot(Integer formatVersion, UUID key, String name, List<
public DanceSchool restore() {
check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
check(key != null, "key ontbreekt"); text(name, "schoolnaam");
check(key != null, "key ontbreekt");
text(name, "schoolnaam");
DanceSchool school = DanceSchool.builder().key(key).name(name).build();
Map<String, Dancer> dancerMap = new LinkedHashMap<>();
for (Person d : list(dancers, "dancers")) {
check(d != null, "Lege danser"); text(d.name(), "dansernaam");
check(d.birthDate() != null && !d.birthDate().isAfter(LocalDate.now()), "Ongeldige geboortedatum voor " + d.name());
Dancer dancer = Dancer.builder().name(d.name()).birthDate(d.birthDate()).build();
check(d != null, "Lege danser");
text(d.name(), "dansernaam");
check(
d.birthDate() != null && !d.birthDate().isAfter(LocalDate.now()),
"Ongeldige geboortedatum voor " + d.name());
Dancer dancer =
Dancer.builder().name(d.name()).birthDate(d.birthDate()).build();
// Assign temporary distinct IDs before inserting entities into sets.
dancer.setId((long) dancerMap.size());
put(dancerMap, d.id(), dancer); school.addDancer(dancer);
put(dancerMap, d.id(), dancer);
school.addDancer(dancer);
}
Map<String, Coach> coachMap = new LinkedHashMap<>();
for (Trainer c : list(coaches, "coaches")) {
check(c != null, "Lege coach"); text(c.name(), "coachnaam");
Coach coach = Coach.builder().name(c.name()).build(); coach.setId((long) coachMap.size());
put(coachMap, c.id(), coach); school.addCoach(coach);
check(c != null, "Lege coach");
text(c.name(), "coachnaam");
Coach coach = Coach.builder().name(c.name()).build();
coach.setId((long) coachMap.size());
put(coachMap, c.id(), coach);
school.addCoach(coach);
}
Map<String, DanceGroup> groupMap = new LinkedHashMap<>();
for (Group g : list(groups, "groups")) {
check(g != null, "Lege groep"); text(g.category(), "groepsnaam");
check(g.type() != null && g.startAge() != null && g.endAge() != null && g.startAge() >= 0 && g.endAge() >= g.startAge(),
check(g != null, "Lege groep");
text(g.category(), "groepsnaam");
check(
g.type() != null
&& g.startAge() != null
&& g.endAge() != null
&& g.startAge() >= 0
&& g.endAge() >= g.startAge(),
"Ongeldig type of leeftijdsbereik voor groep " + g.category());
DanceGroup group = DanceGroup.builder().category(g.category()).type(g.type()).startAge(g.startAge()).endAge(g.endAge())
.styles(new LinkedHashSet<>(list(g.styles(), "styles"))).coach(g.coachId() == null ? null : ref(coachMap, g.coachId(), "groep " + g.category() + " verwijst naar een onbekende coach" )).build();
DanceGroup group = DanceGroup.builder()
.category(g.category())
.type(g.type())
.startAge(g.startAge())
.endAge(g.endAge())
.styles(new LinkedHashSet<>(list(g.styles(), "styles")))
.coach(
g.coachId() == null
? null
: ref(
coachMap,
g.coachId(),
"groep " + g.category() + " verwijst naar een onbekende coach"))
.build();
check(!group.getStyles().contains(null), "Ongeldige dansstijl");
group.setId((long) groupMap.size());
for (String id : unique(g.dancerIds(), "dancerIds")) group.addDancer(ref(dancerMap, id, "groep " + g.category() + " verwijst naar een onbekende danser"));
put(groupMap, g.id(), group); school.addDanceGroup(group);
for (String id : unique(g.dancerIds(), "dancerIds"))
group.addDancer(ref(dancerMap, id, "groep " + g.category() + " verwijst naar een onbekende danser"));
put(groupMap, g.id(), group);
school.addDanceGroup(group);
}
Set<String> showIds = new HashSet<>();
for (Performance show : list(shows, "shows")) {
check(show != null, "Lege show"); text(show.id(), "show-id");
check(showIds.add(show.id()), "Dubbele show-id " + show.id()); text(show.name(), "shownaam");
List<DayPart> parts = new ArrayList<>(); Set<String> types = new HashSet<>();
check(show != null, "Lege show");
text(show.id(), "show-id");
check(showIds.add(show.id()), "Dubbele show-id " + show.id());
text(show.name(), "shownaam");
List<DayPart> parts = new ArrayList<>();
Set<String> types = new HashSet<>();
for (Part part : list(show.dayParts(), "dayParts")) {
check(part != null && part.type() != null && part.date() != null && types.add(part.date() + ":" + part.type()), "Ongeldig of dubbel dagdeel voor " + show.name());
parts.add(DayPart.builder().date(part.date()).type(part.type()).danceGroups(unique(part.groupIds(), "groupIds").stream()
.map(id -> ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep")).toList()).build());
check(
part != null
&& part.type() != null
&& part.date() != null
&& types.add(part.date() + ":" + part.type()),
"Ongeldig of dubbel dagdeel voor " + show.name());
parts.add(DayPart.builder()
.date(part.date())
.type(part.type())
.danceGroups(unique(part.groupIds(), "groupIds").stream()
.map(id ->
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
.toList())
.build());
}
ShowConfiguration.validate(parts, show.appearances());
Show entity = Show.builder().name(show.name()).dayParts(parts).appearances(Map.copyOf(show.appearances())).build();
entity.setId((long) showIds.size()); school.addShow(entity);
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;
}
private static void text(String value, String field) { check(value != null && !value.isBlank(), field + " ontbreekt"); }
private static <T> List<T> list(List<T> value, String field) { check(value != null, field + " ontbreekt"); return value; }
private static List<String> unique(List<String> value, String field) {
list(value, field); check(value.stream().noneMatch(Objects::isNull) && new HashSet<>(value).size() == value.size(), "Ongeldige of dubbele verwijzing in " + field); return value;
private static void text(String value, String field) {
check(value != null && !value.isBlank(), field + " ontbreekt");
}
private static <T> List<T> list(List<T> value, String field) {
check(value != null, field + " ontbreekt");
return value;
}
private static List<String> unique(List<String> value, String field) {
list(value, field);
check(
value.stream().noneMatch(Objects::isNull) && new HashSet<>(value).size() == value.size(),
"Ongeldige of dubbele verwijzing in " + field);
return value;
}
private static <T> void put(Map<String, T> map, String id, T value) {
text(id, "id");
check(map.putIfAbsent(id, value) == null, "Dubbele id " + id);
}
private static <T> T ref(Map<String, T> map, String id, String error) {
T value = map.get(id);
check(value != null, error);
return value;
}
private static void check(boolean valid, String message) {
if (!valid) throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, message);
}
private static <T> void put(Map<String, T> map, String id, T value) { text(id, "id"); check(map.putIfAbsent(id, value) == null, "Dubbele id " + id); }
private static <T> T ref(Map<String,T> map, String id, String error) { T value = map.get(id); check(value != null, error); return value; }
private static void check(boolean valid, String message) { if (!valid) throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, message); }
}

View File

@@ -1,13 +1,14 @@
package nl.agilecc.agiledancers.solver;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import com.google.ortools.Loader;
import com.google.ortools.sat.*;
import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.service.*;
import java.util.*;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine {
@Override
@@ -30,7 +31,7 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
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}));
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.
@@ -39,15 +40,21 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
CpSolverStatus status = solver.solve(model);
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE)
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, switch (status) {
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
case UNKNOWN -> "The 30-minute limit was reached without a valid result. Existing assignments were preserved.";
default -> "Solver failed (" + status + "). Existing assignments were preserved.";
});
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
switch (status) {
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
case UNKNOWN ->
"The 30-minute limit was reached without a valid result. Existing assignments were"
+ " preserved.";
default -> "Solver failed (" + status + "). Existing assignments were preserved.";
});
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.groups().get(g).groupId());
for (int g = 0; g < groups; g++)
if (solver.booleanValue(assigned[g][p]))
ids.add(input.groups().get(g).groupId());
parts.add(List.copyOf(ids));
}
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));

View File

@@ -1,15 +1,16 @@
package nl.agilecc.agiledancers;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
@AnalyzeClasses(packages = "nl.agilecc.agiledancers", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
private static final String ROOT = "nl.agilecc.agiledancers";
@@ -18,85 +19,128 @@ class ArchitectureTest {
// Exclude Lombok builders and compiler-generated enum-switch helper classes.
@ArchTest
static final ArchRule handwritten_types_are_top_level = classes()
.that().areNotAnnotatedWith("lombok.Generated")
.and().doNotHaveModifier(JavaModifier.SYNTHETIC)
.should().beTopLevelClasses();
.that()
.areNotAnnotatedWith("lombok.Generated")
.and()
.doNotHaveModifier(JavaModifier.SYNTHETIC)
.should()
.beTopLevelClasses();
@ArchTest
static final ArchRule service_records_reside_in_service_model = classes()
.that().resideInAPackage(ROOT + ".service..")
.and().areRecords()
.should().resideInAPackage(ROOT + ".service.model");
.that()
.resideInAPackage(ROOT + ".service..")
.and()
.areRecords()
.should()
.resideInAPackage(ROOT + ".service.model");
@ArchTest
static final ArchRule service_interfaces_reside_in_service_port = classes()
.that().resideInAPackage(ROOT + ".service..")
.and().areInterfaces()
.should().resideInAPackage(ROOT + ".service.port");
.that()
.resideInAPackage(ROOT + ".service..")
.and()
.areInterfaces()
.should()
.resideInAPackage(ROOT + ".service.port");
@ArchTest
static final ArchRule scheduling_port_uses_domain_types = classes()
.that().resideInAPackage(ROOT + ".service.port..")
.should().onlyDependOnClassesThat()
.that()
.resideInAPackage(ROOT + ".service.port..")
.should()
.onlyDependOnClassesThat()
.resideInAnyPackage(ROOT + ".service.port..", ROOT + ".model..", "java..");
// API model classes and records use the Request, Response, View, Dto or DTO suffix.
@ArchTest
static final ArchRule api_models_reside_in_the_api_model_package = classes()
.that().haveNameMatching(".*(Request|Response|View|Dto|DTO)$")
.should().resideInAPackage(API_MODEL);
.that()
.haveNameMatching(".*(Request|Response|View|Dto|DTO)$")
.should()
.resideInAPackage(API_MODEL);
@ArchTest
static final ArchRule rest_records_reside_in_the_api_model_package = classes()
.that().resideInAPackage(ROOT + ".rest..")
.and().areRecords()
.should().resideInAPackage(API_MODEL);
.that()
.resideInAPackage(ROOT + ".rest..")
.and()
.areRecords()
.should()
.resideInAPackage(API_MODEL);
@ArchTest
static final ArchRule api_models_are_public_top_level_types = classes()
.that().resideInAPackage(API_MODEL + "..")
.should().bePublic()
.andShould().beTopLevelClasses()
.andShould().haveNameMatching(".*(Request|Response|View|Dto|DTO)$");
.that()
.resideInAPackage(API_MODEL + "..")
.should()
.bePublic()
.andShould()
.beTopLevelClasses()
.andShould()
.haveNameMatching(".*(Request|Response|View|Dto|DTO)$");
// The existing services depend on concrete repositories. This rule preserves that
// arrangement until repository ports and dependency injection are introduced.
@ArchTest
static final ArchRule layer_dependencies = layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Model").definedBy(ROOT + ".model..")
.layer("Services").definedBy(ROOT + ".service..")
.layer("Repositories").definedBy(ROOT + ".repository..")
.optionalLayer("REST").definedBy(ROOT + ".rest..")
.optionalLayer("Solver").definedBy(ROOT + ".solver..")
.whereLayer("Model").mayNotAccessAnyLayer()
.whereLayer("Services").mayOnlyAccessLayers("Model", "Repositories")
.whereLayer("Repositories").mayOnlyAccessLayers("Model")
.whereLayer("REST").mayOnlyAccessLayers("Services", "Model")
.whereLayer("Solver").mayOnlyAccessLayers("Services", "Model");
.layer("Model")
.definedBy(ROOT + ".model..")
.layer("Services")
.definedBy(ROOT + ".service..")
.layer("Repositories")
.definedBy(ROOT + ".repository..")
.optionalLayer("REST")
.definedBy(ROOT + ".rest..")
.optionalLayer("Solver")
.definedBy(ROOT + ".solver..")
.whereLayer("Model")
.mayNotAccessAnyLayer()
.whereLayer("Services")
.mayOnlyAccessLayers("Model", "Repositories")
.whereLayer("Repositories")
.mayOnlyAccessLayers("Model")
.whereLayer("REST")
.mayOnlyAccessLayers("Services", "Model")
.whereLayer("Solver")
.mayOnlyAccessLayers("Services", "Model");
@ArchTest
static final ArchRule production_classes_have_an_architectural_home = classes()
.should().resideInAnyPackage(ROOT + ".model..", ROOT + ".service..",
ROOT + ".repository..", ROOT + ".rest..", ROOT + ".solver..");
.should()
.resideInAnyPackage(
ROOT + ".model..",
ROOT + ".service..",
ROOT + ".repository..",
ROOT + ".rest..",
ROOT + ".solver..");
@ArchTest
static final ArchRule model_is_independent_of_frameworks = classes()
.that().resideInAPackage(ROOT + ".model..")
.should().onlyDependOnClassesThat()
.that()
.resideInAPackage(ROOT + ".model..")
.should()
.onlyDependOnClassesThat()
.resideInAnyPackage(ROOT + ".model..", "java..", "lombok..");
@ArchTest
static final ArchRule ortools_is_encapsulated_in_solver = noClasses()
.that().resideOutsideOfPackage(ROOT + ".solver..")
.should().dependOnClassesThat().resideInAPackage("com.google.ortools..");
.that()
.resideOutsideOfPackage(ROOT + ".solver..")
.should()
.dependOnClassesThat()
.resideInAPackage("com.google.ortools..");
@ArchTest
static final ArchRule http_api_is_encapsulated_in_rest = noClasses()
.that().resideOutsideOfPackage(ROOT + ".rest..")
.should().dependOnClassesThat().resideInAnyPackage("jakarta.ws.rs..", "org.jboss.resteasy..");
.that()
.resideOutsideOfPackage(ROOT + ".rest..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("jakarta.ws.rs..", "org.jboss.resteasy..");
@ArchTest
static final ArchRule packages_are_free_of_cycles = slices()
.matching(ROOT + ".(*)..").should().beFreeOfCycles();
static final ArchRule packages_are_free_of_cycles =
slices().matching(ROOT + ".(*)..").should().beFreeOfCycles();
}

View File

@@ -1,18 +1,15 @@
package nl.agilecc.agiledancers;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
@QuarkusTest
class HealthTest {
@Test
void applicationIsReady() {
given()
.when().get("/q/health/ready")
.then().statusCode(200)
.body("status", equalTo("UP"));
given().when().get("/q/health/ready").then().statusCode(200).body("status", equalTo("UP"));
}
}

View File

@@ -1,54 +1,80 @@
package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
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();
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()); }
try {
service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input));
} finally {
service.endScheduling(show.getId());
}
}
@Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
@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());
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();
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() {
@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);
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));
@@ -58,19 +84,25 @@ class SchedulingTest {
service.updateShow(school, show.getId(), "Show", parts(4), null);
assertEquals(counts, show.getAppearances());
}
@Test void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
@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);
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() {
@Test
void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
var group = group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null); run(show);
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()));
@@ -79,18 +111,25 @@ class SchedulingTest {
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());
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 SchedulingResult(false, List.of(List.of(), List.of()))));
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
assertThrows(
ManagementException.class,
() -> service.finishScheduling(
school, fresh, new SchedulingResult(false, List.of(List.of(), List.of()))));
assertEquals(
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
// Feasible results are accepted without requiring proof of optimality.
var valid = new OrToolsSchedulingEngine().solve(fresh);
assertDoesNotThrow(() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments())));
assertDoesNotThrow(
() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments())));
service.endScheduling(show.getId());
}
@Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {
@Test
void staleDaypartCountChangesAndDeletedShowsAreRejected() {
group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null);
var input = service.beginScheduling(school, show.getId());
@@ -101,7 +140,9 @@ class SchedulingTest {
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.endScheduling(show.getId());
}
@Test void needsGroupsAndDayparts() {
@Test
void needsGroupsAndDayparts() {
var show = service.createShow(school, "Show", parts(1), null);
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
group(DanceGroupType.REGULAR);

View File

@@ -1,19 +1,21 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.ValidatableResponse;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import org.junit.jupiter.api.Test;
@QuarkusTest
class SchoolManagementResourceTest {
private static final String SCHOOLS = "/api/dance-schools";
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", "dayParts", new Object[]{Map.of("date", "2027-06-12", "type", "AFTERNOON")});
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", "dayParts", new Object[] {Map.of("date", "2027-06-12", "type", "AFTERNOON")});
@Test
void managesSchoolAndItsResources() {
@@ -27,37 +29,64 @@ class SchoolManagementResourceTest {
long dancerId = create(school + "/dancers", DANCER);
String dancer = school + "/dancers/" + dancerId;
put(dancer, Map.of("name", "Sam updated", "birthDate", "2011-06-13"))
.statusCode(200).body("birthDate", equalTo("2011-06-13"));
.statusCode(200)
.body("birthDate", equalTo("2011-06-13"));
given().get(dancer).then().statusCode(200).body("name", equalTo("Sam updated"));
given().get(school + "/dancers").then().statusCode(200).body("id", contains((int) dancerId));
long groupId = create(school + "/dance-groups", GROUP);
String group = school + "/dance-groups/" + groupId;
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
put(group, Map.of("category", "Selection", "type", "SELECTION", "startAge", 10,
"endAge", 20, "styles", new String[]{"JAZZ", "MODERN"}))
.statusCode(200).body("category", equalTo("Selection"))
.body("dancerIds", contains((int) dancerId)).body("styles", containsInAnyOrder("JAZZ", "MODERN"));
put(
group,
Map.of(
"category",
"Selection",
"type",
"SELECTION",
"startAge",
10,
"endAge",
20,
"styles",
new String[] {"JAZZ", "MODERN"}))
.statusCode(200)
.body("category", equalTo("Selection"))
.body("dancerIds", contains((int) dancerId))
.body("styles", containsInAnyOrder("JAZZ", "MODERN"));
given().get(group).then().statusCode(200).body("type", equalTo("SELECTION"));
given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId));
long showId = create(school + "/shows", SHOW);
String show = school + "/shows/" + showId;
given().get(show).then().statusCode(200)
given().get(show)
.then()
.statusCode(200)
.body("dayParts.type", contains("AFTERNOON"))
.body("dayParts.danceGroupIds", everyItem(empty()));
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"));
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));
given().get(school)
.then()
.statusCode(200)
.body("dancerIds", contains((int) dancerId))
.body("danceGroupIds", contains((int) groupId))
.body("showIds", contains((int) showId));
for (String path : new String[]{dancer, group, show}) {
for (String path : new String[] {dancer, group, show}) {
given().delete(path).then().statusCode(204);
given().get(path).then().statusCode(404);
given().delete(path).then().statusCode(404);
}
given().get(school).then().body("dancerIds", empty()).body("danceGroupIds", empty()).body("showIds", empty());
given().get(school)
.then()
.body("dancerIds", empty())
.body("danceGroupIds", empty())
.body("showIds", empty());
} finally {
given().delete(school).then().statusCode(204);
}
@@ -71,7 +100,7 @@ class SchoolManagementResourceTest {
long dancerId = create(school + "/dancers", DANCER);
String first = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
String second = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
for (String group : new String[]{first, second}) {
for (String group : new String[] {first, second}) {
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
given().get(group).then().body("dancerIds", contains((int) dancerId));
@@ -98,14 +127,16 @@ class SchoolManagementResourceTest {
long group = create(first + "/dance-groups", GROUP);
long show = create(first + "/shows", SHOW);
long otherGroup = create(second + "/dance-groups", GROUP);
for (String path : new String[]{"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) {
for (String path : new String[] {"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) {
given().get(second + path).then().statusCode(404);
given().delete(second + path).then().statusCode(404);
}
put(second + "/dancers/" + dancer, DANCER).statusCode(404);
put(second + "/dance-groups/" + group, GROUP).statusCode(404);
put(second + "/shows/" + show, SHOW).statusCode(404);
given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer).then().statusCode(404);
given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer)
.then()
.statusCode(404);
given().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty());
given().delete(first).then().statusCode(204);
given().get(first + "/dancers").then().statusCode(404);
@@ -121,30 +152,66 @@ class SchoolManagementResourceTest {
@Test
void rejectsInvalidInputWithoutChangingStoredData() {
given().contentType("application/json").body("{}").post(SCHOOLS).then().statusCode(400);
given().contentType("application/json").body("null").post(SCHOOLS).then().statusCode(400);
given().contentType("application/json")
.body("null")
.post(SCHOOLS)
.then()
.statusCode(400);
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Validation school"));
try {
put(school, Map.of("name", " ")).statusCode(400);
given().get(school).then().body("name", equalTo("Validation school"));
for (Object body : new Object[]{Map.of("name", "Sam"), Map.of("name", "Sam", "birthDate", "2999-01-01"),
Map.of("name", "Sam", "birthDate", "not-a-date")}) {
given().contentType("application/json").body(body).post(school + "/dancers").then().statusCode(400);
for (Object body : new Object[] {
Map.of("name", "Sam"),
Map.of("name", "Sam", "birthDate", "2999-01-01"),
Map.of("name", "Sam", "birthDate", "not-a-date")
}) {
given().contentType("application/json")
.body(body)
.post(school + "/dancers")
.then()
.statusCode(400);
}
for (String body : new String[]{"{}", "{\"category\":\"Teens\",\"type\":\"INVALID\"}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"}) {
given().contentType("application/json").body(body).post(school + "/dance-groups").then().statusCode(400);
for (String body : new String[] {
"{}",
"{\"category\":\"Teens\",\"type\":\"INVALID\"}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"
}) {
given().contentType("application/json")
.body(body)
.post(school + "/dance-groups")
.then()
.statusCode(400);
}
long group = create(school + "/dance-groups", GROUP);
put(school + "/dance-groups/" + group, Map.of("category", "Invalid", "type", "REGULAR",
"startAge", 20, "endAge", 10, "styles", new String[]{})).statusCode(400);
put(
school + "/dance-groups/" + group,
Map.of(
"category",
"Invalid",
"type",
"REGULAR",
"startAge",
20,
"endAge",
10,
"styles",
new String[] {}))
.statusCode(400);
given().get(school + "/dance-groups/" + group).then().body("category", equalTo("Teens"));
given().contentType("application/json").body(Map.of("name", "Show"))
.post(school + "/shows").then().statusCode(400);
given().contentType("application/json")
.body(Map.of("name", "Show"))
.post(school + "/shows")
.then()
.statusCode(400);
given().get(school + "/dancers").then().body("$", empty());
given().get(school + "/shows").then().body("$", empty());
given().contentType("application/json").body(DANCER)
.post(SCHOOLS + "/9223372036854775807/dancers").then().statusCode(404);
given().contentType("application/json")
.body(DANCER)
.post(SCHOOLS + "/9223372036854775807/dancers")
.then()
.statusCode(404);
} finally {
given().delete(school).then().statusCode(204);
}
@@ -161,22 +228,44 @@ class SchoolManagementResourceTest {
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) + "}";
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(io.restassured.path.json.JsonPath.from(exported).getMap("$")));
} finally { given().delete(school); }
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(io.restassured.path.json.JsonPath.from(exported)
.getMap("$")));
} 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]+$"))
.extract().jsonPath().getLong("id");
return given().contentType("application/json")
.body(body)
.post(path)
.then()
.statusCode(201)
.header("Location", matchesPattern(".*/[0-9]+$"))
.extract()
.jsonPath()
.getLong("id");
}
private static ValidatableResponse put(String path, Object body) {

View File

@@ -1,32 +1,68 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
@QuarkusTest
class SchoolSnapshotResourceTest {
private static final String BASE = "/api/dance-schools";
@Test void downloadPreviewRestoreAndRejectInvalidFiles() {
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(2)).extract().asString();
@Test
void downloadPreviewRestoreAndRejectInvalidFiles() {
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(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\" : 2", "\"formatVersion\" : 1"),
json.replace("\"name\" :", "\"unknown\" :"), json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) {
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id)
.then().statusCode(400).body("message", not(emptyOrNullString()));
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\" : 2", "\"formatVersion\" : 1"),
json.replace("\"name\" :", "\"unknown\" :"),
json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")
}) {
given().contentType("application/json")
.body(bad)
.post(BASE + "/import?replaceId=" + id)
.then()
.statusCode(400)
.body("message", not(emptyOrNullString()));
}
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
given().delete(BASE + "/" + id).then().statusCode(204);
id = given().contentType("application/json").body(json).post(BASE + "/import")
.then().statusCode(200).extract().jsonPath().getLong("id");
id = given().contentType("application/json")
.body(json)
.post(BASE + "/import")
.then()
.statusCode(200)
.extract()
.jsonPath()
.getLong("id");
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
} finally { given().delete(BASE + "/" + id); }
} finally {
given().delete(BASE + "/" + id);
}
}
}

View File

@@ -1,31 +1,51 @@
package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.model.specifications.*;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.model.*;
import org.junit.jupiter.api.Test;
class SchoolSnapshotTest {
private SchoolSnapshot complete() {
return new SchoolSnapshot(2, UUID.randomUUID(), "Studio", List.of(new Person("a", "Sam", LocalDate.of(2010,1,1))),
return new SchoolSnapshot(
2,
UUID.randomUUID(),
"Studio",
List.of(new Person("a", "Sam", LocalDate.of(2010, 1, 1))),
List.of(new Trainer("c", "Alex")),
List.of(new Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"),
List.of(
new Group(
"g",
"Hiphop",
DanceGroupType.REGULAR,
0,
99,
List.of(DanceGroupStyle.HIPHOP),
List.of("a"),
"c"),
new Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
List.of(new Performance("s", "Show",
List.of(new Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1))));
List.of(new Performance(
"s",
"Show",
List.of(new Part(LocalDate.of(2027, 1, 1), DayPartType.MORNING, List.of("g", "h"))),
ShowConfiguration.defaults(1))));
}
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
@Test
void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
var snapshot = complete();
var service = new SchoolManagementService();
var existing = service.createSchool("Other");
service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000,1,1));
service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000, 1, 1));
var imported = service.importSchool(snapshot, null);
assertEquals(2, imported.getDanceGroups().size());
var dancer = imported.getDancers().iterator().next();
imported.getDanceGroups().forEach(g -> assertSame(dancer, g.getDancers().iterator().next()));
imported.getDanceGroups()
.forEach(g -> assertSame(dancer, g.getDancers().iterator().next()));
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(imported.getId()));
var restarted = new SchoolManagementService();
var restored = restarted.importSchool(service.exportSchool(imported.getId()), null);
@@ -36,14 +56,26 @@ class SchoolSnapshotTest {
assertEquals(1, existing.getDancers().size());
var group = imported.getDanceGroups().iterator().next();
service.deleteGroup(imported.getId(), group.getId());
assertEquals(1, imported.getShows().iterator().next().getDayParts().getFirst().getDanceGroups().size());
assertEquals(
1,
imported.getShows()
.iterator()
.next()
.getDayParts()
.getFirst()
.getDanceGroups()
.size());
assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore());
}
@Test void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
var service = new SchoolManagementService(); var snapshot = complete();
assertNull(service.preview(snapshot).existingId()); assertTrue(service.schools().isEmpty());
@Test
void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
var service = new SchoolManagementService();
var snapshot = complete();
assertNull(service.preview(snapshot).existingId());
assertTrue(service.schools().isEmpty());
var original = service.importSchool(snapshot, null);
service.createDancer(original.getId(), "Extra", LocalDate.of(2000,1,1));
service.createDancer(original.getId(), "Extra", LocalDate.of(2000, 1, 1));
var other = service.createSchool(snapshot.name());
assertEquals(original.getId(), service.preview(snapshot).existingId());
assertThrows(ManagementException.class, () -> service.importSchool(snapshot, null));
@@ -52,20 +84,31 @@ class SchoolSnapshotTest {
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(replaced.getId()));
assertSame(other, service.school(other.getId()));
service.deleteSchool(replaced.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
assertEquals(
SchoolSnapshot.of(snapshot.restore()),
service.exportSchool(service.importSchool(snapshot, null).getId()));
}
@Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var service = new SchoolManagementService(); var valid = complete();
@Test
void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var service = new SchoolManagementService();
var valid = complete();
var original = service.importSchool(valid, null);
var bad = new SchoolSnapshot(2, 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()));
}
@Test void emptySchoolRoundTrips() {
@Test
void emptySchoolRoundTrips() {
var service = new SchoolManagementService();
var empty = service.createSchool("Empty"); var snapshot = service.exportSchool(empty.getId());
var empty = service.createSchool("Empty");
var snapshot = service.exportSchool(empty.getId());
service.deleteSchool(empty.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
assertEquals(
SchoolSnapshot.of(snapshot.restore()),
service.exportSchool(service.importSchool(snapshot, null).getId()));
}
}

View File

@@ -1,30 +1,44 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingServiceTest {
@Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
@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 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(ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit"); },
input -> {
throw new IllegalStateException("Native failure");
},
input -> {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit");
},
input -> null)) {
scheduling.engine = failure;
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()));
@@ -34,28 +48,46 @@ class SchedulingServiceTest {
}
}
@Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
@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);
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); }
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(ManagementExceptionKind.CONFLICT, assertThrows(ManagementException.class,
() -> scheduling.schedule(school, show.getId())).getKind());
assertEquals(
ManagementExceptionKind.CONFLICT,
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
.getKind());
schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of());
} finally { release.countDown(); }
} 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"));

View File

@@ -14,13 +14,11 @@ class DanceSchoolSit {
int aantalDansers11tot14 = 70;
int aantalDansers14tm17 = 140;
int aantalDansers18Plus = 100;
private DanceSchoolService danceSchoolService = new DanceSchoolService();
@Test
void testDanceSchoolModel() {
DanceSchool danceSchool = danceSchoolService.add("24 Dance Studio");
}
}