updated coding standards
All checks were successful
Build, test and publish / verify (push) Successful in 4m43s

This commit is contained in:
2026-10-30 19:58:03 +01:00
parent 9a69981025
commit f910f1e535
65 changed files with 2827 additions and 724 deletions

View File

@@ -32,6 +32,7 @@
</dependency>
<!-- test dependencies -->
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-jacoco</artifactId><scope>test</scope></dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
@@ -62,6 +63,60 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version>
<executions><execution><id>standards-classpath</id><phase>prepare-package</phase><goals><goal>build-classpath</goal></goals>
<configuration><outputFile>${project.build.directory}/standards-classpath.txt</outputFile></configuration>
</execution>
<execution><id>coverage-tools</id><phase>prepare-package</phase><goals><goal>copy</goal></goals>
<configuration><artifactItems>
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.agent</artifactId><version>0.8.14</version><classifier>runtime</classifier><destFileName>agent.jar</destFileName></artifactItem>
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.cli</artifactId><version>0.8.14</version><classifier>nodeps</classifier><destFileName>cli.jar</destFileName></artifactItem>
</artifactItems><outputDirectory>${project.build.directory}/coverage-tools</outputDirectory></configuration>
</execution></executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version>
<executions><execution><id>standards</id><phase>verify</phase><goals><goal>exec</goal></goals>
<configuration>
<executable>${maven.home}/bin/mvn</executable>
<workingDirectory>${maven.multiModuleProjectDirectory}</workingDirectory>
<arguments><argument>-B</argument><argument>-ntp</argument><argument>-f</argument>
<argument>${maven.multiModuleProjectDirectory}/tooling/standards/pom.xml</argument>
<argument>test</argument><argument>exec:java</argument>
</arguments>
</configuration>
</execution>
<execution><id>coverage</id><phase>verify</phase><goals><goal>exec</goal></goals>
<configuration><executable>${java.home}/bin/java</executable><arguments>
<argument>${maven.multiModuleProjectDirectory}/tooling/coverage/Coverage.java</argument>
<argument>${project.build.directory}/site/jacoco/jacoco.xml</argument>
</arguments></configuration>
</execution>
<execution><id>combined-coverage</id><phase>none</phase><goals><goal>exec</goal></goals>
<configuration><executable>${java.home}/bin/java</executable><arguments>
<argument>${maven.multiModuleProjectDirectory}/tooling/coverage/Coverage.java</argument>
<argument>${project.build.directory}/site/jacoco-combined/jacoco.xml</argument>
</arguments></configuration>
</execution></executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.14</version>
<executions>
<execution><id>agent</id><goals><goal>prepare-agent</goal></goals><configuration>
<exclClassLoaders>*QuarkusClassLoader</exclClassLoaders><destFile>${project.build.directory}/jacoco.exec</destFile><append>true</append>
</configuration></execution>
<execution><id>report</id><phase>post-integration-test</phase><goals><goal>report</goal></goals></execution>
<execution><id>combined</id><phase>none</phase><goals><goal>merge</goal></goals><configuration>
<fileSets><fileSet><directory>${project.build.directory}</directory><includes><include>jacoco.exec</include><include>playwright.exec</include></includes></fileSet></fileSets>
<destFile>${project.build.directory}/jacoco-combined.exec</destFile>
</configuration></execution>
<execution><id>combined-report</id><phase>none</phase><goals><goal>report</goal></goals><configuration>
<dataFile>${project.build.directory}/jacoco-combined.exec</dataFile>
<outputDirectory>${project.build.directory}/site/jacoco-combined</outputDirectory>
</configuration></execution>
</executions>
</plugin>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>

View File

@@ -3,7 +3,11 @@ package nl.agilecc.agiledancers.model;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import lombok.*;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@@ -13,15 +17,15 @@ import lombok.*;
public abstract class AbstractEntity {
private Long id;
protected <T> void addNullSafe(Set<T> set, T t) {
protected <T> void addNullSafe(final Set<T> set, final T t) {
emptyIfNull(set).add(t);
}
private <T> Set<T> emptyIfNull(Set<T> set) {
private <T> Set<T> emptyIfNull(final Set<T> set) {
return set == null ? new HashSet<>() : set;
}
protected <T> Set<T> unmodifiableNullSafe(Set<T> set) {
protected <T> Set<T> unmodifiableNullSafe(final Set<T> set) {
return set == null ? Collections.emptySet() : Collections.unmodifiableSet(set);
}
}

View File

@@ -1,6 +1,10 @@
package nl.agilecc.agiledancers.model;
import lombok.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)

View File

@@ -2,7 +2,12 @@ package nl.agilecc.agiledancers.model;
import java.util.LinkedHashSet;
import java.util.Set;
import lombok.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@@ -42,7 +47,7 @@ public class DanceGroup extends AbstractEntity {
return super.unmodifiableNullSafe(dancers);
}
public void addDancer(Dancer dancer) {
public void addDancer(final Dancer dancer) {
super.addNullSafe(dancers, dancer);
}
@@ -50,15 +55,15 @@ public class DanceGroup extends AbstractEntity {
return super.unmodifiableNullSafe(styles);
}
public void addStyle(DanceGroupStyle style) {
public void addStyle(final DanceGroupStyle style) {
super.addNullSafe(styles, style);
}
public void removeDancer(Dancer dancer) {
public void removeDancer(final Dancer dancer) {
dancers.remove(dancer);
}
public void replaceStyles(Set<DanceGroupStyle> styles) {
public void replaceStyles(final Set<DanceGroupStyle> styles) {
this.styles.clear();
this.styles.addAll(styles);
}

View File

@@ -2,7 +2,12 @@ package nl.agilecc.agiledancers.model;
import java.util.LinkedHashSet;
import java.util.Set;
import lombok.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -43,31 +48,31 @@ public class DanceSchool extends AbstractEntity {
return super.unmodifiableNullSafe(coaches);
}
public void addDancer(Dancer dancer) {
public void addDancer(final Dancer dancer) {
super.addNullSafe(dancers, dancer);
}
public void addDanceGroup(DanceGroup danceGroup) {
public void addDanceGroup(final DanceGroup danceGroup) {
super.addNullSafe(danceGroups, danceGroup);
}
public void addShow(Show show) {
public void addShow(final Show show) {
super.addNullSafe(shows, show);
}
public void addCoach(Coach coach) {
public void addCoach(final Coach coach) {
super.addNullSafe(coaches, coach);
}
public void removeDancer(Dancer entity) {
public void removeDancer(final Dancer entity) {
dancers.remove(entity);
}
public void removeDanceGroup(DanceGroup entity) {
public void removeDanceGroup(final DanceGroup entity) {
danceGroups.remove(entity);
}
public void removeShow(Show entity) {
public void removeShow(final Show entity) {
shows.remove(entity);
}
}

View File

@@ -2,7 +2,12 @@ package nl.agilecc.agiledancers.model;
import java.time.LocalDate;
import java.time.Period;
import lombok.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)

View File

@@ -2,7 +2,12 @@ package nl.agilecc.agiledancers.model;
import java.time.LocalDate;
import java.util.List;
import lombok.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
@Getter
@@ -19,7 +24,9 @@ public class DayPart extends AbstractEntity {
@Setter
private List<DanceGroup> danceGroups;
public void removeDanceGroup(DanceGroup group) {
danceGroups = danceGroups.stream().filter(value -> value != group).toList();
public void removeDanceGroup(final DanceGroup group) {
danceGroups = danceGroups.stream()
.filter((final DanceGroup value) -> value != group)
.toList();
}
}

View File

@@ -2,7 +2,12 @@ package nl.agilecc.agiledancers.model;
import java.util.List;
import java.util.Map;
import lombok.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@Getter

View File

@@ -1,5 +1,16 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {}
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {
public SchedulingResult {
assignments = assignments == null
? null
: assignments.stream()
.map((final List<Long> part) ->
part == null ? null : Collections.unmodifiableList(new ArrayList<>(part)))
.toList();
}
}

View File

@@ -1,18 +1,20 @@
package nl.agilecc.agiledancers.repository;
import java.util.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.AbstractEntity;
public abstract class AbstractRepository<T extends AbstractEntity> {
private long sequence = 0L;
protected Map<Long, T> entities = new LinkedHashMap<>();
protected final Map<Long, T> entities = new LinkedHashMap<>();
public long nextId() {
return sequence++;
}
public T add(T entity) {
public T add(final T entity) {
if (entity.getId() == null) {
entity.setId(nextId());
}
@@ -24,11 +26,11 @@ public abstract class AbstractRepository<T extends AbstractEntity> {
return List.copyOf(entities.values());
}
public T findById(long id) {
public T findById(final long id) {
return entities.get(id);
}
public void delete(long id) {
public void delete(final long id) {
entities.remove(id);
}
}

View File

@@ -3,22 +3,32 @@ package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.model.GroupDetails;
@Path("/api/dance-schools/{schoolId}/dance-groups")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceGroupResource {
@Inject
SchoolManagementService service;
private @Inject SchoolManagementService service;
@PathParam("schoolId")
long schoolId;
private @PathParam("schoolId") long schoolId;
@GET
public List<GroupView> list() {
@@ -27,14 +37,16 @@ public class DanceGroupResource {
@GET
@Path("/{id}")
public GroupView get(@PathParam("id") long id) {
public GroupView get(final @PathParam("id") long id) {
return GroupView.of(service.group(schoolId, id));
}
@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());
public Response create(final @NotNull @Valid GroupRequest request, final @Context UriInfo uriInfo) {
final DanceGroup entity = service.createGroup(
schoolId,
new GroupDetails(
request.category(), request.type(), request.startAge(), request.endAge(), request.styles()));
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
@@ -44,34 +56,31 @@ public class DanceGroupResource {
@PUT
@Path("/{id}")
public GroupView update(@PathParam("id") long id, @NotNull @Valid GroupRequest request) {
public GroupView update(final @PathParam("id") long id, final @NotNull @Valid GroupRequest request) {
return GroupView.of(service.updateGroup(
schoolId,
id,
request.category(),
request.type(),
request.startAge(),
request.endAge(),
request.styles()));
new GroupDetails(
request.category(), request.type(), request.startAge(), request.endAge(), request.styles())));
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
public Response delete(final @PathParam("id") long id) {
service.deleteGroup(schoolId, id);
return Response.noContent().build();
}
@PUT
@Path("/{id}/dancers/{dancerId}")
public Response addDancer(@PathParam("id") long id, @PathParam("dancerId") long dancerId) {
public Response addDancer(final @PathParam("id") long id, final @PathParam("dancerId") long dancerId) {
service.addGroupDancer(schoolId, id, dancerId);
return Response.noContent().build();
}
@DELETE
@Path("/{id}/dancers/{dancerId}")
public Response removeDancer(@PathParam("id") long id, @PathParam("dancerId") long dancerId) {
public Response removeDancer(final @PathParam("id") long id, final @PathParam("dancerId") long dancerId) {
service.removeGroupDancer(schoolId, id, dancerId);
return Response.noContent().build();
}

View File

@@ -3,9 +3,20 @@ package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@@ -14,8 +25,7 @@ import nl.agilecc.agiledancers.service.SchoolManagementService;
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceSchoolResource {
@Inject
SchoolManagementService service;
private @Inject SchoolManagementService service;
@GET
public List<SchoolView> list() {
@@ -24,13 +34,13 @@ public class DanceSchoolResource {
@GET
@Path("/{id}")
public SchoolView get(@PathParam("id") long id) {
public SchoolView get(final @PathParam("id") long id) {
return SchoolView.of(service.school(id));
}
@POST
public Response create(@NotNull @Valid SchoolRequest request, @Context UriInfo uriInfo) {
var entity = service.createSchool(request.name());
public Response create(final @NotNull @Valid SchoolRequest request, final @Context UriInfo uriInfo) {
final DanceSchool entity = service.createSchool(request.name());
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
@@ -40,13 +50,13 @@ public class DanceSchoolResource {
@PUT
@Path("/{id}")
public SchoolView update(@PathParam("id") long id, @NotNull @Valid SchoolRequest request) {
public SchoolView update(final @PathParam("id") long id, final @NotNull @Valid SchoolRequest request) {
return SchoolView.of(service.updateSchool(id, request.name()));
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
public Response delete(final @PathParam("id") long id) {
service.deleteSchool(id);
return Response.noContent().build();
}

View File

@@ -3,9 +3,20 @@ package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@@ -14,11 +25,9 @@ import nl.agilecc.agiledancers.service.SchoolManagementService;
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DancerResource {
@Inject
SchoolManagementService service;
private @Inject SchoolManagementService service;
@PathParam("schoolId")
long schoolId;
private @PathParam("schoolId") long schoolId;
@GET
public List<DancerView> list() {
@@ -27,13 +36,13 @@ public class DancerResource {
@GET
@Path("/{id}")
public DancerView get(@PathParam("id") long id) {
public DancerView get(final @PathParam("id") long id) {
return DancerView.of(service.dancer(schoolId, id));
}
@POST
public Response create(@NotNull @Valid DancerRequest request, @Context UriInfo uriInfo) {
var entity = service.createDancer(schoolId, request.name(), request.birthDate());
public Response create(final @NotNull @Valid DancerRequest request, final @Context UriInfo uriInfo) {
final Dancer entity = service.createDancer(schoolId, request.name(), request.birthDate());
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
@@ -43,13 +52,13 @@ public class DancerResource {
@PUT
@Path("/{id}")
public DancerView update(@PathParam("id") long id, @NotNull @Valid DancerRequest request) {
public DancerView update(final @PathParam("id") long id, final @NotNull @Valid DancerRequest request) {
return DancerView.of(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
public Response delete(final @PathParam("id") long id) {
service.deleteDancer(schoolId, id);
return Response.noContent().build();
}

View File

@@ -8,7 +8,7 @@ import jakarta.inject.Singleton;
@Singleton
public class JacksonConfiguration implements ObjectMapperCustomizer {
@Override
public void customize(ObjectMapper mapper) {
public void customize(final ObjectMapper mapper) {
mapper.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
}
}

View File

@@ -10,8 +10,8 @@ import nl.agilecc.agiledancers.service.ManagementException;
@Provider
public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> {
@Override
public Response toResponse(ManagementException exception) {
int status = switch (exception.getKind()) {
public Response toResponse(final ManagementException exception) {
final int status = switch (exception.getKind()) {
case NOT_FOUND -> 404;
case INVALID_INPUT -> 400;
case CONFLICT -> 409;

View File

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

View File

@@ -3,10 +3,23 @@ package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
@@ -17,14 +30,11 @@ import nl.agilecc.agiledancers.service.SchoolManagementService;
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ShowResource {
@Inject
SchoolManagementService service;
private @Inject SchoolManagementService service;
@Inject
SchedulingService scheduling;
private @Inject SchedulingService scheduling;
@PathParam("schoolId")
long schoolId;
private @PathParam("schoolId") long schoolId;
@GET
public List<ShowView> list() {
@@ -33,13 +43,13 @@ public class ShowResource {
@GET
@Path("/{id}")
public ShowView get(@PathParam("id") long id) {
public ShowView get(final @PathParam("id") long id) {
return ShowView.of(service.show(schoolId, id));
}
@POST
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
var entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) {
final Show entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
@@ -49,13 +59,13 @@ public class ShowResource {
@PUT
@Path("/{id}")
public ShowView update(@PathParam("id") long id, @NotNull @Valid ShowRequest request) {
public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) {
return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances()));
}
private static List<DayPart> parts(ShowRequest request) {
private static List<DayPart> parts(final ShowRequest request) {
return request.dayParts().stream()
.map(p -> DayPart.builder()
.map((final DayPartRequest p) -> DayPart.builder()
.date(p.date())
.type(p.type())
.danceGroups(List.of())
@@ -66,15 +76,15 @@ public class ShowResource {
@POST
@Path("/{id}/schedule")
@Consumes(MediaType.WILDCARD)
public SchedulingView schedule(@PathParam("id") long id) {
var result = scheduling.schedule(schoolId, id);
public SchedulingView schedule(final @PathParam("id") long id) {
final SchedulingResult result = scheduling.schedule(schoolId, id);
return new SchedulingView(
result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven");
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
public Response delete(final @PathParam("id") long id) {
service.deleteShow(schoolId, id);
return Response.noContent().build();
}

View File

@@ -4,7 +4,7 @@ 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) {
public static DancerView of(final Dancer dancer) {
return new DancerView(dancer.getId(), dancer.getName(), dancer.getBirthDate());
}
}

View File

@@ -1,7 +1,13 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
public record DayPartView(LocalDate date, DayPartType type, List<Long> danceGroupIds) {}
public record DayPartView(LocalDate date, DayPartType type, List<Long> danceGroupIds) {
public DayPartView {
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
}
}

View File

@@ -3,6 +3,8 @@ package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@@ -12,4 +14,8 @@ public record GroupRequest(
@NotNull DanceGroupType type,
@NotNull @Min(0) Integer startAge,
@NotNull @Min(0) Integer endAge,
@NotNull Set<@NotNull DanceGroupStyle> styles) {}
@NotNull Set<@NotNull DanceGroupStyle> styles) {
public GroupRequest {
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
}
}

View File

@@ -1,5 +1,8 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.DanceGroup;
@@ -15,7 +18,12 @@ public record GroupView(
int endAge,
Set<DanceGroupStyle> styles,
List<Long> dancerIds) {
public static GroupView of(DanceGroup group) {
public GroupView {
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
}
public static GroupView of(final DanceGroup group) {
return new GroupView(
group.getId(),
group.getCategory(),

View File

@@ -1,5 +1,7 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
@@ -7,7 +9,13 @@ import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.Show;
public record SchoolView(long id, String name, List<Long> dancerIds, List<Long> danceGroupIds, List<Long> showIds) {
public static SchoolView of(DanceSchool school) {
public SchoolView {
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
danceGroupIds = danceGroupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(danceGroupIds));
showIds = showIds == null ? null : Collections.unmodifiableList(new ArrayList<>(showIds));
}
public static SchoolView of(final DanceSchool school) {
return new SchoolView(
school.getId(),
school.getName(),

View File

@@ -1,11 +1,21 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.util.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowRequest(
@NotBlank String name,
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
Map<DanceGroupType, Integer> appearances) {}
Map<DanceGroupType, Integer> appearances) {
public ShowRequest {
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
}
}

View File

@@ -1,16 +1,27 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.*;
import nl.agilecc.agiledancers.model.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) {
public static ShowView of(Show show) {
public ShowView {
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
}
public static ShowView of(final Show show) {
return new ShowView(
show.getId(),
show.getName(),
show.getDayParts().stream()
.map(p -> new DayPartView(
.map((final DayPart p) -> new DayPartView(
p.getDate(),
p.getType(),
p.getDanceGroups().stream()

View File

@@ -4,9 +4,9 @@ import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.repository.CoachRepository;
public class CoachService {
private CoachRepository coachRepository = new CoachRepository();
private final CoachRepository coachRepository = new CoachRepository();
public Coach addCoach(String name, Coach coach) {
public Coach addCoach(final String name, final Coach coach) {
return coachRepository.add(Coach.builder().name(name).build());
}
}

View File

@@ -1,31 +1,24 @@
package nl.agilecc.agiledancers.service;
import java.util.LinkedHashSet;
import java.util.Set;
import nl.agilecc.agiledancers.model.Coach;
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;
import nl.agilecc.agiledancers.repository.DanceGroupRepository;
import nl.agilecc.agiledancers.service.model.GroupDetails;
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) {
DanceGroup danceGroup = DanceGroup.builder()
.category(category)
.type(type)
.startAge(startAge)
.endAge(endAge)
public DanceGroup add(final GroupDetails details, final Set<Dancer> dancers, final Coach coach) {
final DanceGroup danceGroup = DanceGroup.builder()
.category(details.category())
.type(details.type())
.startAge(details.startAge())
.endAge(details.endAge())
.dancers(dancers)
.styles(styles)
.styles(details.styles() == null ? null : new LinkedHashSet<>(details.styles()))
.coach(coach)
.build();

View File

@@ -1,29 +1,33 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.repository.DanceSchoolRepository;
public class DanceSchoolService {
private final DanceSchoolRepository danceSchoolRepository = new DanceSchoolRepository();
public DanceSchool add(String name) {
DanceSchool danceSchool = DanceSchool.builder().name(name).build();
public DanceSchool add(final String name) {
final DanceSchool danceSchool = DanceSchool.builder().name(name).build();
return danceSchoolRepository.add(danceSchool);
}
public void addDancer(Long danceSchoolId, Dancer dancer) {
public void addDancer(final Long danceSchoolId, final Dancer dancer) {
danceSchoolRepository.findById(danceSchoolId).addDancer(dancer);
}
public void addDanceGroup(Long danceSchoolId, DanceGroup danceGroup) {
public void addDanceGroup(final Long danceSchoolId, final DanceGroup danceGroup) {
danceSchoolRepository.findById(danceSchoolId).addDanceGroup(danceGroup);
}
public void addCoach(Long danceSchoolId, Coach coach) {
public void addCoach(final Long danceSchoolId, final Coach coach) {
danceSchoolRepository.findById(danceSchoolId).addCoach(coach);
}
public void addShow(Long danceSchoolId, Show show) {
public void addShow(final Long danceSchoolId, final Show show) {
danceSchoolRepository.findById(danceSchoolId).addShow(show);
}
}

View File

@@ -7,8 +7,8 @@ import nl.agilecc.agiledancers.repository.DancerRepository;
public class DancerService {
private final DancerRepository dancerRepository = new DancerRepository();
public Dancer addDancer(String name, LocalDate birthDate) {
Dancer dancer = Dancer.builder().name(name).birthDate(birthDate).build();
public Dancer addDancer(final String name, final LocalDate birthDate) {
final Dancer dancer = Dancer.builder().name(name).birthDate(birthDate).build();
return dancerRepository.add(dancer);
}
}

View File

@@ -4,7 +4,7 @@ public class ManagementException extends RuntimeException {
private final ManagementExceptionKind kind;
public ManagementException(ManagementExceptionKind kind, String message) {
public ManagementException(final ManagementExceptionKind kind, final String message) {
super(message);
this.kind = kind;
}

View File

@@ -2,26 +2,35 @@ package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton
public class SchedulingService {
@Inject
SchoolManagementService schools;
private SchoolManagementService schools;
@Inject
SchedulingEngine engine;
private SchedulingEngine engine;
public SchedulingResult schedule(long schoolId, long showId) {
var input = schools.beginScheduling(schoolId, showId);
public SchedulingService() {}
/** Supplies collaborators directly for callers outside the CDI lifecycle. */
public SchedulingService(final SchoolManagementService schools, final SchedulingEngine engine) {
this.schools = schools;
this.engine = engine;
}
public SchedulingResult schedule(final long schoolId, final long showId) {
final SchedulingInput input = schools.beginScheduling(schoolId, showId);
try {
var result = engine.solve(input);
final SchedulingResult result = engine.solve(input);
schools.finishScheduling(schoolId, input, result);
return result;
} catch (ManagementException e) {
} catch (final ManagementException e) {
throw e;
} catch (RuntimeException | LinkageError e) {
} catch (final RuntimeException | LinkageError e) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"Solver failed. Existing assignments were preserved. Please run scheduling again.");

View File

@@ -1,17 +1,34 @@
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 java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import nl.agilecc.agiledancers.model.AbstractEntity;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.repository.*;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.repository.DanceGroupRepository;
import nl.agilecc.agiledancers.repository.DanceSchoolRepository;
import nl.agilecc.agiledancers.repository.DancerRepository;
import nl.agilecc.agiledancers.repository.ShowRepository;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.ImportPreview;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
/** Owns the in-memory school aggregates; all writes go through this shared service. */
@Singleton
@@ -24,18 +41,23 @@ public class SchoolManagementService {
private final Map<Long, Long> revisions = new HashMap<>();
private final Set<Long> running = new HashSet<>();
private void touch(Show show) {
private void touch(final 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.");
List<DanceGroup> current = groups(schoolId);
if (current.isEmpty() || show.getDayParts().isEmpty())
/** Reserves this show's run slot and snapshots its inputs; always release it with endScheduling. */
public synchronized SchedulingInput beginScheduling(final long schoolId, final long showId) {
final Show show = show(schoolId, showId);
if (running.contains(showId)) {
throw new ManagementException(
INVALID_INPUT, "Scheduling requires at least one school group and one daypart.");
ManagementExceptionKind.CONFLICT, "Scheduling is already running for this show.");
}
final List<DanceGroup> current = groups(schoolId);
if (current.isEmpty() || show.getDayParts().isEmpty()) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"Scheduling requires at least one school group and one daypart.");
}
ShowConfiguration.validate(show.getDayParts(), show.getAppearances());
running.add(showId);
return new SchedulingInput(
@@ -43,42 +65,29 @@ public class SchoolManagementService {
revisions.getOrDefault(showId, 0L),
show.getDayParts().size(),
current.stream()
.map(g -> new GroupRequirement(
.map((final DanceGroup g) -> new GroupRequirement(
g.getId(), show.getAppearances().get(g.getType())))
.toList());
}
public synchronized void finishScheduling(long schoolId, SchedulingInput input, SchedulingResult result) {
Show show;
/** Validates the complete result and input revision before replacing any saved assignments. */
public synchronized void finishScheduling(
final long schoolId, final SchedulingInput input, final SchedulingResult result) {
final Show show;
try {
show = show(schoolId, input.showId());
} catch (ManagementException e) {
throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again.");
}
if (revisions.getOrDefault(input.showId(), 0L) != input.revision())
throw new ManagementException(CONFLICT, "Scheduling inputs changed. Run scheduling again.");
// Validate the complete solver output before mutating any assignments.
if (result == null
|| result.assignments() == null
|| result.assignments().size() != input.dayParts())
} catch (final ManagementException e) {
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))
throw new ManagementException(INVALID_INPUT, "Solver returned invalid group assignments.");
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
}
for (GroupRequirement group : input.groups()) {
long id = group.groupId();
if (result.assignments().stream().filter(p -> p.contains(id)).count() != group.appearances())
throw new ManagementException(INVALID_INPUT, "Solver did not satisfy every required appearance.");
if (revisions.getOrDefault(input.showId(), 0L) != input.revision()) {
throw new ManagementException(
ManagementExceptionKind.CONFLICT, "Scheduling inputs changed. Run scheduling again.");
}
List<DayPart> replacement = new ArrayList<>();
final Map<Long, DanceGroup> current = validateAssignments(schoolId, input, result);
final List<DayPart> replacement = new ArrayList<>();
for (int p = 0; p < input.dayParts(); p++) {
DayPart old = show.getDayParts().get(p);
final DayPart old = show.getDayParts().get(p);
replacement.add(DayPart.builder()
.date(old.getDate())
.type(old.getType())
@@ -90,7 +99,43 @@ public class SchoolManagementService {
show.setDayParts(List.copyOf(replacement));
}
public synchronized void endScheduling(long showId) {
private Map<Long, DanceGroup> validateAssignments(
final long schoolId, final SchedulingInput input, final SchedulingResult result) {
// Validate the complete solver output before mutating any assignments.
if (result == null
|| result.assignments() == null
|| result.assignments().size() != input.dayParts()) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"Solver returned an invalid result. Existing assignments were preserved.");
}
final Map<Long, DanceGroup> current = new HashMap<>();
groups(schoolId).forEach((final DanceGroup g) -> current.put(g.getId(), g));
validateParts(result.assignments(), current.keySet());
for (final GroupRequirement group : input.groups()) {
final long id = group.groupId();
if (result.assignments().stream()
.filter((final List<Long> p) -> p.contains(id))
.count()
!= group.appearances()) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "Solver did not satisfy every required appearance.");
}
}
return current;
}
private static void validateParts(final List<List<Long>> assignments, final Set<Long> groupIds) {
for (final List<Long> part : assignments) {
if (part == null || new HashSet<>(part).size() != part.size() || !groupIds.containsAll(part)) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "Solver returned invalid group assignments.");
}
}
}
/** Releases the run slot after success or failure, without changing saved assignments. */
public synchronized void endScheduling(final long showId) {
running.remove(showId);
}
@@ -98,139 +143,134 @@ public class SchoolManagementService {
return schools.findAll();
}
public synchronized DanceSchool school(long schoolId) {
public synchronized DanceSchool school(final long schoolId) {
return required(schools.findById(schoolId), "Dance school", schoolId);
}
public synchronized DanceSchool createSchool(String name) {
public synchronized DanceSchool createSchool(final String name) {
return schools.add(DanceSchool.builder().name(name).build());
}
public synchronized DanceSchool updateSchool(long schoolId, String name) {
DanceSchool school = school(schoolId);
public synchronized DanceSchool updateSchool(final long schoolId, final String name) {
final DanceSchool school = school(schoolId);
school.setName(name);
return school;
}
public synchronized void deleteSchool(long schoolId) {
DanceSchool school = school(schoolId);
school.getDanceGroups().forEach(group -> groups.delete(group.getId()));
school.getDancers().forEach(dancer -> dancers.delete(dancer.getId()));
school.getShows().forEach(show -> shows.delete(show.getId()));
public synchronized void deleteSchool(final long schoolId) {
final DanceSchool school = school(schoolId);
school.getDanceGroups().forEach((final DanceGroup group) -> groups.delete(group.getId()));
school.getDancers().forEach((final Dancer dancer) -> dancers.delete(dancer.getId()));
school.getShows().forEach((final Show show) -> shows.delete(show.getId()));
schools.delete(schoolId);
}
public synchronized List<Dancer> dancers(long schoolId) {
public synchronized List<Dancer> dancers(final long schoolId) {
return List.copyOf(school(schoolId).getDancers());
}
public synchronized Dancer dancer(long schoolId, long dancerId) {
public synchronized Dancer dancer(final long schoolId, final long dancerId) {
return member(school(schoolId).getDancers(), dancerId, "Dancer");
}
public synchronized Dancer createDancer(long schoolId, String name, LocalDate birthDate) {
DanceSchool school = school(schoolId);
Dancer dancer =
public synchronized Dancer createDancer(final long schoolId, final String name, final LocalDate birthDate) {
final DanceSchool school = school(schoolId);
final Dancer dancer =
dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
school.addDancer(dancer);
return dancer;
}
public synchronized Dancer updateDancer(long schoolId, long dancerId, String name, LocalDate birthDate) {
Dancer dancer = dancer(schoolId, dancerId);
public synchronized Dancer updateDancer(
final long schoolId, final long dancerId, final String name, final LocalDate birthDate) {
final Dancer dancer = dancer(schoolId, dancerId);
dancer.setName(name);
dancer.setBirthDate(birthDate);
return dancer;
}
public synchronized void deleteDancer(long schoolId, long dancerId) {
DanceSchool school = school(schoolId);
Dancer dancer = dancer(schoolId, dancerId);
school.getDanceGroups().forEach(group -> group.removeDancer(dancer));
public synchronized void deleteDancer(final long schoolId, final long dancerId) {
final DanceSchool school = school(schoolId);
final Dancer dancer = dancer(schoolId, dancerId);
school.getDanceGroups().forEach((final DanceGroup group) -> group.removeDancer(dancer));
school.removeDancer(dancer);
dancers.delete(dancerId);
}
public synchronized List<DanceGroup> groups(long schoolId) {
public synchronized List<DanceGroup> groups(final long schoolId) {
return List.copyOf(school(schoolId).getDanceGroups());
}
public synchronized DanceGroup group(long schoolId, long groupId) {
public synchronized DanceGroup group(final long schoolId, final long groupId) {
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) {
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))
public synchronized DanceGroup createGroup(final long schoolId, final GroupDetails details) {
final DanceSchool school = school(schoolId);
validateAgeRange(details.startAge(), details.endAge());
final DanceGroup group = groups.add(DanceGroup.builder()
.category(details.category())
.type(details.type())
.startAge(details.startAge())
.endAge(details.endAge())
.styles(new LinkedHashSet<>(details.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) {
DanceGroup group = group(schoolId, groupId);
validateAgeRange(startAge, endAge);
group.setCategory(category);
if (group.getType() != type) school(schoolId).getShows().forEach(this::touch);
group.setType(type);
group.setStartAge(startAge);
group.setEndAge(endAge);
group.replaceStyles(styles);
public synchronized DanceGroup updateGroup(final long schoolId, final long groupId, final GroupDetails details) {
final DanceGroup group = group(schoolId, groupId);
validateAgeRange(details.startAge(), details.endAge());
group.setCategory(details.category());
if (group.getType() != details.type()) {
school(schoolId).getShows().forEach(this::touch);
}
group.setType(details.type());
group.setStartAge(details.startAge());
group.setEndAge(details.endAge());
group.replaceStyles(details.styles());
return group;
}
public synchronized void deleteGroup(long schoolId, long groupId) {
DanceGroup group = group(schoolId, groupId);
school(schoolId).getShows().forEach(show -> show.getDayParts().forEach(part -> part.removeDanceGroup(group)));
public synchronized void deleteGroup(final long schoolId, final long groupId) {
final DanceGroup group = group(schoolId, groupId);
school(schoolId)
.getShows()
.forEach((final Show show) ->
show.getDayParts().forEach((final DayPart part) -> part.removeDanceGroup(group)));
school(schoolId).getShows().forEach(this::touch);
school(schoolId).removeDanceGroup(group);
groups.delete(groupId);
}
public synchronized void addGroupDancer(long schoolId, long groupId, long dancerId) {
DanceGroup group = group(schoolId, groupId);
public synchronized void addGroupDancer(final long schoolId, final long groupId, final long dancerId) {
final DanceGroup group = group(schoolId, groupId);
group.addDancer(dancer(schoolId, dancerId));
}
public synchronized void removeGroupDancer(long schoolId, long groupId, long dancerId) {
DanceGroup group = group(schoolId, groupId);
public synchronized void removeGroupDancer(final long schoolId, final long groupId, final long dancerId) {
final DanceGroup group = group(schoolId, groupId);
group.removeDancer(dancer(schoolId, dancerId));
}
public synchronized List<Show> shows(long schoolId) {
public synchronized List<Show> shows(final long schoolId) {
return List.copyOf(school(schoolId).getShows());
}
public synchronized Show show(long schoolId, long showId) {
public synchronized Show show(final long schoolId, final long showId) {
return member(school(schoolId).getShows(), showId, "Show");
}
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());
final long schoolId, final String name, final List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
final DanceSchool school = school(schoolId);
if (counts == null) {
counts = ShowConfiguration.defaults(parts.size());
}
ShowConfiguration.validate(parts, counts);
Show show = shows.add(Show.builder()
final Show show = shows.add(Show.builder()
.name(name)
.dayParts(List.copyOf(parts))
.appearances(Map.copyOf(counts))
@@ -240,19 +280,25 @@ public class SchoolManagementService {
}
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)
final long schoolId,
final long showId,
final String name,
final List<DayPart> parts,
Map<DanceGroupType, Integer> counts) {
final Show show = show(schoolId, showId);
if (counts == null) {
counts = show.getAppearances().isEmpty() ? ShowConfiguration.defaults(parts.size()) : show.getAppearances();
}
ShowConfiguration.validate(parts, counts);
boolean changed =
final boolean changed =
!counts.equals(show.getAppearances()) || !partKeys(parts).equals(partKeys(show.getDayParts()));
List<DayPart> retained = parts.stream()
.map(p -> DayPart.builder()
final List<DayPart> retained = parts.stream()
.map((final DayPart p) -> DayPart.builder()
.date(p.getDate())
.type(p.getType())
.danceGroups(show.getDayParts().stream()
.filter(old -> old.getDate().equals(p.getDate()) && old.getType() == p.getType())
.filter((final DayPart old) ->
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
.findFirst()
.map(DayPart::getDanceGroups)
.orElse(List.of()))
@@ -261,28 +307,32 @@ public class SchoolManagementService {
show.setName(name);
show.setDayParts(retained);
show.setAppearances(Map.copyOf(counts));
if (changed) touch(show);
if (changed) {
touch(show);
}
return show;
}
private static List<String> partKeys(List<DayPart> parts) {
return parts.stream().map(p -> p.getDate() + ":" + p.getType()).toList();
private static List<String> partKeys(final List<DayPart> parts) {
return parts.stream()
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
.toList();
}
public synchronized void deleteShow(long schoolId, long showId) {
Show show = show(schoolId, showId);
public synchronized void deleteShow(final long schoolId, final long showId) {
final Show show = show(schoolId, showId);
school(schoolId).removeShow(show);
shows.delete(showId);
}
public synchronized SchoolSnapshot exportSchool(long id) {
public synchronized SchoolSnapshot exportSchool(final long id) {
return SchoolSnapshot.of(school(id));
}
public synchronized ImportPreview preview(SchoolSnapshot snapshot) {
public synchronized ImportPreview preview(final SchoolSnapshot snapshot) {
snapshot.restore();
DanceSchool existing = schools.findAll().stream()
.filter(s -> s.getKey().equals(snapshot.key()))
final DanceSchool existing = schools.findAll().stream()
.filter((final DanceSchool s) -> s.getKey().equals(snapshot.key()))
.findFirst()
.orElse(null);
return new ImportPreview(
@@ -294,58 +344,30 @@ public class SchoolManagementService {
existing == null ? null : existing.getName());
}
public synchronized DanceSchool importSchool(SchoolSnapshot snapshot, Long replaceId) {
DanceSchool staged = snapshot.restore();
ImportPreview preview = preview(snapshot);
/** Validates and stages the complete graph before replacing repositories; file IDs become runtime IDs. */
public synchronized DanceSchool importSchool(final SchoolSnapshot snapshot, final Long replaceId) {
final DanceSchool staged = snapshot.restore();
final ImportPreview preview = preview(snapshot);
if (!Objects.equals(preview.existingId(), replaceId)) {
throw new ManagementException(INVALID_INPUT, "De bestaande school is gewijzigd. Bekijk de import opnieuw.");
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"De bestaande school is gewijzigd. Bekijk de import opnieuw.");
}
// All validation and construction finish before changing repository state.
// Rebuild sets after assigning runtime IDs: entity hash codes include the ID.
List<Dancer> importedDancers = new ArrayList<>(staged.getDancers());
List<DanceGroup> importedGroups = new ArrayList<>(staged.getDanceGroups());
List<Show> importedShows = new ArrayList<>(staged.getShows());
Map<DanceGroup, List<Dancer>> memberships = new IdentityHashMap<>();
importedGroups.forEach(g -> memberships.put(g, new ArrayList<>(g.getDancers())));
importedDancers.forEach(d -> d.setId(dancers.nextId()));
importedGroups.forEach(g -> {
final List<Dancer> importedDancers = new ArrayList<>(staged.getDancers());
final List<DanceGroup> importedGroups = new ArrayList<>(staged.getDanceGroups());
final List<Show> importedShows = new ArrayList<>(staged.getShows());
final Map<DanceGroup, List<Dancer>> memberships = new IdentityHashMap<>();
importedGroups.forEach((final DanceGroup g) -> memberships.put(g, new ArrayList<>(g.getDancers())));
importedDancers.forEach((final Dancer d) -> d.setId(dancers.nextId()));
importedGroups.forEach((final DanceGroup g) -> {
g.setId(groups.nextId());
});
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);
}
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);
}
DanceSchool imported = DanceSchool.builder()
final Map<DanceGroup, DanceGroup> replacements = new IdentityHashMap<>();
final List<DanceGroup> rebuiltGroups = rebuildGroups(importedGroups, memberships, replacements);
final List<Show> rebuiltShows = rebuildShows(importedShows, replacements);
final DanceSchool imported = DanceSchool.builder()
.key(staged.getKey())
.name(staged.getName())
.dancers(new LinkedHashSet<>(importedDancers))
@@ -363,22 +385,67 @@ public class SchoolManagementService {
return schools.add(imported);
}
private static void validateAgeRange(int startAge, int endAge) {
private static List<DanceGroup> rebuildGroups(
final List<DanceGroup> importedGroups,
final Map<DanceGroup, List<Dancer>> memberships,
final Map<DanceGroup, DanceGroup> replacements) {
final List<DanceGroup> rebuiltGroups = new ArrayList<>();
for (final DanceGroup g : importedGroups) {
final 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);
}
return rebuiltGroups;
}
private List<Show> rebuildShows(final List<Show> importedShows, final Map<DanceGroup, DanceGroup> replacements) {
final List<Show> rebuiltShows = new ArrayList<>();
for (final Show show : importedShows) {
final Show rebuilt = Show.builder()
.name(show.getName())
.appearances(show.getAppearances())
.dayParts(show.getDayParts().stream()
.map((final DayPart 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);
}
return rebuiltShows;
}
private static void validateAgeRange(final int startAge, final int endAge) {
if (startAge > endAge) {
throw new ManagementException(INVALID_INPUT, "startAge must not exceed endAge");
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "startAge must not exceed endAge");
}
}
private static <T extends AbstractEntity> T member(Collection<T> entities, long id, String type) {
private static <T extends AbstractEntity> T member(final Collection<T> entities, final long id, final String type) {
return entities.stream()
.filter(entity -> entity.getId() == id)
.filter((final T entity) -> entity.getId() == id)
.findFirst()
.orElseThrow(() -> new ManagementException(NOT_FOUND, type + " " + id + " not found in this school"));
.orElseThrow(() -> new ManagementException(
ManagementExceptionKind.NOT_FOUND, type + " " + id + " not found in this school"));
}
private static <T> T required(T value, String type, long id) {
private static <T> T required(final T value, final String type, final long id) {
if (value == null) {
throw new ManagementException(NOT_FOUND, type + " " + id + " not found");
throw new ManagementException(ManagementExceptionKind.NOT_FOUND, type + " " + id + " not found");
}
return value;
}

View File

@@ -1,16 +1,19 @@
package nl.agilecc.agiledancers.service;
import java.util.*;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
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())
public static Map<DanceGroupType, Integer> defaults(final int n) {
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
if (n > 0) {
for (final DanceGroupType type : DanceGroupType.values()) {
counts.put(
type,
switch (type) {
@@ -18,28 +21,41 @@ public final class ShowConfiguration {
case SHOW_CASE, PRE_EDUCATION -> n;
default -> Math.min(2, n);
});
}
}
return counts;
}
public static void validate(List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
public static void validate(final List<DayPart> parts, final Map<DanceGroupType, Integer> counts) {
validateParts(parts);
if (counts == null) {
invalidCounts();
}
if (parts.isEmpty() && counts.isEmpty()) {
return;
}
if (counts.size() != DanceGroupType.values().length
|| Arrays.stream(DanceGroupType.values())
.anyMatch((final DanceGroupType t) -> counts.get(t) == null
|| counts.get(t) < 1
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) {
invalidCounts();
}
}
private static void validateParts(final List<DayPart> parts) {
if (parts == null
|| parts.size() > 6
|| parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null)
|| parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null)
|| parts.stream()
.map(p -> p.getDate() + ":" + p.getType())
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
.distinct()
.count()
!= parts.size())
!= 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();
}
}
private static void invalidCounts() {

View File

@@ -1,5 +1,7 @@
package nl.agilecc.agiledancers.service.model;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@@ -12,4 +14,10 @@ public record Group(
Integer endAge,
List<DanceGroupStyle> styles,
List<String> dancerIds,
String coachId) {}
String coachId) {
// The maintainer-approved parameter waiver preserves this version-2 JSON contract.
public Group {
styles = styles == null ? null : Collections.unmodifiableList(new ArrayList<>(styles));
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
}
}

View File

@@ -0,0 +1,14 @@
package nl.agilecc.agiledancers.service.model;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record GroupDetails(
String category, DanceGroupType type, int startAge, int endAge, Set<DanceGroupStyle> styles) {
public GroupDetails {
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
}
}

View File

@@ -1,7 +1,13 @@
package nl.agilecc.agiledancers.service.model;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
public record Part(LocalDate date, DayPartType type, List<String> groupIds) {}
public record Part(LocalDate date, DayPartType type, List<String> groupIds) {
public Part {
groupIds = groupIds == null ? null : Collections.unmodifiableList(new ArrayList<>(groupIds));
}
}

View File

@@ -1,7 +1,15 @@
package nl.agilecc.agiledancers.service.model;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record Performance(String id, String name, List<Part> dayParts, Map<DanceGroupType, Integer> appearances) {}
public record Performance(String id, String name, List<Part> dayParts, Map<DanceGroupType, Integer> appearances) {
public Performance {
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
}
}

View File

@@ -1,9 +1,24 @@
package nl.agilecc.agiledancers.service.model;
import java.time.LocalDate;
import java.util.*;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.ShowConfiguration;
@@ -17,23 +32,30 @@ public record SchoolSnapshot(
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());
public SchoolSnapshot {
dancers = dancers == null ? null : Collections.unmodifiableList(new ArrayList<>(dancers));
coaches = coaches == null ? null : Collections.unmodifiableList(new ArrayList<>(coaches));
groups = groups == null ? null : Collections.unmodifiableList(new ArrayList<>(groups));
shows = shows == null ? null : Collections.unmodifiableList(new ArrayList<>(shows));
}
public static SchoolSnapshot of(final DanceSchool s) {
final Map<Dancer, String> dancers = ids(s.getDancers());
final Map<Coach, String> coaches = ids(s.getCoaches());
final Map<DanceGroup, String> groups = ids(s.getDanceGroups());
final 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()))
.map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
.toList(),
s.getCoaches().stream()
.map(c -> new Trainer(coaches.get(c), c.getName()))
.map((final Coach c) -> new Trainer(coaches.get(c), c.getName()))
.toList(),
s.getDanceGroups().stream()
.map(g -> new Group(
.map((final DanceGroup g) -> new Group(
groups.get(g),
g.getCategory(),
g.getType(),
@@ -44,11 +66,11 @@ public record SchoolSnapshot(
g.getCoach() == null ? null : coaches.get(g.getCoach())))
.toList(),
s.getShows().stream()
.map(show -> new Performance(
.map((final Show show) -> new Performance(
shows.get(show),
show.getName(),
show.getDayParts().stream()
.map(p -> new Part(
.map((final DayPart p) -> new Part(
p.getDate(),
p.getType(),
p.getDanceGroups().stream()
@@ -59,42 +81,62 @@ public record SchoolSnapshot(
.toList());
}
private static <T> Map<T, String> ids(Collection<T> values) {
Map<T, String> result = new IdentityHashMap<>();
for (T value : values) result.put(value, Integer.toString(result.size() + 1));
private static <T> Map<T, String> ids(final Collection<T> values) {
final Map<T, String> result = new IdentityHashMap<>();
for (final T value : values) {
result.put(value, Integer.toString(result.size() + 1));
}
return result;
}
/** Validates all references and values, then rebuilds an independent graph with temporary IDs. */
public DanceSchool restore() {
check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
check(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")) {
final DanceSchool school = DanceSchool.builder().key(key).name(name).build();
final Map<String, Dancer> dancerMap = restoreDancers(school);
final Map<String, Coach> coachMap = restoreCoaches(school);
final Map<String, DanceGroup> groupMap = restoreGroups(school, dancerMap, coachMap);
restoreShows(school, groupMap);
return school;
}
private Map<String, Dancer> restoreDancers(final DanceSchool school) {
final Map<String, Dancer> dancerMap = new LinkedHashMap<>();
for (final 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 =
final 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);
}
Map<String, Coach> coachMap = new LinkedHashMap<>();
for (Trainer c : list(coaches, "coaches")) {
return dancerMap;
}
private Map<String, Coach> restoreCoaches(final DanceSchool school) {
final Map<String, Coach> coachMap = new LinkedHashMap<>();
for (final Trainer c : list(coaches, "coaches")) {
check(c != null, "Lege coach");
text(c.name(), "coachnaam");
Coach coach = Coach.builder().name(c.name()).build();
final 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")) {
return coachMap;
}
private Map<String, DanceGroup> restoreGroups(
final DanceSchool school, final Map<String, Dancer> dancerMap, final Map<String, Coach> coachMap) {
final Map<String, DanceGroup> groupMap = new LinkedHashMap<>();
for (final Group g : list(groups, "groups")) {
check(g != null, "Lege groep");
text(g.category(), "groepsnaam");
check(
@@ -104,7 +146,7 @@ public record SchoolSnapshot(
&& g.startAge() >= 0
&& g.endAge() >= g.startAge(),
"Ongeldig type of leeftijdsbereik voor groep " + g.category());
DanceGroup group = DanceGroup.builder()
final DanceGroup group = DanceGroup.builder()
.category(g.category())
.type(g.type())
.startAge(g.startAge())
@@ -120,20 +162,25 @@ public record SchoolSnapshot(
.build();
check(!group.getStyles().contains(null), "Ongeldige dansstijl");
group.setId((long) groupMap.size());
for (String id : unique(g.dancerIds(), "dancerIds"))
for (final 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")) {
return groupMap;
}
private void restoreShows(final DanceSchool school, final Map<String, DanceGroup> groupMap) {
final Set<String> showIds = new HashSet<>();
for (final 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<>();
for (Part part : list(show.dayParts(), "dayParts")) {
final List<DayPart> parts = new ArrayList<>();
final Set<String> types = new HashSet<>();
for (final Part part : list(show.dayParts(), "dayParts")) {
check(
part != null
&& part.type() != null
@@ -144,13 +191,13 @@ public record SchoolSnapshot(
.date(part.date())
.type(part.type())
.danceGroups(unique(part.groupIds(), "groupIds").stream()
.map(id ->
.map((final String id) ->
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
.toList())
.build());
}
ShowConfiguration.validate(parts, show.appearances());
Show entity = Show.builder()
final Show entity = Show.builder()
.name(show.name())
.dayParts(parts)
.appearances(Map.copyOf(show.appearances()))
@@ -158,19 +205,18 @@ public record SchoolSnapshot(
entity.setId((long) showIds.size());
school.addShow(entity);
}
return school;
}
private static void text(String value, String field) {
private static void text(final String value, final String field) {
check(value != null && !value.isBlank(), field + " ontbreekt");
}
private static <T> List<T> list(List<T> value, String field) {
private static <T> List<T> list(final List<T> value, final String field) {
check(value != null, field + " ontbreekt");
return value;
}
private static List<String> unique(List<String> value, String field) {
private static List<String> unique(final List<String> value, final String field) {
list(value, field);
check(
value.stream().noneMatch(Objects::isNull) && new HashSet<>(value).size() == value.size(),
@@ -178,18 +224,20 @@ public record SchoolSnapshot(
return value;
}
private static <T> void put(Map<String, T> map, String id, T value) {
private static <T> void put(final Map<String, T> map, final String id, final 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);
private static <T> T ref(final Map<String, T> map, final String id, final String error) {
final 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 void check(final boolean valid, final String message) {
if (!valid) {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, message);
}
}
}

View File

@@ -4,5 +4,6 @@ import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
public interface SchedulingEngine {
SchedulingResult solve(SchedulingInput input);
/** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */
public SchedulingResult solve(final SchedulingInput input);
}

View File

@@ -1,45 +1,71 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.Loader;
import com.google.ortools.sat.*;
import com.google.ortools.sat.BoolVar;
import com.google.ortools.sat.CpModel;
import com.google.ortools.sat.CpSolver;
import com.google.ortools.sat.CpSolverStatus;
import com.google.ortools.sat.IntVar;
import com.google.ortools.sat.LinearExpr;
import jakarta.inject.Singleton;
import java.util.*;
import java.util.ArrayList;
import java.util.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine {
@Override
public SchedulingResult solve(SchedulingInput input) {
public SchedulingResult solve(final SchedulingInput input) {
Loader.loadNativeLibraries();
CpModel model = new CpModel();
int n = input.dayParts(), groups = input.groups().size();
BoolVar[][] assigned = new BoolVar[groups][n];
for (int g = 0; g < groups; g++) {
for (int p = 0; p < n; p++) assigned[g][p] = model.newBoolVar("g" + g + "p" + p);
model.addEquality(LinearExpr.sum(assigned[g]), input.groups().get(g).appearances());
}
IntVar[] loads = new IntVar[n];
for (int p = 0; p < n; p++) {
BoolVar[] column = new BoolVar[groups];
for (int g = 0; g < groups; g++) column[g] = assigned[g][p];
loads[p] = model.newIntVar(0, groups, "load" + p);
model.addEquality(loads[p], LinearExpr.sum(column));
}
IntVar max = model.newIntVar(0, groups, "max"), min = model.newIntVar(0, groups, "min");
model.addMaxEquality(max, loads);
model.addMinEquality(min, loads);
model.minimize(LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1}));
CpSolver solver = new CpSolver();
final CpModel model = new CpModel();
final BoolVar[][] assigned = assignments(model, input);
balance(model, assigned, input.dayParts());
final CpSolver solver = new CpSolver();
solver.getParameters().setMaxTimeInSeconds(1800);
// Quarkus owns process shutdown; do not install a native SIGINT handler.
solver.getParameters().setCatchSigintSignal(false);
// Single-worker search with a fixed seed makes identical inputs repeatable.
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
CpSolverStatus status = solver.solve(model);
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE)
final CpSolverStatus status = solver.solve(model);
requireSolution(status);
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, readAssignments(input, assigned, solver));
}
private static BoolVar[][] assignments(final CpModel model, final SchedulingInput input) {
final BoolVar[][] assigned = new BoolVar[input.groups().size()][input.dayParts()];
for (int g = 0; g < assigned.length; g++) {
for (int p = 0; p < input.dayParts(); p++) {
assigned[g][p] = model.newBoolVar("g" + g + "p" + p);
}
model.addEquality(LinearExpr.sum(assigned[g]), input.groups().get(g).appearances());
}
return assigned;
}
private static void balance(final CpModel model, final BoolVar[][] assigned, final int dayParts) {
final int groups = assigned.length;
final IntVar[] loads = new IntVar[dayParts];
for (int p = 0; p < dayParts; p++) {
final BoolVar[] column = new BoolVar[groups];
for (int g = 0; g < groups; g++) {
column[g] = assigned[g][p];
}
loads[p] = model.newIntVar(0, groups, "load" + p);
model.addEquality(loads[p], LinearExpr.sum(column));
}
final IntVar max = model.newIntVar(0, groups, "max");
final IntVar min = model.newIntVar(0, groups, "min");
model.addMaxEquality(max, loads);
model.addMinEquality(min, loads);
model.minimize(LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1}));
}
private static void requireSolution(final CpSolverStatus status) {
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
switch (status) {
@@ -49,14 +75,21 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
+ " 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]))
}
}
private static List<List<Long>> readAssignments(
final SchedulingInput input, final BoolVar[][] assigned, final CpSolver solver) {
final List<List<Long>> parts = new ArrayList<>();
for (int p = 0; p < input.dayParts(); p++) {
final List<Long> ids = new ArrayList<>();
for (int g = 0; g < assigned.length; g++) {
if (solver.booleanValue(assigned[g][p])) {
ids.add(input.groups().get(g).groupId());
}
}
parts.add(List.copyOf(ids));
}
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));
return List.copyOf(parts);
}
}

View File

@@ -4,3 +4,8 @@ quarkus.http.host=0.0.0.0
# Scheduling is synchronous with a 30-minute solver limit.
quarkus.http.idle-timeout=31M
quarkus.jackson.fail-on-unknown-properties=true
# Share execution data from ordinary and Quarkus tests. Reports are generated at verify.
%test.quarkus.jacoco.data-file=target/jacoco.exec
%test.quarkus.jacoco.reuse-data-file=true
%test.quarkus.jacoco.report=false

View File

@@ -17,8 +17,7 @@ class ArchitectureTest {
private static final String API_MODEL = ROOT + ".rest.apimodel";
// Exclude Lombok builders and compiler-generated enum-switch helper classes.
@ArchTest
static final ArchRule handwritten_types_are_top_level = classes()
private @ArchTest static final ArchRule HANDWRITTEN_TYPES_ARE_TOP_LEVEL = classes()
.that()
.areNotAnnotatedWith("lombok.Generated")
.and()
@@ -26,8 +25,7 @@ class ArchitectureTest {
.should()
.beTopLevelClasses();
@ArchTest
static final ArchRule service_records_reside_in_service_model = classes()
private @ArchTest static final ArchRule SERVICE_RECORDS_RESIDE_IN_SERVICE_MODEL = classes()
.that()
.resideInAPackage(ROOT + ".service..")
.and()
@@ -35,8 +33,7 @@ class ArchitectureTest {
.should()
.resideInAPackage(ROOT + ".service.model");
@ArchTest
static final ArchRule service_interfaces_reside_in_service_port = classes()
private @ArchTest static final ArchRule SERVICE_INTERFACES_RESIDE_IN_SERVICE_PORT = classes()
.that()
.resideInAPackage(ROOT + ".service..")
.and()
@@ -44,8 +41,7 @@ class ArchitectureTest {
.should()
.resideInAPackage(ROOT + ".service.port");
@ArchTest
static final ArchRule scheduling_port_uses_domain_types = classes()
private @ArchTest static final ArchRule SCHEDULING_PORT_USES_DOMAIN_TYPES = classes()
.that()
.resideInAPackage(ROOT + ".service.port..")
.should()
@@ -53,15 +49,13 @@ class ArchitectureTest {
.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()
private @ArchTest static final ArchRule API_MODELS_RESIDE_IN_THE_API_MODEL_PACKAGE = classes()
.that()
.haveNameMatching(".*(Request|Response|View|Dto|DTO)$")
.should()
.resideInAPackage(API_MODEL);
@ArchTest
static final ArchRule rest_records_reside_in_the_api_model_package = classes()
private @ArchTest static final ArchRule REST_RECORDS_RESIDE_IN_THE_API_MODEL_PACKAGE = classes()
.that()
.resideInAPackage(ROOT + ".rest..")
.and()
@@ -69,8 +63,7 @@ class ArchitectureTest {
.should()
.resideInAPackage(API_MODEL);
@ArchTest
static final ArchRule api_models_are_public_top_level_types = classes()
private @ArchTest static final ArchRule API_MODELS_ARE_PUBLIC_TOP_LEVEL_TYPES = classes()
.that()
.resideInAPackage(API_MODEL + "..")
.should()
@@ -82,8 +75,7 @@ class ArchitectureTest {
// 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()
private @ArchTest static final ArchRule LAYER_DEPENDENCIES = layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Model")
.definedBy(ROOT + ".model..")
@@ -106,8 +98,7 @@ class ArchitectureTest {
.whereLayer("Solver")
.mayOnlyAccessLayers("Services", "Model");
@ArchTest
static final ArchRule production_classes_have_an_architectural_home = classes()
private @ArchTest static final ArchRule PRODUCTION_CLASSES_HAVE_AN_ARCHITECTURAL_HOME = classes()
.should()
.resideInAnyPackage(
ROOT + ".model..",
@@ -116,31 +107,27 @@ class ArchitectureTest {
ROOT + ".rest..",
ROOT + ".solver..");
@ArchTest
static final ArchRule model_is_independent_of_frameworks = classes()
private @ArchTest static final ArchRule MODEL_IS_INDEPENDENT_OF_FRAMEWORKS = classes()
.that()
.resideInAPackage(ROOT + ".model..")
.should()
.onlyDependOnClassesThat()
.resideInAnyPackage(ROOT + ".model..", "java..", "lombok..");
@ArchTest
static final ArchRule ortools_is_encapsulated_in_solver = noClasses()
private @ArchTest static final ArchRule ORTOOLS_IS_ENCAPSULATED_IN_SOLVER = noClasses()
.that()
.resideOutsideOfPackage(ROOT + ".solver..")
.should()
.dependOnClassesThat()
.resideInAPackage("com.google.ortools..");
@ArchTest
static final ArchRule http_api_is_encapsulated_in_rest = noClasses()
private @ArchTest static final ArchRule HTTP_API_IS_ENCAPSULATED_IN_REST = noClasses()
.that()
.resideOutsideOfPackage(ROOT + ".rest..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("jakarta.ws.rs..", "org.jboss.resteasy..");
@ArchTest
static final ArchRule packages_are_free_of_cycles =
private @ArchTest static final ArchRule PACKAGES_ARE_FREE_OF_CYCLES =
slices().matching(ROOT + ".(*)..").should().beFreeOfCycles();
}

View File

@@ -8,8 +8,7 @@ import org.junit.jupiter.api.Test;
@QuarkusTest
class HealthTest {
@Test
void applicationIsReady() {
public @Test void applicationIsReady() {
given().when().get("/q/health/ready").then().statusCode(200).body("status", equalTo("UP"));
}
}

View File

@@ -1,14 +1,28 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import java.util.*;
import nl.agilecc.agiledancers.model.*;
import java.util.EnumMap;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.Set;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowConfiguration;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
@@ -16,9 +30,9 @@ class SchedulingTest {
private final SchoolManagementService service = new SchoolManagementService();
private final long school = service.createSchool("Studio").getId();
private List<DayPart> parts(int n) {
private List<DayPart> parts(final int n) {
return java.util.stream.IntStream.range(0, n)
.mapToObj(i -> DayPart.builder()
.mapToObj((final int i) -> DayPart.builder()
.date(LocalDate.of(2027, 1, 1).plusDays(i))
.type(DayPartType.AFTERNOON)
.danceGroups(List.of())
@@ -26,12 +40,12 @@ class SchedulingTest {
.toList();
}
private DanceGroup group(DanceGroupType type) {
return service.createGroup(school, "Group", type, 0, 99, Set.of());
private DanceGroup group(final DanceGroupType type) {
return service.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
}
private void run(Show show) {
var input = service.beginScheduling(school, show.getId());
private void run(final Show show) {
final SchedulingInput input = service.beginScheduling(school, show.getId());
try {
service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input));
} finally {
@@ -39,41 +53,42 @@ class SchedulingTest {
}
}
@Test
void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
public @Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
group(DanceGroupType.PRE_SCHOOLER);
var show = service.createShow(school, "Show", parts(6), null);
final Show show = service.createShow(school, "Show", parts(6), null);
run(show);
var loads = show.getDayParts().stream()
.mapToInt(p -> p.getDanceGroups().size())
IntSummaryStatistics loads = show.getDayParts().stream()
.mapToInt((final DayPart p) -> p.getDanceGroups().size())
.summaryStatistics();
assertEquals(1, loads.getSum());
assertEquals(0, loads.getMin());
assertEquals(1, loads.getMax());
for (var type : DanceGroupType.values()) group(type);
for (final DanceGroupType type : DanceGroupType.values()) {
group(type);
}
run(show);
for (var group : service.groups(school))
for (final DanceGroup group : service.groups(school)) {
assertEquals(
show.getAppearances().get(group.getType()).longValue(),
show.getDayParts().stream()
.filter(p -> p.getDanceGroups().contains(group))
.filter((final DayPart p) -> p.getDanceGroups().contains(group))
.count());
}
loads = show.getDayParts().stream()
.mapToInt(p -> p.getDanceGroups().size())
.mapToInt((final DayPart p) -> p.getDanceGroups().size())
.summaryStatistics();
assertTrue(loads.getMax() - loads.getMin() <= 1);
var snapshot = service.exportSchool(school);
final SchoolSnapshot snapshot = service.exportSchool(school);
assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore()));
}
@Test
void defaultsInitialConfigurationAndPreservationOnEdits() {
var show = service.createShow(school, "Show", List.of(), null);
public @Test void defaultsInitialConfigurationAndPreservationOnEdits() {
final Show show = service.createShow(school, "Show", List.of(), null);
assertTrue(show.getAppearances().isEmpty());
service.updateShow(school, show.getId(), "Show", parts(3), null);
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
var counts = new EnumMap<>(show.getAppearances());
final 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());
@@ -85,36 +100,35 @@ class SchedulingTest {
assertEquals(counts, show.getAppearances());
}
@Test
void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
var duplicate = List.of(parts(1).getFirst(), parts(1).getFirst());
public @Test void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
final List<DayPart> 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);
final Map<DanceGroupType, Integer> counts = ShowConfiguration.defaults(2);
counts.put(DanceGroupType.REGULAR, 3);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
counts.put(DanceGroupType.REGULAR, 0);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
}
@Test
void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
var group = group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null);
public @Test void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
final DanceGroup group = group(DanceGroupType.REGULAR);
final Show 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());
final List<List<DanceGroup>> before =
show.getDayParts().stream().map(DayPart::getDanceGroups).toList();
final SchedulingInput input = service.beginScheduling(school, show.getId());
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
var result = new OrToolsSchedulingEngine().solve(input);
service.updateGroup(school, group.getId(), "Renamed", group.getType(), 0, 99, Set.of());
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input);
service.updateGroup(school, group.getId(), new GroupDetails("Renamed", group.getType(), 0, 99, Set.of()));
assertDoesNotThrow(() -> service.finishScheduling(school, input, result));
group(DanceGroupType.PRE_SCHOOLER);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
assertEquals(
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
service.endScheduling(show.getId());
var fresh = service.beginScheduling(school, show.getId());
final SchedulingInput fresh = service.beginScheduling(school, show.getId());
assertThrows(
ManagementException.class,
() -> service.finishScheduling(
@@ -122,18 +136,17 @@ class SchedulingTest {
assertEquals(
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
// Feasible results are accepted without requiring proof of optimality.
var valid = new OrToolsSchedulingEngine().solve(fresh);
final SchedulingResult valid = new OrToolsSchedulingEngine().solve(fresh);
assertDoesNotThrow(
() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments())));
service.endScheduling(show.getId());
}
@Test
void staleDaypartCountChangesAndDeletedShowsAreRejected() {
public @Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {
group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null);
var input = service.beginScheduling(school, show.getId());
var result = new OrToolsSchedulingEngine().solve(input);
final Show show = service.createShow(school, "Show", parts(2), null);
final SchedulingInput input = service.beginScheduling(school, show.getId());
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input);
service.updateShow(school, show.getId(), "Show", parts(3), null);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.deleteShow(school, show.getId());
@@ -141,13 +154,12 @@ class SchedulingTest {
service.endScheduling(show.getId());
}
@Test
void needsGroupsAndDayparts() {
var show = service.createShow(school, "Show", parts(1), null);
public @Test void needsGroupsAndDayparts() {
final Show show = service.createShow(school, "Show", parts(1), null);
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
group(DanceGroupType.REGULAR);
var empty = service.createShow(school, "Empty", List.of(), null);
final Show empty = service.createShow(school, "Empty", List.of(), null);
assertThrows(ManagementException.class, () -> service.beginScheduling(school, empty.getId()));
assertTrue(show.getAppearances().values().stream().allMatch(n -> n == 1));
assertTrue(show.getAppearances().values().stream().allMatch((final Integer n) -> n == 1));
}
}

View File

@@ -1,7 +1,13 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.everyItem;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.matchesPattern;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.ValidatableResponse;
@@ -17,59 +23,26 @@ class SchoolManagementResourceTest {
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() {
long schoolId = create(SCHOOLS, Map.of("name", "Dance Studio"));
String school = SCHOOLS + "/" + schoolId;
public @Test void managesSchoolAndItsResources() {
final long schoolId = create(SCHOOLS, Map.of("name", "Dance Studio"));
final String school = SCHOOLS + "/" + schoolId;
try {
given().get(school).then().statusCode(200).body("name", equalTo("Dance Studio"));
given().get(SCHOOLS).then().statusCode(200).body("id", hasItem((int) schoolId));
put(school, Map.of("name", "Renamed studio")).statusCode(200).body("name", equalTo("Renamed studio"));
long dancerId = create(school + "/dancers", DANCER);
String dancer = school + "/dancers/" + dancerId;
final long dancerId = create(school + "/dancers", DANCER);
final String dancer = school + "/dancers/" + dancerId;
put(dancer, Map.of("name", "Sam updated", "birthDate", "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"));
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)
.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"));
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
final String group = manageGroup(school, dancerId);
final long groupId = Long.parseLong(group.substring(group.lastIndexOf('/') + 1));
final String show = manageShow(school);
final long showId = Long.parseLong(show.substring(show.lastIndexOf('/') + 1));
given().get(school)
.then()
.statusCode(200)
@@ -77,7 +50,7 @@ class SchoolManagementResourceTest {
.body("danceGroupIds", contains((int) groupId))
.body("showIds", contains((int) showId));
for (String path : new String[] {dancer, group, show}) {
for (final 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);
@@ -93,14 +66,13 @@ class SchoolManagementResourceTest {
given().get(school).then().statusCode(404);
}
@Test
void dancerCanBelongToMultipleGroupsAndDeletingDancerRemovesMemberships() {
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Membership school"));
public @Test void dancerCanBelongToMultipleGroupsAndDeletingDancerRemovesMemberships() {
final String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Membership school"));
try {
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}) {
final long dancerId = create(school + "/dancers", DANCER);
final String first = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
final String second = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
for (final 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));
@@ -118,16 +90,15 @@ class SchoolManagementResourceTest {
}
}
@Test
void isolatesSchoolsAndCascadesSchoolDeletion() {
String first = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "First"));
String second = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Second"));
public @Test void isolatesSchoolsAndCascadesSchoolDeletion() {
final String first = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "First"));
final String second = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Second"));
try {
long dancer = create(first + "/dancers", DANCER);
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}) {
final long dancer = create(first + "/dancers", DANCER);
final long group = create(first + "/dance-groups", GROUP);
final long show = create(first + "/shows", SHOW);
final long otherGroup = create(second + "/dance-groups", GROUP);
for (final 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);
}
@@ -149,42 +120,19 @@ class SchoolManagementResourceTest {
}
}
@Test
void rejectsInvalidInputWithoutChangingStoredData() {
public @Test void rejectsInvalidInputWithoutChangingStoredData() {
given().contentType("application/json").body("{}").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"));
final 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 (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);
rejectsInvalidDancersAndGroups(school);
final long group = create(school + "/dance-groups", GROUP);
put(
school + "/dance-groups/" + group,
Map.of(
@@ -217,25 +165,25 @@ class SchoolManagementResourceTest {
}
}
@Test
void schedulesAndValidatesTheNewShowConfiguration() {
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Scheduling school"));
public @Test void schedulesAndValidatesTheNewShowConfiguration() {
final String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Scheduling school"));
try {
String show = school + "/shows/" + create(school + "/shows", SHOW);
final String show = school + "/shows/" + create(school + "/shows", SHOW);
given().post(show + "/schedule").then().statusCode(400);
long group = create(school + "/dance-groups", GROUP);
final long group = create(school + "/dance-groups", GROUP);
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
String before = given().get(show).asString();
String counts = "{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
for (String count : new String[] {"0", "2", "1.5", "null"}) {
String body =
final String before = given().get(show).asString();
final String counts =
"{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
for (final String count : new String[] {"0", "2", "1.5", "null"}) {
final 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")
final String exported = given().get(school + "/export")
.then()
.statusCode(200)
.extract()
@@ -256,7 +204,77 @@ class SchoolManagementResourceTest {
}
}
private static long create(String path, Object body) {
private static String manageGroup(final String school, final long dancerId) {
final long groupId = create(school + "/dance-groups", GROUP);
final 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"));
given().get(group).then().statusCode(200).body("type", equalTo("SELECTION"));
given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId));
return group;
}
private static String manageShow(final String school) {
final long showId = create(school + "/shows", SHOW);
final String show = school + "/shows/" + showId;
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"));
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
return show;
}
private static void rejectsInvalidDancersAndGroups(final String school) {
for (final 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 (final 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);
}
}
private static long create(final String path, final Object body) {
return given().contentType("application/json")
.body(body)
.post(path)
@@ -268,7 +286,7 @@ class SchoolManagementResourceTest {
.getLong("id");
}
private static ValidatableResponse put(String path, Object body) {
private static ValidatableResponse put(final String path, final Object body) {
return given().contentType("application/json").body(body).put(path).then();
}
}

View File

@@ -1,7 +1,10 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.emptyOrNullString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.not;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
@@ -10,8 +13,7 @@ import org.junit.jupiter.api.Test;
class SchoolSnapshotResourceTest {
private static final String BASE = "/api/dance-schools";
@Test
void downloadPreviewRestoreAndRejectInvalidFiles() {
public @Test void downloadPreviewRestoreAndRejectInvalidFiles() {
long id = given().contentType("application/json")
.body("{\"name\":\"JSON school\"}")
.post(BASE)
@@ -20,7 +22,7 @@ class SchoolSnapshotResourceTest {
.extract()
.jsonPath()
.getLong("id");
String json = given().get(BASE + "/" + id + "/export")
final String json = given().get(BASE + "/" + id + "/export")
.then()
.statusCode(200)
.header("Content-Disposition", containsString(".json"))
@@ -35,21 +37,7 @@ class SchoolSnapshotResourceTest {
.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()));
}
rejectInvalidFiles(id, json);
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
given().delete(BASE + "/" + id).then().statusCode(204);
id = given().contentType("application/json")
@@ -65,4 +53,22 @@ class SchoolSnapshotResourceTest {
given().delete(BASE + "/" + id);
}
}
private static void rejectInvalidFiles(final long id, final String json) {
for (final 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()));
}
}
}

View File

@@ -1,12 +1,30 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import java.util.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.model.*;
import java.util.List;
import java.util.UUID;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowConfiguration;
import nl.agilecc.agiledancers.service.model.Group;
import nl.agilecc.agiledancers.service.model.Part;
import nl.agilecc.agiledancers.service.model.Performance;
import nl.agilecc.agiledancers.service.model.Person;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
import nl.agilecc.agiledancers.service.model.Trainer;
import org.junit.jupiter.api.Test;
class SchoolSnapshotTest {
@@ -35,26 +53,27 @@ class SchoolSnapshotTest {
ShowConfiguration.defaults(1))));
}
@Test
void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
var snapshot = complete();
var service = new SchoolManagementService();
var existing = service.createSchool("Other");
public @Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
final SchoolSnapshot snapshot = complete();
final var service = new SchoolManagementService();
final DanceSchool existing = service.createSchool("Other");
service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000, 1, 1));
var imported = service.importSchool(snapshot, null);
final DanceSchool imported = service.importSchool(snapshot, null);
assertEquals(2, imported.getDanceGroups().size());
var dancer = imported.getDancers().iterator().next();
final Dancer dancer = imported.getDancers().iterator().next();
imported.getDanceGroups()
.forEach(g -> assertSame(dancer, g.getDancers().iterator().next()));
.forEach((final DanceGroup 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);
final var restarted = new SchoolManagementService();
final DanceSchool restored = restarted.importSchool(service.exportSchool(imported.getId()), null);
assertEquals(SchoolSnapshot.of(snapshot.restore()), restarted.exportSchool(restored.getId()));
// Membership removal must work after remapping hash-based sets.
service.deleteDancer(imported.getId(), dancer.getId());
imported.getDanceGroups().forEach(g -> assertTrue(g.getDancers().isEmpty()));
imported.getDanceGroups()
.forEach((final DanceGroup g) -> assertTrue(g.getDancers().isEmpty()));
assertEquals(1, existing.getDancers().size());
var group = imported.getDanceGroups().iterator().next();
final DanceGroup group = imported.getDanceGroups().iterator().next();
service.deleteGroup(imported.getId(), group.getId());
assertEquals(
1,
@@ -68,19 +87,18 @@ class SchoolSnapshotTest {
assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore());
}
@Test
void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
var service = new SchoolManagementService();
var snapshot = complete();
public @Test void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
final var service = new SchoolManagementService();
final SchoolSnapshot snapshot = complete();
assertNull(service.preview(snapshot).existingId());
assertTrue(service.schools().isEmpty());
var original = service.importSchool(snapshot, null);
final DanceSchool original = service.importSchool(snapshot, null);
service.createDancer(original.getId(), "Extra", LocalDate.of(2000, 1, 1));
var other = service.createSchool(snapshot.name());
final DanceSchool other = service.createSchool(snapshot.name());
assertEquals(original.getId(), service.preview(snapshot).existingId());
assertThrows(ManagementException.class, () -> service.importSchool(snapshot, null));
assertEquals(2, original.getDancers().size());
var replaced = service.importSchool(snapshot, original.getId());
final DanceSchool replaced = service.importSchool(snapshot, original.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(replaced.getId()));
assertSame(other, service.school(other.getId()));
service.deleteSchool(replaced.getId());
@@ -89,23 +107,21 @@ class SchoolSnapshotTest {
service.exportSchool(service.importSchool(snapshot, null).getId()));
}
@Test
void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var service = new SchoolManagementService();
var valid = complete();
var original = service.importSchool(valid, null);
var bad = new SchoolSnapshot(
public @Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
final var service = new SchoolManagementService();
final SchoolSnapshot valid = complete();
final DanceSchool original = service.importSchool(valid, null);
final 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() {
var service = new SchoolManagementService();
var empty = service.createSchool("Empty");
var snapshot = service.exportSchool(empty.getId());
public @Test void emptySchoolRoundTrips() {
final var service = new SchoolManagementService();
final DanceSchool empty = service.createSchool("Empty");
final SchoolSnapshot snapshot = service.exportSchool(empty.getId());
service.deleteSchool(empty.getId());
assertEquals(
SchoolSnapshot.of(snapshot.restore()),

View File

@@ -0,0 +1,103 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.service.DanceGroupService;
import nl.agilecc.agiledancers.service.model.Group;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.Person;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
import org.junit.jupiter.api.Test;
class SnapshotCollectionsTest {
@Test
public void snapshotCopiesCallerOwnedCollectionsAndRetainsInvalidInputForValidation() {
final List<Person> dancers = new ArrayList<>();
final SchoolSnapshot snapshot =
new SchoolSnapshot(2, UUID.randomUUID(), "School", dancers, List.of(), List.of(), List.of());
dancers.add(new Person("1", "Dancer", null));
assertEquals(List.of(), snapshot.dancers());
assertThrows(
UnsupportedOperationException.class, () -> snapshot.dancers().add(null));
final SchoolSnapshot invalid =
new SchoolSnapshot(2, UUID.randomUUID(), "School", null, List.of(), List.of(), List.of());
assertNull(invalid.dancers());
}
@Test
public void schedulingResultCopiesBothCollectionLevelsAndPreservesMalformedSolverResults() {
final List<Long> part = new ArrayList<>(List.of(1L));
final List<List<Long>> assignments = new ArrayList<>(List.of(part));
final SchedulingResult result = new SchedulingResult(true, assignments);
part.clear();
assignments.clear();
assertEquals(List.of(List.of(1L)), result.assignments());
assertThrows(
UnsupportedOperationException.class,
() -> result.assignments().getFirst().clear());
assertNull(new SchedulingResult(false, null).assignments());
final List<List<Long>> invalid = new ArrayList<>();
invalid.add(null);
assertNull(new SchedulingResult(false, invalid).assignments().getFirst());
}
@Test
public void requestCollectionsRetainTheirJsonValuesAndCannotBeMutatedThroughAliases() {
final Map<DanceGroupType, Integer> counts = new LinkedHashMap<>(Map.of(DanceGroupType.REGULAR, 1));
final ShowRequest show = new ShowRequest("Show", List.of(), counts);
counts.clear();
assertEquals(1, show.appearances().get(DanceGroupType.REGULAR));
assertThrows(
UnsupportedOperationException.class, () -> show.appearances().clear());
final var styles = new LinkedHashSet<>(List.of(DanceGroupStyle.JAZZ));
final GroupRequest group = new GroupRequest("Group", DanceGroupType.REGULAR, 0, 99, styles);
styles.clear();
assertEquals(1, group.styles().size());
assertThrows(UnsupportedOperationException.class, () -> group.styles().clear());
}
@Test
public void groupCopiesBothListsWithoutChangingNullableSnapshotValues() {
final List<DanceGroupStyle> styles = new ArrayList<>(List.of(DanceGroupStyle.JAZZ));
final List<String> dancerIds = new ArrayList<>(List.of("dancer-1"));
final Group group = new Group("group-1", "Group", DanceGroupType.REGULAR, 0, 99, styles, dancerIds, null);
styles.clear();
dancerIds.clear();
assertEquals(List.of(DanceGroupStyle.JAZZ), group.styles());
assertEquals(List.of("dancer-1"), group.dancerIds());
assertThrows(UnsupportedOperationException.class, () -> group.styles().clear());
assertThrows(
UnsupportedOperationException.class, () -> group.dancerIds().clear());
final Group invalid = new Group("group-2", "Group", DanceGroupType.REGULAR, 0, 99, null, null, null);
assertNull(invalid.styles());
assertNull(invalid.dancerIds());
}
@Test
public void groupDetailsCopiesTheStylesPassedAcrossTheServiceBoundary() {
final var styles = new LinkedHashSet<>(List.of(DanceGroupStyle.JAZZ));
final GroupDetails details = new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, styles);
styles.clear();
assertEquals(1, details.styles().size());
assertThrows(UnsupportedOperationException.class, () -> details.styles().clear());
final DanceGroup group = new DanceGroupService().add(details, new LinkedHashSet<>(), null);
group.addStyle(DanceGroupStyle.HIPHOP);
assertEquals(2, group.getStyles().size());
assertEquals(1, details.styles().size());
assertNull(new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, null).styles());
}
}

View File

@@ -1,23 +1,36 @@
package nl.agilecc.agiledancers.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingServiceTest {
@Test
void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
var show = schools.createShow(
public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
final var schools = new SchoolManagementService();
final long school = schools.createSchool("School").getId();
schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()));
final Show show = schools.createShow(
school,
"Show",
List.of(DayPart.builder()
@@ -26,34 +39,31 @@ class SchedulingServiceTest {
.danceGroups(List.of())
.build()),
null);
var scheduling = new SchedulingService();
scheduling.schools = schools;
scheduling.engine = new OrToolsSchedulingEngine();
final var scheduling = new SchedulingService(schools, new OrToolsSchedulingEngine());
scheduling.schedule(school, show.getId());
var before = schools.exportSchool(school);
for (SchedulingEngine failure : List.<SchedulingEngine>of(
input -> {
final SchoolSnapshot before = schools.exportSchool(school);
for (final SchedulingEngine failure : List.<SchedulingEngine>of(
(final SchedulingInput input) -> {
throw new IllegalStateException("Native failure");
},
input -> {
(final SchedulingInput 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()));
(final SchedulingInput input) -> null)) {
final var failedScheduling = new SchedulingService(schools, failure);
assertThrows(ManagementException.class, () -> failedScheduling.schedule(school, show.getId()));
assertEquals(before, schools.exportSchool(school));
var next = schools.beginScheduling(school, show.getId());
final SchedulingInput next = schools.beginScheduling(school, show.getId());
schools.endScheduling(next.showId());
}
}
@Test
void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
var show = schools.createShow(
public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
final var schools = new SchoolManagementService();
final long school = schools.createSchool("School").getId();
schools.createGroup(school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()));
final Show show = schools.createShow(
school,
"Show",
List.of(DayPart.builder()
@@ -62,39 +72,45 @@ class SchedulingServiceTest {
.danceGroups(List.of())
.build()),
null);
var scheduling = new SchedulingService();
scheduling.schools = schools;
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
scheduling.engine = input -> {
entered.countDown();
try {
if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
return new OrToolsSchedulingEngine().solve(input);
};
try (var executor = Executors.newSingleThreadExecutor()) {
var first = executor.submit(() -> scheduling.schedule(school, show.getId()));
final var entered = new CountDownLatch(1);
final var release = new CountDownLatch(1);
final SchedulingEngine engine = blockingEngine(entered, release);
final var scheduling = new SchedulingService(schools, engine);
try (final java.util.concurrent.ExecutorService executor = Executors.newSingleThreadExecutor()) {
final Future<SchedulingResult> 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());
schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of());
schools.createGroup(school, new GroupDetails("New group", DanceGroupType.REGULAR, 0, 99, Set.of()));
} finally {
release.countDown();
}
var exception = assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS));
final ExecutionException exception =
assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS));
assertInstanceOf(ManagementException.class, exception.getCause());
assertTrue(exception.getCause().getMessage().contains("inputs changed"));
assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty());
}
scheduling.engine = new OrToolsSchedulingEngine();
scheduling.schedule(school, show.getId());
new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size());
}
private static SchedulingEngine blockingEngine(final CountDownLatch entered, final CountDownLatch release) {
final SchedulingEngine engine = (final SchedulingInput input) -> {
entered.countDown();
try {
if (!release.await(10, TimeUnit.SECONDS)) {
throw new IllegalStateException("Test timeout");
}
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
return new OrToolsSchedulingEngine().solve(input);
};
return engine;
}
}

View File

@@ -6,19 +6,18 @@ import nl.agilecc.agiledancers.service.DanceSchoolService;
import org.junit.jupiter.api.Test;
class DanceSchoolSit {
Faker faker = new Faker();
int numberOfDanceGroups = 42;
private final Faker faker = new Faker();
private final int numberOfDanceGroups = 42;
int aantalDansersTm5 = 10;
int aantalDansers6tm10 = 30;
int aantalDansers11tot14 = 70;
int aantalDansers14tm17 = 140;
int aantalDansers18Plus = 100;
private final int aantalDansersTm5 = 10;
private final int aantalDansers6tm10 = 30;
private final int aantalDansers11tot14 = 70;
private final int aantalDansers14tm17 = 140;
private final int aantalDansers18Plus = 100;
private DanceSchoolService danceSchoolService = new DanceSchoolService();
private final DanceSchoolService danceSchoolService = new DanceSchoolService();
@Test
void testDanceSchoolModel() {
DanceSchool danceSchool = danceSchoolService.add("24 Dance Studio");
public @Test void testDanceSchoolModel() {
final DanceSchool danceSchool = danceSchoolService.add("24 Dance Studio");
}
}