S8-palentir #7

Merged
wanjakrah merged 4 commits from S8-palentir into main 2026-10-30 16:58:50 +01:00
66 changed files with 2431 additions and 727 deletions

View File

@@ -253,3 +253,58 @@ Only successful `main` runs publish images to the Synology registry. See
[the CI management guide](docs/gitea-ci.md) for runner setup, registry secrets, [the CI management guide](docs/gitea-ci.md) for runner setup, registry secrets,
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md) image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
for the current operational status. for the current operational status.
### Java formatting (S8)
Maven is the authoritative formatting path. The root build runs the actual
Palantir Java Format **2.101.0**, Palantir style, through a custom OpenRewrite
**8.63.4** text recipe in `tooling/format/Format.java`. The pinned
`exec-maven-plugin` **3.6.3** launches that helper with JDK 25. Native import
ordering and unused-import removal are enabled; extra Javadoc formatting is off.
No OpenRewrite AutoFormat or additional refactoring recipes run.
From the repository root:
```sh
mvn -N exec:exec@format # apply, without application compilation
mvn -N exec:exec@format -Dformat.mode=check # check, without application compilation
mvn compile # applies during validate, before compilation
mvn -Dformat.mode=check clean verify # CI: fails before compilation on differences
```
Root `compile`, `test`, `verify` and `install` may rewrite maintained Java files
in the validate phase. CI's Java orchestration passes `-Dformat.mode=check` and
never repairs source files. On failure, the output lists paths and the apply
command. Apply locally, inspect and commit the result, then repeat the check.
Run root Maven commands; selecting only the backend with `-pl agiledancers-be`
bypasses the root formatting execution.
The scope is every `.java` file recursively under `agiledancers-be/src/main/java`,
`agiledancers-be/src/test/java`, `ci` (including the runner helper), and the
formatter helper itself. Generated `target` files and frontend files are excluded.
The adapter keeps paths and UTF-8 content, computes changes before writing, and
uses atomic per-file replacements. Unsupported syntax fails with its path/cause.
The source launcher compiles the adapter directly in memory from a clean checkout;
no custom recipe jar needs compiling or installing beforehand. Maven downloads
only pinned external tooling dependencies on first use.
Successfully checked/formatted content hashes are cached in
`target/palantir-cache.properties`; new or changed files are always processed.
The root POM, adapter source, dependency classpath and JDK version invalidate the cache. `clean` removes
it. Check and apply share the engine, options, scope and successful-content cache;
a failing check does not write sources or update the cache. For controlled timing
comparisons only, `-Dformat.skip=true` disables the execution.
Editors are optional. Palantir supplies an IntelliJ plugin (see the
[upstream instructions](https://github.com/palantir/palantir-java-format#intellij-plugin)).
Use it only with the same engine version/style and Javadoc setting; if the editor
cannot match them, use Maven to format instead. Editor import optimization or
format-on-save must not override Maven's result. Other editors can invoke the
standalone Maven apply command as an external tool.
Verification and measurements are recorded in
[the S8 evidence](specs/S8-formatting-evidence.md). Reproduce the isolated failure,
UTF-8, import, cache and idempotence checks with
`python3 tooling/format/test.py`, and the sequential end-to-end build measurements
with `python3 tooling/format/benchmark.py` (requires the ordinary backend test
prerequisites). Results are written under `target/`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,15 +5,13 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*; import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService; import java.util.List;
import nl.agilecc.agiledancers.service.SchedulingService;
import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView; import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest; import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView; import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.service.SchedulingService;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@Path("/api/dance-schools/{schoolId}/shows") @Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@@ -42,8 +40,11 @@ public class ShowResource {
@POST @POST
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) { public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
var entity = service.createShow(schoolId, request.name(), parts(request), request.appearances()); var entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build()) return Response.created(uriInfo.getAbsolutePathBuilder()
.entity(ShowView.of(entity)).build(); .path(entity.getId().toString())
.build())
.entity(ShowView.of(entity))
.build();
} }
@PUT @PUT
@@ -53,8 +54,13 @@ public class ShowResource {
} }
private static List<DayPart> parts(ShowRequest request) { private static List<DayPart> parts(ShowRequest request) {
return request.dayParts().stream().map(p -> DayPart.builder() return request.dayParts().stream()
.date(p.date()).type(p.type()).danceGroups(List.of()).build()).toList(); .map(p -> DayPart.builder()
.date(p.date())
.type(p.type())
.danceGroups(List.of())
.build())
.toList();
} }
@POST @POST
@@ -62,8 +68,8 @@ public class ShowResource {
@Consumes(MediaType.WILDCARD) @Consumes(MediaType.WILDCARD)
public SchedulingView schedule(@PathParam("id") long id) { public SchedulingView schedule(@PathParam("id") long id) {
var result = scheduling.schedule(schoolId, id); var result = scheduling.schedule(schoolId, id);
return new SchedulingView(result.optimal(), result.optimal() return new SchedulingView(
? "Optimal balance" : "Valid result; optimal balance not proven"); result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven");
} }
@DELETE @DELETE

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,11 @@
package nl.agilecc.agiledancers.rest.apimodel; package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.util.*; import java.util.*;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType; import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowRequest(@NotBlank String name, @NotNull List<@NotNull @Valid DayPartRequest> dayParts,
Map<DanceGroupType, Integer> appearances) {} public record ShowRequest(
@NotBlank String name,
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
Map<DanceGroupType, Integer> appearances) {}

View File

@@ -1,10 +1,22 @@
package nl.agilecc.agiledancers.rest.apimodel; package nl.agilecc.agiledancers.rest.apimodel;
import java.util.*;
import nl.agilecc.agiledancers.model.*; import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType; import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import java.util.*;
public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) { public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) {
public static ShowView of(Show show) { public static ShowView of(Show show) {
return new ShowView(show.getId(), show.getName(), show.getDayParts().stream() return new ShowView(
.map(p -> new DayPartView(p.getDate(), p.getType(), p.getDanceGroups().stream().map(DanceGroup::getId).toList())).toList(), show.getAppearances()); show.getId(),
show.getName(),
show.getDayParts().stream()
.map(p -> new DayPartView(
p.getDate(),
p.getType(),
p.getDanceGroups().stream()
.map(DanceGroup::getId)
.toList()))
.toList(),
show.getAppearances());
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,32 @@
package nl.agilecc.agiledancers.service; package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton @Singleton
public class SchedulingService { public class SchedulingService {
@Inject SchoolManagementService schools; @Inject
@Inject SchedulingEngine engine; SchoolManagementService schools;
@Inject
SchedulingEngine engine;
public SchedulingResult schedule(long schoolId, long showId) { public SchedulingResult schedule(long schoolId, long showId) {
var input = schools.beginScheduling(schoolId, showId); var input = schools.beginScheduling(schoolId, showId);
try { try {
var result = engine.solve(input); var result = engine.solve(input);
schools.finishScheduling(schoolId, input, result); schools.finishScheduling(schoolId, input, result);
return result; return result;
} catch (ManagementException e) { throw e; } } catch (ManagementException e) {
catch (RuntimeException | LinkageError e) { throw e;
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Solver failed. Existing assignments were preserved. Please run scheduling again."); } catch (RuntimeException | LinkageError e) {
} finally { schools.endScheduling(showId); } throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"Solver failed. Existing assignments were preserved. Please run scheduling again.");
} finally {
schools.endScheduling(showId);
}
} }
} }

View File

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

View File

@@ -1,33 +1,51 @@
package nl.agilecc.agiledancers.service; package nl.agilecc.agiledancers.service;
import java.util.*;
import nl.agilecc.agiledancers.model.DayPart; import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType; import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import java.util.*;
public final class ShowConfiguration { public final class ShowConfiguration {
private ShowConfiguration() {} private ShowConfiguration() {}
public static Map<DanceGroupType, Integer> defaults(int n) { public static Map<DanceGroupType, Integer> defaults(int n) {
Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class); Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
if (n > 0) for (var type : DanceGroupType.values()) counts.put(type, switch (type) { if (n > 0)
case PRE_SCHOOLER -> 1; for (var type : DanceGroupType.values())
case SHOW_CASE, PRE_EDUCATION -> n; counts.put(
default -> Math.min(2, n); type,
}); switch (type) {
case PRE_SCHOOLER -> 1;
case SHOW_CASE, PRE_EDUCATION -> n;
default -> Math.min(2, n);
});
return counts; return counts;
} }
public static void validate(List<DayPart> parts, Map<DanceGroupType, Integer> counts) { public static void validate(List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
if (parts == null || parts.size() > 6 || parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null) if (parts == null
|| parts.stream().map(p -> p.getDate() + ":" + p.getType()).distinct().count() != parts.size()) || parts.size() > 6
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Configure zero to six dayparts with unique date/type combinations."); || parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null)
|| parts.stream()
.map(p -> p.getDate() + ":" + p.getType())
.distinct()
.count()
!= parts.size())
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"Configure zero to six dayparts with unique date/type combinations.");
if (counts == null) invalidCounts(); if (counts == null) invalidCounts();
if (parts.isEmpty() && counts.isEmpty()) return; if (parts.isEmpty() && counts.isEmpty()) return;
if (counts.size() != DanceGroupType.values().length if (counts.size() != DanceGroupType.values().length
|| Arrays.stream(DanceGroupType.values()).anyMatch(t -> counts.get(t) == null || counts.get(t) < 1 || Arrays.stream(DanceGroupType.values())
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts(); .anyMatch(t -> counts.get(t) == null
|| counts.get(t) < 1
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts();
} }
private static void invalidCounts() { private static void invalidCounts() {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, throw new ManagementException(
"Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are retained when dayparts are removed."); ManagementExceptionKind.INVALID_INPUT,
"Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are"
+ " retained when dayparts are removed.");
} }
} }

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
package nl.agilecc.agiledancers.solver; package nl.agilecc.agiledancers.solver;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import com.google.ortools.Loader; import com.google.ortools.Loader;
import com.google.ortools.sat.*; import com.google.ortools.sat.*;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.service.*;
import java.util.*; import java.util.*;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton @Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine { public class OrToolsSchedulingEngine implements SchedulingEngine {
@Override @Override
@@ -30,7 +31,7 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
IntVar max = model.newIntVar(0, groups, "max"), min = model.newIntVar(0, groups, "min"); IntVar max = model.newIntVar(0, groups, "max"), min = model.newIntVar(0, groups, "min");
model.addMaxEquality(max, loads); model.addMaxEquality(max, loads);
model.addMinEquality(min, loads); model.addMinEquality(min, loads);
model.minimize(LinearExpr.weightedSum(new IntVar[]{max, min}, new long[]{1, -1})); model.minimize(LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1}));
CpSolver solver = new CpSolver(); CpSolver solver = new CpSolver();
solver.getParameters().setMaxTimeInSeconds(1800); solver.getParameters().setMaxTimeInSeconds(1800);
// Quarkus owns process shutdown; do not install a native SIGINT handler. // Quarkus owns process shutdown; do not install a native SIGINT handler.
@@ -39,15 +40,21 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0); solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
CpSolverStatus status = solver.solve(model); CpSolverStatus status = solver.solve(model);
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE)
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, switch (status) { throw new ManagementException(
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved."; ManagementExceptionKind.INVALID_INPUT,
case UNKNOWN -> "The 30-minute limit was reached without a valid result. Existing assignments were preserved."; switch (status) {
default -> "Solver failed (" + status + "). Existing assignments were preserved."; case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
}); case UNKNOWN ->
"The 30-minute limit was reached without a valid result. Existing assignments were"
+ " preserved.";
default -> "Solver failed (" + status + "). Existing assignments were preserved.";
});
List<List<Long>> parts = new ArrayList<>(); List<List<Long>> parts = new ArrayList<>();
for (int p = 0; p < n; p++) { for (int p = 0; p < n; p++) {
List<Long> ids = new ArrayList<>(); List<Long> ids = new ArrayList<>();
for (int g = 0; g < groups; g++) if (solver.booleanValue(assigned[g][p])) ids.add(input.groups().get(g).groupId()); for (int g = 0; g < groups; g++)
if (solver.booleanValue(assigned[g][p]))
ids.add(input.groups().get(g).groupId());
parts.add(List.copyOf(ids)); parts.add(List.copyOf(ids));
} }
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts)); return new SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));

View File

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

View File

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

View File

@@ -1,54 +1,80 @@
package nl.agilecc.agiledancers; package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; import static org.junit.jupiter.api.Assertions.*;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.*; import java.util.*;
import static org.junit.jupiter.api.Assertions.*; import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingTest { class SchedulingTest {
private final SchoolManagementService service = new SchoolManagementService(); private final SchoolManagementService service = new SchoolManagementService();
private final long school = service.createSchool("Studio").getId(); private final long school = service.createSchool("Studio").getId();
private List<DayPart> parts(int n) { private List<DayPart> parts(int n) {
return java.util.stream.IntStream.range(0, n).mapToObj(i -> DayPart.builder() return java.util.stream.IntStream.range(0, n)
.date(LocalDate.of(2027, 1, 1).plusDays(i)).type(DayPartType.AFTERNOON).danceGroups(List.of()).build()).toList(); .mapToObj(i -> DayPart.builder()
.date(LocalDate.of(2027, 1, 1).plusDays(i))
.type(DayPartType.AFTERNOON)
.danceGroups(List.of())
.build())
.toList();
} }
private DanceGroup group(DanceGroupType type) { private DanceGroup group(DanceGroupType type) {
return service.createGroup(school, "Group", type, 0, 99, Set.of()); return service.createGroup(school, "Group", type, 0, 99, Set.of());
} }
private void run(Show show) { private void run(Show show) {
var input = service.beginScheduling(school, show.getId()); var input = service.beginScheduling(school, show.getId());
try { service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input)); } try {
finally { service.endScheduling(show.getId()); } service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input));
} finally {
service.endScheduling(show.getId());
}
} }
@Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
@Test
void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
group(DanceGroupType.PRE_SCHOOLER); group(DanceGroupType.PRE_SCHOOLER);
var show = service.createShow(school, "Show", parts(6), null); var show = service.createShow(school, "Show", parts(6), null);
run(show); run(show);
var loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics(); var loads = show.getDayParts().stream()
assertEquals(1, loads.getSum()); assertEquals(0, loads.getMin()); assertEquals(1, loads.getMax()); .mapToInt(p -> p.getDanceGroups().size())
.summaryStatistics();
assertEquals(1, loads.getSum());
assertEquals(0, loads.getMin());
assertEquals(1, loads.getMax());
for (var type : DanceGroupType.values()) group(type); for (var type : DanceGroupType.values()) group(type);
run(show); run(show);
for (var group : service.groups(school)) assertEquals(show.getAppearances().get(group.getType()).longValue(), for (var group : service.groups(school))
show.getDayParts().stream().filter(p -> p.getDanceGroups().contains(group)).count()); assertEquals(
loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics(); show.getAppearances().get(group.getType()).longValue(),
show.getDayParts().stream()
.filter(p -> p.getDanceGroups().contains(group))
.count());
loads = show.getDayParts().stream()
.mapToInt(p -> p.getDanceGroups().size())
.summaryStatistics();
assertTrue(loads.getMax() - loads.getMin() <= 1); assertTrue(loads.getMax() - loads.getMin() <= 1);
var snapshot = service.exportSchool(school); var snapshot = service.exportSchool(school);
assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore())); assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore()));
} }
@Test void defaultsInitialConfigurationAndPreservationOnEdits() {
@Test
void defaultsInitialConfigurationAndPreservationOnEdits() {
var show = service.createShow(school, "Show", List.of(), null); var show = service.createShow(school, "Show", List.of(), null);
assertTrue(show.getAppearances().isEmpty()); assertTrue(show.getAppearances().isEmpty());
service.updateShow(school, show.getId(), "Show", parts(3), null); service.updateShow(school, show.getId(), "Show", parts(3), null);
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION)); assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER)); assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
var counts = new EnumMap<>(show.getAppearances()); counts.put(DanceGroupType.PRE_SCHOOLER, 2); var counts = new EnumMap<>(show.getAppearances());
counts.put(DanceGroupType.PRE_SCHOOLER, 2);
service.updateShow(school, show.getId(), "Show", parts(4), counts); service.updateShow(school, show.getId(), "Show", parts(4), counts);
assertEquals(counts, show.getAppearances()); assertEquals(counts, show.getAppearances());
assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null)); assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null));
@@ -58,19 +84,25 @@ class SchedulingTest {
service.updateShow(school, show.getId(), "Show", parts(4), null); service.updateShow(school, show.getId(), "Show", parts(4), null);
assertEquals(counts, show.getAppearances()); assertEquals(counts, show.getAppearances());
} }
@Test void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
@Test
void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
var duplicate = List.of(parts(1).getFirst(), parts(1).getFirst()); var duplicate = List.of(parts(1).getFirst(), parts(1).getFirst());
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", duplicate, null)); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", duplicate, null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null)); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of())); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of()));
var counts = ShowConfiguration.defaults(2); counts.put(DanceGroupType.REGULAR, 3); var counts = ShowConfiguration.defaults(2);
counts.put(DanceGroupType.REGULAR, 3);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts)); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
counts.put(DanceGroupType.REGULAR, 0); counts.put(DanceGroupType.REGULAR, 0);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts)); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
} }
@Test void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
@Test
void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
var group = group(DanceGroupType.REGULAR); var group = group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null); run(show); var show = service.createShow(school, "Show", parts(2), null);
run(show);
var before = show.getDayParts().stream().map(DayPart::getDanceGroups).toList(); var before = show.getDayParts().stream().map(DayPart::getDanceGroups).toList();
var input = service.beginScheduling(school, show.getId()); var input = service.beginScheduling(school, show.getId());
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId())); assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
@@ -79,18 +111,25 @@ class SchedulingTest {
assertDoesNotThrow(() -> service.finishScheduling(school, input, result)); assertDoesNotThrow(() -> service.finishScheduling(school, input, result));
group(DanceGroupType.PRE_SCHOOLER); group(DanceGroupType.PRE_SCHOOLER);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result)); assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList()); assertEquals(
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
service.endScheduling(show.getId()); service.endScheduling(show.getId());
var fresh = service.beginScheduling(school, show.getId()); var fresh = service.beginScheduling(school, show.getId());
assertThrows(ManagementException.class, () -> service.finishScheduling(school, fresh, assertThrows(
new SchedulingResult(false, List.of(List.of(), List.of())))); ManagementException.class,
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList()); () -> service.finishScheduling(
school, fresh, new SchedulingResult(false, List.of(List.of(), List.of()))));
assertEquals(
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
// Feasible results are accepted without requiring proof of optimality. // Feasible results are accepted without requiring proof of optimality.
var valid = new OrToolsSchedulingEngine().solve(fresh); var valid = new OrToolsSchedulingEngine().solve(fresh);
assertDoesNotThrow(() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments()))); assertDoesNotThrow(
() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments())));
service.endScheduling(show.getId()); service.endScheduling(show.getId());
} }
@Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {
@Test
void staleDaypartCountChangesAndDeletedShowsAreRejected() {
group(DanceGroupType.REGULAR); group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null); var show = service.createShow(school, "Show", parts(2), null);
var input = service.beginScheduling(school, show.getId()); var input = service.beginScheduling(school, show.getId());
@@ -101,7 +140,9 @@ class SchedulingTest {
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result)); assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.endScheduling(show.getId()); service.endScheduling(show.getId());
} }
@Test void needsGroupsAndDayparts() {
@Test
void needsGroupsAndDayparts() {
var show = service.createShow(school, "Show", parts(1), null); var show = service.createShow(school, "Show", parts(1), null);
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId())); assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
group(DanceGroupType.REGULAR); group(DanceGroupType.REGULAR);

View File

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

View File

@@ -1,32 +1,68 @@
package nl.agilecc.agiledancers; package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
@QuarkusTest @QuarkusTest
class SchoolSnapshotResourceTest { class SchoolSnapshotResourceTest {
private static final String BASE = "/api/dance-schools"; private static final String BASE = "/api/dance-schools";
@Test void downloadPreviewRestoreAndRejectInvalidFiles() {
long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE) @Test
.then().statusCode(201).extract().jsonPath().getLong("id"); void downloadPreviewRestoreAndRejectInvalidFiles() {
String json = given().get(BASE + "/" + id + "/export").then().statusCode(200) long id = given().contentType("application/json")
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(2)).extract().asString(); .body("{\"name\":\"JSON school\"}")
.post(BASE)
.then()
.statusCode(201)
.extract()
.jsonPath()
.getLong("id");
String json = given().get(BASE + "/" + id + "/export")
.then()
.statusCode(200)
.header("Content-Disposition", containsString(".json"))
.body("formatVersion", equalTo(2))
.extract()
.asString();
try { try {
given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200) given().contentType("application/json")
.body("existingId", equalTo((int) id)).body("dancers", equalTo(0)); .body(json)
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"), .post(BASE + "/import/preview")
json.replace("\"name\" :", "\"unknown\" :"), json + " {}", .then()
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) { .statusCode(200)
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id) .body("existingId", equalTo((int) id))
.then().statusCode(400).body("message", not(emptyOrNullString())); .body("dancers", equalTo(0));
for (String bad : new String[] {
"{",
"null",
json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
json.replace("\"name\" :", "\"unknown\" :"),
json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")
}) {
given().contentType("application/json")
.body(bad)
.post(BASE + "/import?replaceId=" + id)
.then()
.statusCode(400)
.body("message", not(emptyOrNullString()));
} }
given().get(BASE + "/" + id + "/export").then().body(equalTo(json)); given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
given().delete(BASE + "/" + id).then().statusCode(204); given().delete(BASE + "/" + id).then().statusCode(204);
id = given().contentType("application/json").body(json).post(BASE + "/import") id = given().contentType("application/json")
.then().statusCode(200).extract().jsonPath().getLong("id"); .body(json)
.post(BASE + "/import")
.then()
.statusCode(200)
.extract()
.jsonPath()
.getLong("id");
given().get(BASE + "/" + id + "/export").then().body(equalTo(json)); given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
} finally { given().delete(BASE + "/" + id); } } finally {
given().delete(BASE + "/" + id);
}
} }
} }

View File

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

View File

@@ -1,30 +1,44 @@
package nl.agilecc.agiledancers.service; package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.service.port.SchedulingEngine; import static org.junit.jupiter.api.Assertions.*;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.*; import java.util.*;
import java.util.concurrent.*; import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*; import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingServiceTest { class SchedulingServiceTest {
@Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() { @Test
void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
var schools = new SchoolManagementService(); var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId(); long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of()); schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1)) var show = schools.createShow(
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null); school,
"Show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build()),
null);
var scheduling = new SchedulingService(); var scheduling = new SchedulingService();
scheduling.schools = schools; scheduling.schools = schools;
scheduling.engine = new OrToolsSchedulingEngine(); scheduling.engine = new OrToolsSchedulingEngine();
scheduling.schedule(school, show.getId()); scheduling.schedule(school, show.getId());
var before = schools.exportSchool(school); var before = schools.exportSchool(school);
for (SchedulingEngine failure : List.<SchedulingEngine>of( for (SchedulingEngine failure : List.<SchedulingEngine>of(
input -> { throw new IllegalStateException("Native failure"); }, input -> {
input -> { throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit"); }, throw new IllegalStateException("Native failure");
},
input -> {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit");
},
input -> null)) { input -> null)) {
scheduling.engine = failure; scheduling.engine = failure;
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId())); assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()));
@@ -34,28 +48,46 @@ class SchedulingServiceTest {
} }
} }
@Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception { @Test
void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
var schools = new SchoolManagementService(); var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId(); long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of()); schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1)) var show = schools.createShow(
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null); school,
var scheduling = new SchedulingService(); scheduling.schools = schools; "Show",
var entered = new CountDownLatch(1); var release = new CountDownLatch(1); List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build()),
null);
var scheduling = new SchedulingService();
scheduling.schools = schools;
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
scheduling.engine = input -> { scheduling.engine = input -> {
entered.countDown(); entered.countDown();
try { if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout"); } try {
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } 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); return new OrToolsSchedulingEngine().solve(input);
}; };
try (var executor = Executors.newSingleThreadExecutor()) { try (var executor = Executors.newSingleThreadExecutor()) {
var first = executor.submit(() -> scheduling.schedule(school, show.getId())); var first = executor.submit(() -> scheduling.schedule(school, show.getId()));
try { try {
assertTrue(entered.await(10, TimeUnit.SECONDS)); assertTrue(entered.await(10, TimeUnit.SECONDS));
assertEquals(ManagementExceptionKind.CONFLICT, assertThrows(ManagementException.class, assertEquals(
() -> scheduling.schedule(school, show.getId())).getKind()); 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 group", DanceGroupType.REGULAR, 0, 99, Set.of());
} finally { release.countDown(); } } finally {
release.countDown();
}
var exception = assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS)); var exception = assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS));
assertInstanceOf(ManagementException.class, exception.getCause()); assertInstanceOf(ManagementException.class, exception.getCause());
assertTrue(exception.getCause().getMessage().contains("inputs changed")); assertTrue(exception.getCause().getMessage().contains("inputs changed"));

View File

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

View File

@@ -24,16 +24,18 @@ public class Ci {
case "attempt" -> { case "attempt" -> {
Path file = Path.of(Setup.required(env, "GITHUB_ENV")); Path file = Path.of(Setup.required(env, "GITHUB_ENV"));
String id = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSSSSSSSS") String id = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSSSSSSSS")
.withZone(ZoneOffset.UTC).format(Instant.now()); .withZone(ZoneOffset.UTC)
Files.writeString(file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND); .format(Instant.now());
Files.writeString(
file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} }
case "configure-gitea" -> Setup.configureGitea(env); case "configure-gitea" -> Setup.configureGitea(env);
case "configure-docker" -> { case "configure-docker" -> {
if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path"); if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path");
Setup.configureDocker(Path.of(args[1])); Setup.configureDocker(Path.of(args[1]));
} }
case "install-runner" -> Setup.installRunner(root, env, commands, case "install-runner" ->
args.length == 2 && args[1].equals("--prepare-only")); Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
case "test" -> CiTests.run(); case "test" -> CiTests.run();
default -> throw new IllegalArgumentException(usage()); default -> throw new IllegalArgumentException(usage());
} }
@@ -42,13 +44,16 @@ public class Ci {
System.exit(e.exitCode); System.exit(e.exitCode);
} catch (Exception e) { } catch (Exception e) {
// Never dump HTTP bodies, process arguments, environment or tokens. // Never dump HTTP bodies, process arguments, environment or tokens.
System.err.println(e instanceof IllegalArgumentException || e instanceof Setup.ApiFailure ? e.getMessage() System.err.println(
: "CI failed (" + e.getClass().getSimpleName() + "). Check diagnostics and configuration."); e instanceof IllegalArgumentException || e instanceof Setup.ApiFailure
? e.getMessage()
: "CI failed (" + e.getClass().getSimpleName() + "). Check diagnostics and configuration.");
System.exit(1); System.exit(1);
} }
} }
static String usage() { static String usage() {
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker <path>|install-runner [--prepare-only]"; return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker"
+ " <path>|install-runner [--prepare-only]";
} }
} }

View File

@@ -6,26 +6,41 @@ import java.util.*;
class CiTests { class CiTests {
static final String SHA = "a".repeat(40); static final String SHA = "a".repeat(40);
static int count; static int count;
interface Test { void run() throws Exception; }
interface Test {
void run() throws Exception;
}
static void test(String name, Test test) throws Exception { static void test(String name, Test test) throws Exception {
test.run(); count++; System.out.println("PASS " + name); test.run();
count++;
System.out.println("PASS " + name);
} }
static void equal(Object expected, Object actual) { static void equal(Object expected, Object actual) {
if (!Objects.equals(expected,actual)) throw new AssertionError("Expected " + expected + ", got " + actual); if (!Objects.equals(expected, actual)) throw new AssertionError("Expected " + expected + ", got " + actual);
} }
static void failure(Test action) throws Exception { static void failure(Test action) throws Exception {
try { action.run(); } catch (Exception expected) { return; } try {
action.run();
} catch (Exception expected) {
return;
}
throw new AssertionError("Expected failure"); throw new AssertionError("Expected failure");
} }
static void run() throws Exception { static void run() throws Exception {
count = 0; count = 0;
test("JSON preserves configuration values and escaped text", () -> { test("JSON preserves configuration values and escaped text", () -> {
String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}"; String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}";
Object data = Json.parse(json); equal(data,Json.parse(Json.write(data))); Object data = Json.parse(json);
equal("line\nquote\"€",Json.object(data).get("text")); equal(data, Json.parse(Json.write(data)));
equal("line\nquote\"€", Json.object(data).get("text"));
}); });
test("JSON rejects malformed/ambiguous input", () -> { test("JSON rejects malformed/ambiguous input", () -> {
for (String value : List.of("{\"a\":null,\"a\":1}","{} []","[1,]","01","\"\\uZZZZ\"","\"line\n\"","truefalse","+1")) for (String value : List.of(
"{\"a\":null,\"a\":1}", "{} []", "[1,]", "01", "\"\\uZZZZ\"", "\"line\n\"", "truefalse", "+1"))
failure(() -> Json.parse(value)); failure(() -> Json.parse(value));
}); });
test("Docker configuration merge is idempotent and preserves backup", () -> { test("Docker configuration merge is idempotent and preserves backup", () -> {
@@ -33,149 +48,288 @@ class CiTests {
try { try {
Path file = dir.resolve("daemon.json"); Path file = dir.resolve("daemon.json");
String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}"; String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}";
Files.writeString(file,original); Files.writeString(file, original);
Setup.configureDocker(file); String first = Files.readString(file); Setup.configureDocker(file); Setup.configureDocker(file);
equal(first,Files.readString(file)); String first = Files.readString(file);
equal(List.of("existing:5000","192.168.2.28:3000"),Json.object(Json.parse(first)).get("insecure-registries")); Setup.configureDocker(file);
equal(original,Files.readString(dir.resolve("daemon.json.before-agiledancers"))); equal(first, Files.readString(file));
} finally { Pipeline.deleteTree(dir); } equal(
List.of("existing:5000", "192.168.2.28:3000"),
Json.object(Json.parse(first)).get("insecure-registries"));
equal(original, Files.readString(dir.resolve("daemon.json.before-agiledancers")));
} finally {
Pipeline.deleteTree(dir);
}
}); });
test("Invalid Docker configuration is unchanged", () -> { test("Invalid Docker configuration is unchanged", () -> {
Path dir = Files.createTempDirectory("ci-docker-test-"); Path dir = Files.createTempDirectory("ci-docker-test-");
try { try {
Path file = dir.resolve("daemon.json"); String original = "{\"insecure-registries\":\"invalid\"}"; Path file = dir.resolve("daemon.json");
Files.writeString(file,original); failure(() -> Setup.configureDocker(file)); equal(original,Files.readString(file)); String original = "{\"insecure-registries\":\"invalid\"}";
} finally { Pipeline.deleteTree(dir); } Files.writeString(file, original);
failure(() -> Setup.configureDocker(file));
equal(original, Files.readString(file));
} finally {
Pipeline.deleteTree(dir);
}
}); });
test("Feature branches never contact registry", () -> { test("Feature branches never contact registry", () -> {
try (Fixture f = new Fixture()) { try (Fixture f = new Fixture()) {
f.pipeline.env.put("CI_REF","refs/heads/feature/test"); f.pipeline.publish(); equal(0L,f.calls("login")); equal(0L,f.calls("push")); f.pipeline.env.put("CI_REF", "refs/heads/feature/test");
f.pipeline.publish();
equal(0L, f.calls("login"));
equal(0L, f.calls("push"));
} }
}); });
test("Unverified commit cannot publish", () -> { test("Unverified commit cannot publish", () -> {
try (Fixture f = new Fixture()) { try (Fixture f = new Fixture()) {
Files.delete(f.root.resolve("ci/artifacts/verified-commit")); failure(f.pipeline::publish); equal(0L,f.calls("push")); Files.delete(f.root.resolve("ci/artifacts/verified-commit"));
failure(f.pipeline::publish);
equal(0L, f.calls("push"));
} }
}); });
test("Publication pullback and rerun preserve commit tags", () -> { test("Publication pullback and rerun preserve commit tags", () -> {
try (Fixture f = new Fixture()) { try (Fixture f = new Fixture()) {
f.pipeline.publish(); equal(4L,f.calls("push")); f.pipeline.publish(); equal(6L,f.calls("push")); equal(8L,f.calls("pull")); f.pipeline.publish();
equal(4L, f.calls("push"));
f.pipeline.publish();
equal(6L, f.calls("push"));
equal(8L, f.calls("pull"));
} }
}); });
test("Partial publication is completed without overwriting backend", () -> { test("Partial publication is completed without overwriting backend", () -> {
try (Fixture f = new Fixture()) { try (Fixture f = new Fixture()) {
f.fake.pushFailure = "frontend:" + SHA; failure(f.pipeline::publish); f.fake.pushFailure = "frontend:" + SHA;
equal(0L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith(":main")).count()); failure(f.pipeline::publish);
f.fake.pushFailure = ""; f.pipeline.publish(); equal(
equal(1L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith("backend:" + SHA)).count()); 0L,
f.fake.calls.stream()
.filter(a -> a.contains("push") && a.getLast().endsWith(":main"))
.count());
f.fake.pushFailure = "";
f.pipeline.publish();
equal(
1L,
f.fake.calls.stream()
.filter(a -> a.contains("push") && a.getLast().endsWith("backend:" + SHA))
.count());
} }
}); });
test("Authentication error is not a missing tag", () -> { test("Authentication error is not a missing tag", () -> {
try (Fixture f = new Fixture()) { f.fake.manifestError = true; failure(f.pipeline::publish); equal(0L,f.calls("push")); } try (Fixture f = new Fixture()) {
f.fake.manifestError = true;
failure(f.pipeline::publish);
equal(0L, f.calls("push"));
}
}); });
test("Pull failure blocks moving tags", () -> { test("Pull failure blocks moving tags", () -> {
try (Fixture f = new Fixture()) { try (Fixture f = new Fixture()) {
f.fake.pullFailure = "backend:" + SHA; failure(f.pipeline::publish); f.fake.pullFailure = "backend:" + SHA;
equal(0L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith(":main")).count()); failure(f.pipeline::publish);
equal(
0L,
f.fake.calls.stream()
.filter(a -> a.contains("push") && a.getLast().endsWith(":main"))
.count());
} }
}); });
test("Older commit cannot move main", () -> { test("Older commit cannot move main", () -> {
try (Fixture f = new Fixture()) { f.fake.head = "c".repeat(40); f.pipeline.publish(); equal(2L,f.calls("push")); } try (Fixture f = new Fixture()) {
f.fake.head = "c".repeat(40);
f.pipeline.publish();
equal(2L, f.calls("push"));
}
}); });
test("Unavailable Docker fails before build with a concrete cause", () -> { test("Unavailable Docker fails before build with a concrete cause", () -> {
try (Fixture f = new Fixture()) { try (Fixture f = new Fixture()) {
f.fake.dockerAvailable = false; f.fake.dockerAvailable = false;
try { f.pipeline.verify(); throw new AssertionError("Expected failure"); } try {
catch (IllegalArgumentException error) { equal(true,error.getMessage().contains("Docker is unavailable")); } f.pipeline.verify();
equal(0L,f.fake.calls.stream().filter(a -> a.getFirst().equals("mvn")).count()); throw new AssertionError("Expected failure");
} catch (IllegalArgumentException error) {
equal(true, error.getMessage().contains("Docker is unavailable"));
}
equal(
0L,
f.fake.calls.stream()
.filter(a -> a.getFirst().equals("mvn"))
.count());
} }
}); });
test("Architecture mismatch blocks publication", () -> { test("Architecture mismatch blocks publication", () -> {
try (Fixture f = new Fixture()) { f.pipeline.env.put("TARGET_PLATFORM","linux/arm64"); failure(f.pipeline::publish); equal(0L,f.calls("push")); } try (Fixture f = new Fixture()) {
f.pipeline.env.put("TARGET_PLATFORM", "linux/arm64");
failure(f.pipeline::publish);
equal(0L, f.calls("push"));
}
}); });
test("Failed verification removes success marker and preserves exit status", () -> { test("Failed verification removes success marker and preserves exit status", () -> {
try (Fixture f = new Fixture()) { try (Fixture f = new Fixture()) {
f.fake.mavenFailure = true; f.fake.mavenFailure = true;
try { f.pipeline.verify(); throw new AssertionError("Expected failure"); } try {
catch (Commands.Failure error) { equal(42,error.exitCode); } f.pipeline.verify();
equal(false,Files.exists(f.root.resolve("ci/artifacts/verified-commit"))); throw new AssertionError("Expected failure");
equal(0L,f.calls("build")); equal(0L,f.calls("push")); } catch (Commands.Failure error) {
equal(42, error.exitCode);
}
equal(
List.of("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"),
f.fake.calls.stream()
.filter(a -> a.getFirst().equals("mvn"))
.findFirst()
.orElseThrow());
equal(false, Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
equal(0L, f.calls("build"));
equal(0L, f.calls("push"));
} }
}); });
test("Frontend lint runs after install and blocks tests and build on failure", () -> { test("Frontend lint runs after install and blocks tests and build on failure", () -> {
try (Fixture f = new Fixture()) { try (Fixture f = new Fixture()) {
f.fake.lintFailure = true; f.fake.lintFailure = true;
try { f.pipeline.verifyFrontend(); throw new AssertionError("Expected failure"); } try {
catch (Commands.Failure error) { equal(1,error.exitCode); } f.pipeline.verifyFrontend();
throw new AssertionError("Expected failure");
} catch (Commands.Failure error) {
equal(1, error.exitCode);
}
equal(List.of(List.of("npm", "ci"), List.of("npm", "run", "lint")), f.fake.calls); equal(List.of(List.of("npm", "ci"), List.of("npm", "run", "lint")), f.fake.calls);
} }
}); });
test("Frontend verification checks before testing and building", () -> { test("Frontend verification checks before testing and building", () -> {
try (Fixture f = new Fixture()) { try (Fixture f = new Fixture()) {
f.pipeline.verifyFrontend(); f.pipeline.verifyFrontend();
equal(List.of("ci", "run lint", "test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml", "run build"), equal(
f.fake.calls.stream().map(a -> String.join(" ", a.subList(1,a.size()))).toList()); List.of(
"ci",
"run lint",
"test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml",
"run build"),
f.fake.calls.stream()
.map(a -> String.join(" ", a.subList(1, a.size())))
.toList());
} }
}); });
test("Process execution enforces its deadline", () -> { test("Process execution enforces its deadline", () -> {
Path dir = Files.createTempDirectory("ci-process-test-"); Path dir = Files.createTempDirectory("ci-process-test-");
try { try {
Path file = dir.resolve("Sleep.java"); Path file = dir.resolve("Sleep.java");
Files.writeString(file,"class Sleep { public static void main(String[] args) throws Exception { Thread.sleep(10000); } }"); Files.writeString(
file,
"class Sleep { public static void main(String[] args) throws Exception { Thread.sleep(10000);"
+ " } }");
Commands runner = new Commands(Duration.ofMillis(100)); Commands runner = new Commands(Duration.ofMillis(100));
try { try {
runner.execute(List.of(System.getProperty("java.home") + "/bin/java",file.toString()),dir,System.getenv(),null,false); runner.execute(
List.of(System.getProperty("java.home") + "/bin/java", file.toString()),
dir,
System.getenv(),
null,
false);
throw new AssertionError("Expected timeout"); throw new AssertionError("Expected timeout");
} catch (Commands.Failure error) { equal(124,error.exitCode); } } catch (Commands.Failure error) {
} finally { Pipeline.deleteTree(dir); } equal(124, error.exitCode);
}
} finally {
Pipeline.deleteTree(dir);
}
}); });
System.out.println(count + " CI tests passed"); System.out.println(count + " CI tests passed");
} }
static class Fixture implements AutoCloseable { static class Fixture implements AutoCloseable {
final Path root = Files.createTempDirectory("ci-publication-test-"); final Path root = Files.createTempDirectory("ci-publication-test-");
final FakeCommands fake = new FakeCommands(); final FakeCommands fake = new FakeCommands();
final Pipeline pipeline; final Pipeline pipeline;
Fixture() throws Exception { Fixture() throws Exception {
Map<String,String> env = new HashMap<>(System.getenv()); Map<String, String> env = new HashMap<>(System.getenv());
env.putAll(Map.of("CI_COMMIT",SHA,"CI_RUN_ID","test","CI_RUN_ATTEMPT","1","CI_REF","refs/heads/main", env.putAll(Map.of(
"CI_REPOSITORY","wanjakrah/agiledancers","TARGET_PLATFORM","linux/amd64","GITEA_SERVER_URL","http://192.168.2.28:3000", "CI_COMMIT",
"REGISTRY_USER","test","REGISTRY_TOKEN","dummy-token","CI_PUBLISH_LOCK_DIR",root.resolve("lock").toString())); SHA,
pipeline = new Pipeline(root,env,fake) { @Override String mainHead() { return fake.head; } }; "CI_RUN_ID",
Files.writeString(root.resolve("ci/artifacts/verified-commit"),SHA + "\n"); "test",
"CI_RUN_ATTEMPT",
"1",
"CI_REF",
"refs/heads/main",
"CI_REPOSITORY",
"wanjakrah/agiledancers",
"TARGET_PLATFORM",
"linux/amd64",
"GITEA_SERVER_URL",
"http://192.168.2.28:3000",
"REGISTRY_USER",
"test",
"REGISTRY_TOKEN",
"dummy-token",
"CI_PUBLISH_LOCK_DIR",
root.resolve("lock").toString()));
pipeline = new Pipeline(root, env, fake) {
@Override
String mainHead() {
return fake.head;
}
};
Files.writeString(root.resolve("ci/artifacts/verified-commit"), SHA + "\n");
}
long calls(String verb) {
return fake.calls.stream()
.filter(a -> a.size() > 1
&& a.getFirst().equals("docker")
&& a.get(1).equals(verb))
.count();
}
public void close() throws Exception {
pipeline.closeDockerConfig();
Pipeline.deleteTree(root);
} }
long calls(String verb) { return fake.calls.stream().filter(a -> a.size()>1 && a.getFirst().equals("docker") && a.get(1).equals(verb)).count(); }
public void close() throws Exception { pipeline.closeDockerConfig(); Pipeline.deleteTree(root); }
} }
static class FakeCommands extends Commands { static class FakeCommands extends Commands {
final List<List<String>> calls = new ArrayList<>(); final List<List<String>> calls = new ArrayList<>();
final Map<String,String> images = new HashMap<>(), tags = new HashMap<>(); final Map<String, String> images = new HashMap<>(), tags = new HashMap<>();
String head = SHA, pushFailure = "", pullFailure = ""; String head = SHA, pushFailure = "", pullFailure = "";
boolean manifestError, mavenFailure, lintFailure; boolean manifestError, mavenFailure, lintFailure;
boolean dockerAvailable = true; boolean dockerAvailable = true;
FakeCommands() { super(Duration.ofMinutes(1)); }
@Override Result execute(List<String> args, Path cwd, Map<String,String> env, String input, boolean echo) { FakeCommands() {
super(Duration.ofMinutes(1));
}
@Override
Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) {
calls.add(List.copyOf(args)); calls.add(List.copyOf(args));
if (args.getFirst().equals("git")) return new Result(0,SHA); if (args.getFirst().equals("git")) return new Result(0, SHA);
if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42,"deliberate failure"); if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42, "deliberate failure");
if (args.equals(List.of("npm", "run", "lint")) && lintFailure) return new Result(1,"deliberate lint failure"); if (args.equals(List.of("npm", "run", "lint")) && lintFailure)
if (!args.getFirst().equals("docker")) return new Result(0,""); return new Result(1, "deliberate lint failure");
if (!args.getFirst().equals("docker")) return new Result(0, "");
String command = args.get(1), ref = args.getLast(); String command = args.get(1), ref = args.getLast();
if (command.equals("info")) return new Result(dockerAvailable ? 0 : 1,args.contains("{{.Architecture}}") ? "amd64" : "linux"); if (command.equals("info"))
if (command.equals("context")) return new Result(0,"unix:///tmp/test-docker.sock"); return new Result(dockerAvailable ? 0 : 1, args.contains("{{.Architecture}}") ? "amd64" : "linux");
if (command.equals("context")) return new Result(0, "unix:///tmp/test-docker.sock");
if (command.equals("manifest")) { if (command.equals("manifest")) {
if (manifestError) return new Result(1,"unauthorized"); if (manifestError) return new Result(1, "unauthorized");
return images.containsKey(ref) ? new Result(0,"{}") : new Result(1,"manifest unknown"); return images.containsKey(ref) ? new Result(0, "{}") : new Result(1, "manifest unknown");
} }
if (command.equals("tag")) tags.put(ref,images.getOrDefault(args.get(2),SHA)); if (command.equals("tag")) tags.put(ref, images.getOrDefault(args.get(2), SHA));
if (command.equals("push")) { if (command.equals("push")) {
if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1,"push failed"); if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1, "push failed");
images.put(ref,tags.getOrDefault(ref,SHA)); images.put(ref, tags.getOrDefault(ref, SHA));
} }
if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure)) return new Result(1,"pull failed"); if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure))
if (command.equals("image")) return new Result(0,args.stream().anyMatch(a -> a.contains("RepoDigests")) return new Result(1, "pull failed");
? ref.substring(0,ref.lastIndexOf(':')) + "@sha256:" + "b".repeat(64) : images.getOrDefault(ref,"missing")); if (command.equals("image"))
if (command.equals("container") || command.equals("network") && args.contains("inspect")) return new Result(1,""); return new Result(
return new Result(0,""); 0,
args.stream().anyMatch(a -> a.contains("RepoDigests"))
? ref.substring(0, ref.lastIndexOf(':')) + "@sha256:" + "b".repeat(64)
: images.getOrDefault(ref, "missing"));
if (command.equals("container") || command.equals("network") && args.contains("inspect"))
return new Result(1, "");
return new Result(0, "");
} }
} }
} }

View File

@@ -13,20 +13,37 @@ class Commands {
return output.strip(); return output.strip();
} }
} }
static class Failure extends RuntimeException { static class Failure extends RuntimeException {
final int exitCode; final int exitCode;
Failure(int exitCode, String message) { super(message); this.exitCode = exitCode; }
Failure(int exitCode, String message) {
super(message);
this.exitCode = exitCode;
}
} }
Instant deadline; Instant deadline;
volatile Process active; volatile Process active;
Path log; Path log;
void renew(Duration timeout) { deadline = Instant.now().plus(timeout); }
void cancel() { Process process = active; if (process != null) stop(process); } void renew(Duration timeout) {
Commands(Duration timeout) { deadline = Instant.now().plus(timeout); } deadline = Instant.now().plus(timeout);
}
void cancel() {
Process process = active;
if (process != null) stop(process);
}
Commands(Duration timeout) {
deadline = Instant.now().plus(timeout);
}
Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) throws Exception { Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) throws Exception {
if (!Instant.now().isBefore(deadline)) throw new Failure(124, "CI time limit exceeded"); if (!Instant.now().isBefore(deadline)) throw new Failure(124, "CI time limit exceeded");
ProcessBuilder builder = new ProcessBuilder(args).directory(cwd.toFile()).redirectErrorStream(true); ProcessBuilder builder =
new ProcessBuilder(args).directory(cwd.toFile()).redirectErrorStream(true);
builder.environment().clear(); builder.environment().clear();
builder.environment().putAll(env); builder.environment().putAll(env);
Process process = builder.start(); Process process = builder.start();
@@ -36,22 +53,30 @@ class Commands {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<?> reader = executor.submit(() -> { Future<?> reader = executor.submit(() -> {
try (InputStream stream = process.getInputStream(); try (InputStream stream = process.getInputStream();
OutputStream file = destination == null ? OutputStream.nullOutputStream() OutputStream file = destination == null
: Files.newOutputStream(destination, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) { ? OutputStream.nullOutputStream()
: Files.newOutputStream(
destination, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
byte[] buffer = new byte[8192]; byte[] buffer = new byte[8192];
int count; int count;
while ((count = stream.read(buffer)) != -1) { while ((count = stream.read(buffer)) != -1) {
captured.write(buffer, 0, count); captured.write(buffer, 0, count);
if (echo) { System.out.write(buffer, 0, count); System.out.flush(); } if (echo) {
System.out.write(buffer, 0, count);
System.out.flush();
}
file.write(buffer, 0, count); file.write(buffer, 0, count);
} }
} catch (IOException e) { throw new UncheckedIOException(e); } } catch (IOException e) {
throw new UncheckedIOException(e);
}
}); });
try { try {
try (OutputStream stdin = process.getOutputStream()) { try (OutputStream stdin = process.getOutputStream()) {
if (input != null) stdin.write(input.getBytes(StandardCharsets.UTF_8)); if (input != null) stdin.write(input.getBytes(StandardCharsets.UTF_8));
} }
if (!process.waitFor(Math.max(1, Duration.between(Instant.now(), deadline).toMillis()), TimeUnit.MILLISECONDS)) { if (!process.waitFor(
Math.max(1, Duration.between(Instant.now(), deadline).toMillis()), TimeUnit.MILLISECONDS)) {
stop(process); stop(process);
throw new Failure(124, "CI time limit exceeded"); throw new Failure(124, "CI time limit exceeded");
} }

View File

@@ -5,93 +5,145 @@ import java.util.*;
final class Json { final class Json {
static Object parse(String source) { static Object parse(String source) {
Parser parser = new Parser(source); Parser parser = new Parser(source);
Object result = parser.value(); parser.space(); Object result = parser.value();
parser.space();
if (parser.pos != source.length()) throw parser.invalid(); if (parser.pos != source.length()) throw parser.invalid();
return result; return result;
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
static Map<String, Object> object(Object value) { static Map<String, Object> object(Object value) {
if (!(value instanceof Map<?, ?>)) throw new IllegalArgumentException("Expected a JSON object"); if (!(value instanceof Map<?, ?>)) throw new IllegalArgumentException("Expected a JSON object");
return (Map<String, Object>) value; return (Map<String, Object>) value;
} }
static String write(Object value) { static String write(Object value) {
if (value == null) return "null"; if (value == null) return "null";
if (value instanceof String text) { if (value instanceof String text) {
StringBuilder out = new StringBuilder("\""); StringBuilder out = new StringBuilder("\"");
for (char c : text.toCharArray()) { for (char c : text.toCharArray()) {
switch (c) { switch (c) {
case '"' -> out.append("\\\""); case '\\' -> out.append("\\\\"); case '"' -> out.append("\\\"");
case '\n' -> out.append("\\n"); case '\r' -> out.append("\\r"); case '\t' -> out.append("\\t"); case '\\' -> out.append("\\\\");
default -> { if (c < 32) out.append(String.format("\\u%04x", (int)c)); else out.append(c); } case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
default -> {
if (c < 32) out.append(String.format("\\u%04x", (int) c));
else out.append(c);
}
} }
} }
return out.append('"').toString(); return out.append('"').toString();
} }
if (value instanceof Boolean || value instanceof BigDecimal || value instanceof Integer || value instanceof Long) if (value instanceof Boolean
return value.toString(); || value instanceof BigDecimal
|| value instanceof Integer
|| value instanceof Long) return value.toString();
if (value instanceof Map<?, ?> map) { if (value instanceof Map<?, ?> map) {
StringJoiner out = new StringJoiner(",", "{", "}"); StringJoiner out = new StringJoiner(",", "{", "}");
map.forEach((k,v) -> out.add(write(Objects.toString(k)) + ":" + write(v))); return out.toString(); map.forEach((k, v) -> out.add(write(Objects.toString(k)) + ":" + write(v)));
return out.toString();
} }
if (value instanceof List<?> list) { if (value instanceof List<?> list) {
StringJoiner out = new StringJoiner(",", "[", "]"); list.forEach(v -> out.add(write(v))); return out.toString(); StringJoiner out = new StringJoiner(",", "[", "]");
list.forEach(v -> out.add(write(v)));
return out.toString();
} }
throw new IllegalArgumentException("Unsupported JSON value"); throw new IllegalArgumentException("Unsupported JSON value");
} }
private static final class Parser { private static final class Parser {
final String source; int pos; final String source;
Parser(String source) { this.source = source; } int pos;
IllegalArgumentException invalid() { return new IllegalArgumentException("Invalid JSON at offset " + pos); }
void space() { while (pos < source.length() && " \r\n\t".indexOf(source.charAt(pos)) >= 0) pos++; } Parser(String source) {
boolean take(char c) { space(); if (pos < source.length() && source.charAt(pos) == c) { pos++; return true; } return false; } this.source = source;
}
IllegalArgumentException invalid() {
return new IllegalArgumentException("Invalid JSON at offset " + pos);
}
void space() {
while (pos < source.length() && " \r\n\t".indexOf(source.charAt(pos)) >= 0) pos++;
}
boolean take(char c) {
space();
if (pos < source.length() && source.charAt(pos) == c) {
pos++;
return true;
}
return false;
}
Object value() { Object value() {
space(); if (pos >= source.length()) throw invalid(); space();
if (pos >= source.length()) throw invalid();
char c = source.charAt(pos); char c = source.charAt(pos);
if (c == '"') return string(); if (c == '"') return string();
if (take('{')) { if (take('{')) {
Map<String,Object> out = new LinkedHashMap<>(); Map<String, Object> out = new LinkedHashMap<>();
if (take('}')) return out; if (take('}')) return out;
do { do {
space(); if (pos >= source.length() || source.charAt(pos) != '"') throw invalid(); space();
String key = string(); if (!take(':') || out.containsKey(key)) throw invalid(); if (pos >= source.length() || source.charAt(pos) != '"') throw invalid();
String key = string();
if (!take(':') || out.containsKey(key)) throw invalid();
out.put(key, value()); out.put(key, value());
} while (take(',')); } while (take(','));
if (!take('}')) throw invalid(); return out; if (!take('}')) throw invalid();
return out;
} }
if (take('[')) { if (take('[')) {
List<Object> out = new ArrayList<>(); if (take(']')) return out; List<Object> out = new ArrayList<>();
do { out.add(value()); } while (take(',')); if (take(']')) return out;
if (!take(']')) throw invalid(); return out; do {
out.add(value());
} while (take(','));
if (!take(']')) throw invalid();
return out;
} }
for (String token : List.of("true", "false", "null")) { for (String token : List.of("true", "false", "null")) {
if (source.startsWith(token, pos)) { if (source.startsWith(token, pos)) {
pos += token.length(); return token.equals("null") ? null : Boolean.valueOf(token); pos += token.length();
return token.equals("null") ? null : Boolean.valueOf(token);
} }
} }
int start = pos; int start = pos;
while (pos < source.length() && "-+0123456789.eE".indexOf(source.charAt(pos)) >= 0) pos++; while (pos < source.length() && "-+0123456789.eE".indexOf(source.charAt(pos)) >= 0) pos++;
String number = source.substring(start,pos); String number = source.substring(start, pos);
if (!number.matches("-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?")) throw invalid(); if (!number.matches("-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?")) throw invalid();
return new BigDecimal(number); return new BigDecimal(number);
} }
String string() { String string() {
pos++; StringBuilder out = new StringBuilder(); pos++;
StringBuilder out = new StringBuilder();
while (pos < source.length()) { while (pos < source.length()) {
char c = source.charAt(pos++); char c = source.charAt(pos++);
if (c == '"') return out.toString(); if (c == '"') return out.toString();
if (c < 32) throw invalid(); if (c < 32) throw invalid();
if (c != '\\') { out.append(c); continue; } if (c != '\\') {
out.append(c);
continue;
}
if (pos >= source.length()) throw invalid(); if (pos >= source.length()) throw invalid();
char escaped = source.charAt(pos++); char escaped = source.charAt(pos++);
switch (escaped) { switch (escaped) {
case '"', '\\', '/' -> out.append(escaped); case '"', '\\', '/' -> out.append(escaped);
case 'b' -> out.append('\b'); case 'f' -> out.append('\f'); case 'b' -> out.append('\b');
case 'n' -> out.append('\n'); case 'r' -> out.append('\r'); case 't' -> out.append('\t'); case 'f' -> out.append('\f');
case 'n' -> out.append('\n');
case 'r' -> out.append('\r');
case 't' -> out.append('\t');
case 'u' -> { case 'u' -> {
if (pos + 4 > source.length()) throw invalid(); if (pos + 4 > source.length()) throw invalid();
String digits = source.substring(pos,pos+4); String digits = source.substring(pos, pos + 4);
if (!digits.matches("[0-9a-fA-F]{4}")) throw invalid(); if (!digits.matches("[0-9a-fA-F]{4}")) throw invalid();
out.append((char) Integer.parseInt(digits,16)); pos += 4; out.append((char) Integer.parseInt(digits, 16));
pos += 4;
} }
default -> throw invalid(); default -> throw invalid();
} }

View File

@@ -7,12 +7,12 @@ import java.util.*;
/** Verification, publication and cleanup shared by local use and Gitea Actions. */ /** Verification, publication and cleanup shared by local use and Gitea Actions. */
class Pipeline { class Pipeline {
final Path root, artifacts; final Path root, artifacts;
final Map<String,String> env; final Map<String, String> env;
final Commands commands; final Commands commands;
final String commit, runName, backendImage, frontendImage; final String commit, runName, backendImage, frontendImage;
Path isolatedConfig; Path isolatedConfig;
Pipeline(Path root, Map<String,String> environment, Commands commands) throws Exception { Pipeline(Path root, Map<String, String> environment, Commands commands) throws Exception {
this.root = root; this.root = root;
this.env = new HashMap<>(environment); this.env = new HashMap<>(environment);
this.commands = commands; this.commands = commands;
@@ -23,190 +23,401 @@ class Pipeline {
runName = "agiledancers-" + id + "-" + attempt; runName = "agiledancers-" + id + "-" + attempt;
backendImage = "agiledancers-ci-backend:" + commit + "-" + id + "-" + attempt; backendImage = "agiledancers-ci-backend:" + commit + "-" + id + "-" + attempt;
frontendImage = "agiledancers-ci-frontend:" + commit + "-" + id + "-" + attempt; frontendImage = "agiledancers-ci-frontend:" + commit + "-" + id + "-" + attempt;
artifacts = root.resolve("ci/artifacts"); Files.createDirectories(artifacts); artifacts = root.resolve("ci/artifacts");
Files.createDirectories(artifacts);
} }
interface Value { String get() throws Exception; }
interface Value {
String get() throws Exception;
}
String value(String key, Value defaultValue) throws Exception { String value(String key, Value defaultValue) throws Exception {
if (env.getOrDefault(key, "").isBlank()) env.put(key, defaultValue.get()); if (env.getOrDefault(key, "").isBlank()) env.put(key, defaultValue.get());
return env.get(key); return env.get(key);
} }
static void check(boolean condition, String message) { static void check(boolean condition, String message) {
if (!condition) throw new IllegalArgumentException(message); if (!condition) throw new IllegalArgumentException(message);
} }
Commands.Result result(boolean echo, String... args) throws Exception { Commands.Result result(boolean echo, String... args) throws Exception {
return commands.execute(List.of(args), root, env, null, echo); return commands.execute(List.of(args), root, env, null, echo);
} }
String capture(String... args) throws Exception { return result(false, args).checked(); }
void run(String... args) throws Exception { result(true, args).checked(); } String capture(String... args) throws Exception {
return result(false, args).checked();
}
void run(String... args) throws Exception {
result(true, args).checked();
}
void requirePlatform() throws Exception { void requirePlatform() throws Exception {
String target = Setup.required(env,"TARGET_PLATFORM"); String target = Setup.required(env, "TARGET_PLATFORM");
var docker = result(false,"docker", "info", "--format", "{{.Architecture}}"); var docker = result(false, "docker", "info", "--format", "{{.Architecture}}");
check(docker.code() == 0, "Docker is unavailable; start Docker Desktop before running CI (details in verify.log)"); check(
docker.code() == 0,
"Docker is unavailable; start Docker Desktop before running CI (details in verify.log)");
String arch = docker.output().strip(); String arch = docker.output().strip();
arch = switch (arch) { case "x86_64" -> "amd64"; case "aarch64" -> "arm64"; default -> arch; }; arch = switch (arch) {
case "x86_64" -> "amd64";
case "aarch64" -> "arm64";
default -> arch;
};
String nativePlatform = capture("docker", "info", "--format", "{{.OSType}}") + "/" + arch; String nativePlatform = capture("docker", "info", "--format", "{{.OSType}}") + "/" + arch;
check(target.equals(nativePlatform), "Target " + target + " differs from Docker host " + nativePlatform); check(target.equals(nativePlatform), "Target " + target + " differs from Docker host " + nativePlatform);
} }
void isolateDocker() throws Exception { void isolateDocker() throws Exception {
value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}")); value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"));
env.remove("DOCKER_CONTEXT"); env.remove("DOCKER_CONTEXT");
isolatedConfig = Files.createTempDirectory("agiledancers-docker-"); isolatedConfig = Files.createTempDirectory("agiledancers-docker-");
env.put("DOCKER_CONFIG", isolatedConfig.toString()); env.put("DOCKER_CONFIG", isolatedConfig.toString());
} }
void verify() throws Exception { void verify() throws Exception {
Files.deleteIfExists(artifacts.resolve("verified-commit")); Files.deleteIfExists(artifacts.resolve("verified-commit"));
env.put("CI", "true"); env.put("CI", "true");
commands.log = artifacts.resolve("verify.log"); Files.writeString(commands.log, ""); commands.log = artifacts.resolve("verify.log");
Thread shutdown = new Thread(() -> { Files.writeString(commands.log, "");
commands.cancel(); Thread shutdown = new Thread(
try { cleanup(); closeDockerConfig(); } catch (Exception e) { System.err.println("CI shutdown cleanup failed"); } () -> {
}, "ci-cleanup"); commands.cancel();
try {
cleanup();
closeDockerConfig();
} catch (Exception e) {
System.err.println("CI shutdown cleanup failed");
}
},
"ci-cleanup");
Runtime.getRuntime().addShutdownHook(shutdown); Runtime.getRuntime().addShutdownHook(shutdown);
Exception failure = null; Exception failure = null;
try { try {
requirePlatform(); isolateDocker(); requirePlatform();
isolateDocker();
check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit"); check(commit.equals(capture("git", "rev-parse", "HEAD")), "Checkout does not match pushed commit");
run("java", "-version"); run("node", "--version"); run("mvn", "-B", "-ntp", "clean", "verify"); run("java", "-version");
run("node", "--version");
run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify");
CiTests.run(); CiTests.run();
Path frontend = root.resolve("agiledancers-fe"); Path frontend = root.resolve("agiledancers-fe");
verifyFrontend(); verifyFrontend();
String platform = env.get("TARGET_PLATFORM"); String platform = env.get("TARGET_PLATFORM");
run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label", run(
"org.opencontainers.image.revision=" + commit, "-t", backendImage, "."); "docker",
run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label", "build",
"org.opencontainers.image.revision=" + commit, "-t", frontendImage, "agiledancers-fe"); "--platform",
platform,
"--target",
"prebuilt",
"--label",
"org.opencontainers.image.revision=" + commit,
"-t",
backendImage,
".");
run(
"docker",
"build",
"--platform",
platform,
"--target",
"prebuilt",
"--label",
"org.opencontainers.image.revision=" + commit,
"-t",
frontendImage,
"agiledancers-fe");
Files.writeString(artifacts.resolve("containers-started"), ""); Files.writeString(artifacts.resolve("containers-started"), "");
run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName); run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName);
run("docker", "run", "-d", "--name", runName + "-backend", "--label", "agiledancers.ci.run=" + runName, run(
"--platform", platform, "--network", runName, "--network-alias", "backend", "--memory", "1g", "--cpus", "2", backendImage); "docker",
run("docker", "run", "-d", "--name", runName + "-frontend", "--label", "agiledancers.ci.run=" + runName, "run",
"--platform", platform, "--network", runName, "--network-alias", "frontend", "--memory", "256m", "--cpus", "1", frontendImage); "-d",
"--name",
runName + "-backend",
"--label",
"agiledancers.ci.run=" + runName,
"--platform",
platform,
"--network",
runName,
"--network-alias",
"backend",
"--memory",
"1g",
"--cpus",
"2",
backendImage);
run(
"docker",
"run",
"-d",
"--name",
runName + "-frontend",
"--label",
"agiledancers.ci.run=" + runName,
"--platform",
platform,
"--network",
runName,
"--network-alias",
"frontend",
"--memory",
"256m",
"--cpus",
"1",
frontendImage);
waitForApplication(); waitForApplication();
String user = capture("id", "-u") + ":" + capture("id", "-g"); String user = capture("id", "-u") + ":" + capture("id", "-g");
run("docker", "run", "--name", runName + "-browser", "--label", "agiledancers.ci.run=" + runName, run(
"--platform", platform, "--network", runName, "--memory", "2g", "--cpus", "2", "--shm-size=1g", "docker",
"--user", user, "--env", "HOME=/tmp", "--env", "CI=true", "--env", "PLAYWRIGHT_BASE_URL=http://frontend:8080", "run",
"--volume", frontend + ":/workspace", "--workdir", "/workspace", "mcr.microsoft.com/playwright:v1.64.1-noble", "--name",
"npm", "run", "e2e"); runName + "-browser",
"--label",
"agiledancers.ci.run=" + runName,
"--platform",
platform,
"--network",
runName,
"--memory",
"2g",
"--cpus",
"2",
"--shm-size=1g",
"--user",
user,
"--env",
"HOME=/tmp",
"--env",
"CI=true",
"--env",
"PLAYWRIGHT_BASE_URL=http://frontend:8080",
"--volume",
frontend + ":/workspace",
"--workdir",
"/workspace",
"mcr.microsoft.com/playwright:v1.64.1-noble",
"npm",
"run",
"e2e");
Files.writeString(artifacts.resolve("verified-commit"), commit + "\n"); Files.writeString(artifacts.resolve("verified-commit"), commit + "\n");
} catch (Exception e) { failure = e; throw e; } } catch (Exception e) {
finally { failure = e;
try { cleanup(); } catch (Exception e) { if (failure != null) failure.addSuppressed(e); else throw e; } throw e;
finally { } finally {
try {
cleanup();
} catch (Exception e) {
if (failure != null) failure.addSuppressed(e);
else throw e;
} finally {
closeDockerConfig(); closeDockerConfig();
try { Runtime.getRuntime().removeShutdownHook(shutdown); } catch (IllegalStateException ignored) { /* shutting down */ } try {
Runtime.getRuntime().removeShutdownHook(shutdown);
} catch (IllegalStateException ignored) {
/* shutting down */
}
} }
} }
} }
void verifyFrontend() throws Exception { void verifyFrontend() throws Exception {
Path frontend = root.resolve("agiledancers-fe"); Path frontend = root.resolve("agiledancers-fe");
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked(); commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked();
commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true).checked(); commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true)
commands.execute(List.of("npm", "test", "--", "--reporters=default", "--reporters=junit", .checked();
"--output-file=unit-results/junit.xml"), frontend, env, null, true).checked(); commands.execute(
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true).checked(); List.of(
"npm",
"test",
"--",
"--reporters=default",
"--reporters=junit",
"--output-file=unit-results/junit.xml"),
frontend,
env,
null,
true)
.checked();
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true)
.checked();
} }
void waitForApplication() throws Exception { void waitForApplication() throws Exception {
for (int i = 0; i < 60; i++) { for (int i = 0; i < 60; i++) {
if (result(false,"docker", "exec", runName + "-frontend", "wget", "-q", "-O", "-", if (result(
"http://127.0.0.1:8080/api/dance-schools").code() == 0) return; false,
"docker",
"exec",
runName + "-frontend",
"wget",
"-q",
"-O",
"-",
"http://127.0.0.1:8080/api/dance-schools")
.code()
== 0) return;
Thread.sleep(2000); Thread.sleep(2000);
} }
throw new IllegalArgumentException("Application readiness timed out"); throw new IllegalArgumentException("Application readiness timed out");
} }
synchronized void cleanup() throws Exception { synchronized void cleanup() throws Exception {
// A separate cleanup budget remains available after the build deadline. // A separate cleanup budget remains available after the build deadline.
commands.renew(Duration.ofMinutes(2)); commands.renew(Duration.ofMinutes(2));
List<String> failures = new ArrayList<>(); List<String> failures = new ArrayList<>();
for (String path : List.of("agiledancers-be/target/surefire-reports", "agiledancers-be/target/failsafe-reports", for (String path : List.of(
"agiledancers-fe/unit-results", "agiledancers-fe/test-results", "agiledancers-fe/playwright-report")) { "agiledancers-be/target/surefire-reports",
"agiledancers-be/target/failsafe-reports",
"agiledancers-fe/unit-results",
"agiledancers-fe/test-results",
"agiledancers-fe/playwright-report")) {
if (Files.isDirectory(root.resolve(path))) { if (Files.isDirectory(root.resolve(path))) {
try { copyTree(root.resolve(path), artifacts.resolve(path)); } catch (IOException e) { failures.add(path); } try {
} copyTree(root.resolve(path), artifacts.resolve(path));
} } catch (IOException e) {
if (result(false,"docker", "info").code() == 0) { failures.add(path);
for (String role : List.of("backend", "frontend", "browser")) {
String container = runName + "-" + role;
if (result(false,"docker", "container", "inspect", container).code() == 0) {
var logs = result(false,"docker", "logs", container);
Files.writeString(artifacts.resolve(role + ".log"), logs.output());
if (logs.code() != 0) failures.add(role + " logs");
if (result(false,"docker", "rm", "-f", container).code() != 0) failures.add(role + " cleanup");
} }
} }
if (result(false,"docker", "network", "inspect", runName).code() == 0 }
&& result(false,"docker", "network", "rm", runName).code() != 0) failures.add("network cleanup"); if (result(false, "docker", "info").code() == 0) {
for (String role : List.of("backend", "frontend", "browser")) {
String container = runName + "-" + role;
if (result(false, "docker", "container", "inspect", container).code() == 0) {
var logs = result(false, "docker", "logs", container);
Files.writeString(artifacts.resolve(role + ".log"), logs.output());
if (logs.code() != 0) failures.add(role + " logs");
if (result(false, "docker", "rm", "-f", container).code() != 0) failures.add(role + " cleanup");
}
}
if (result(false, "docker", "network", "inspect", runName).code() == 0
&& result(false, "docker", "network", "rm", runName).code() != 0) failures.add("network cleanup");
if (failures.isEmpty()) Files.deleteIfExists(artifacts.resolve("containers-started")); if (failures.isEmpty()) Files.deleteIfExists(artifacts.resolve("containers-started"));
} else if (Files.exists(artifacts.resolve("containers-started"))) failures.add("Docker unavailable during cleanup"); } else if (Files.exists(artifacts.resolve("containers-started")))
failures.add("Docker unavailable during cleanup");
check(failures.isEmpty(), "Cleanup incomplete: " + String.join(", ", failures)); check(failures.isEmpty(), "Cleanup incomplete: " + String.join(", ", failures));
} }
void publish() throws Exception { void publish() throws Exception {
if (!"refs/heads/main".equals(env.get("CI_REF"))) { System.out.println("Branch is not main; no publication"); return; } if (!"refs/heads/main".equals(env.get("CI_REF"))) {
check(Files.exists(artifacts.resolve("verified-commit")) System.out.println("Branch is not main; no publication");
&& Files.readString(artifacts.resolve("verified-commit")).strip().equals(commit), "This commit has not passed verification"); return;
String user = Setup.required(env,"REGISTRY_USER"), token = Setup.required(env,"REGISTRY_TOKEN"); }
String server = Setup.required(env,"GITEA_SERVER_URL"); check(
String repository = Setup.required(env,"CI_REPOSITORY"); Files.exists(artifacts.resolve("verified-commit"))
&& Files.readString(artifacts.resolve("verified-commit"))
.strip()
.equals(commit),
"This commit has not passed verification");
String user = Setup.required(env, "REGISTRY_USER"), token = Setup.required(env, "REGISTRY_TOKEN");
String server = Setup.required(env, "GITEA_SERVER_URL");
String repository = Setup.required(env, "CI_REPOSITORY");
requirePlatform(); requirePlatform();
commands.log = artifacts.resolve("publish.log"); Files.writeString(commands.log, ""); commands.log = artifacts.resolve("publish.log");
Files.writeString(commands.log, "");
var uri = java.net.URI.create(server); var uri = java.net.URI.create(server);
check(Set.of("http", "https").contains(uri.getScheme()) && (uri.getPath().isEmpty() || uri.getPath().equals("/")), check(
Set.of("http", "https").contains(uri.getScheme())
&& (uri.getPath().isEmpty() || uri.getPath().equals("/")),
"Registry must be hosted at the Gitea server root"); "Registry must be hosted at the Gitea server root");
String registry = uri.getRawAuthority(); String registry = uri.getRawAuthority();
String owner = repository.split("/")[0].toLowerCase(Locale.ROOT); String owner = repository.split("/")[0].toLowerCase(Locale.ROOT);
check(owner.matches("[a-z0-9][a-z0-9_.-]*"), "Invalid registry namespace"); check(owner.matches("[a-z0-9][a-z0-9_.-]*"), "Invalid registry namespace");
Path lockDir = Path.of(env.getOrDefault("CI_PUBLISH_LOCK_DIR", Path lockDir = Path.of(env.getOrDefault(
env.getOrDefault("XDG_STATE_HOME", Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci")); "CI_PUBLISH_LOCK_DIR",
env.getOrDefault(
"XDG_STATE_HOME",
Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci"));
Files.createDirectories(lockDir); Files.createDirectories(lockDir);
// Shared host lock: Gitea 1.25 ignores workflow concurrency. // Shared host lock: Gitea 1.25 ignores workflow concurrency.
try (FileChannel channel = FileChannel.open(lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE); try (FileChannel channel = FileChannel.open(
FileLock lock = awaitLock(channel)) { lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock lock = awaitLock(channel)) {
isolateDocker(); isolateDocker();
try { try {
commands.execute(List.of("docker", "login", registry, "--username", user, "--password-stdin"), root, env, token, true).checked(); commands.execute(
List.of("docker", "login", registry, "--username", user, "--password-stdin"),
root,
env,
token,
true)
.checked();
String prefix = registry + "/" + owner + "/agiledancers-"; String prefix = registry + "/" + owner + "/agiledancers-";
ensureImage(prefix + "backend", backendImage); ensureImage(prefix + "backend", backendImage);
ensureImage(prefix + "frontend", frontendImage); ensureImage(prefix + "frontend", frontendImage);
for (String role : List.of("backend", "frontend")) { for (String role : List.of("backend", "frontend")) {
if (!commit.equals(mainHead())) { System.out.println("Newer main exists; moving tags unchanged"); return; } if (!commit.equals(mainHead())) {
System.out.println("Newer main exists; moving tags unchanged");
return;
}
String image = prefix + role; String image = prefix + role;
run("docker", "tag", image + ":" + commit, image + ":main"); run("docker", "tag", image + ":" + commit, image + ":main");
run("docker", "push", image + ":main"); run("docker", "push", image + ":main");
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), image + ":main"); run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), image + ":main");
} }
} finally { closeDockerConfig(); } } finally {
closeDockerConfig();
}
} }
} }
FileLock awaitLock(FileChannel channel) throws Exception { FileLock awaitLock(FileChannel channel) throws Exception {
Instant end = Instant.now().plusSeconds(600); Instant end = Instant.now().plusSeconds(600);
while (Instant.now().isBefore(end)) { while (Instant.now().isBefore(end)) {
FileLock lock = channel.tryLock(); if (lock != null) return lock; FileLock lock = channel.tryLock();
if (lock != null) return lock;
Thread.sleep(250); Thread.sleep(250);
} }
throw new IllegalArgumentException("Publication lock timed out"); throw new IllegalArgumentException("Publication lock timed out");
} }
String mainHead() throws Exception { String mainHead() throws Exception {
Map<String,Object> branch = Json.object(Setup.api(env.get("GITEA_SERVER_URL"), Map<String, Object> branch = Json.object(Setup.api(
env.get("REGISTRY_TOKEN"), "GET", "/api/v1/repos/" + env.get("CI_REPOSITORY") + "/branches/main", null)); env.get("GITEA_SERVER_URL"),
env.get("REGISTRY_TOKEN"),
"GET",
"/api/v1/repos/" + env.get("CI_REPOSITORY") + "/branches/main",
null));
return Objects.toString(Json.object(branch.get("commit")).get("id")); return Objects.toString(Json.object(branch.get("commit")).get("id"));
} }
void ensureImage(String repository, String source) throws Exception { void ensureImage(String repository, String source) throws Exception {
String ref = repository + ":" + commit; String ref = repository + ":" + commit;
var manifest = result(false,"docker", "manifest", "inspect", "--insecure", ref); var manifest = result(false, "docker", "manifest", "inspect", "--insecure", ref);
if (manifest.code() == 0) System.out.println("Preserving existing " + ref); if (manifest.code() == 0) System.out.println("Preserving existing " + ref);
else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) { else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) {
run("docker", "tag", source, ref); run("docker", "push", ref); run("docker", "tag", source, ref);
} else throw new IllegalArgumentException("Registry manifest lookup failed; authentication/network errors are not missing tags"); run("docker", "push", ref);
} else
throw new IllegalArgumentException(
"Registry manifest lookup failed; authentication/network errors are not missing tags");
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), ref); run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), ref);
check(commit.equals(capture("docker", "image", "inspect", "--format", "{{index .Config.Labels \"org.opencontainers.image.revision\"}}", ref)), check(
commit.equals(capture(
"docker",
"image",
"inspect",
"--format",
"{{index .Config.Labels \"org.opencontainers.image.revision\"}}",
ref)),
"Existing image has an unexpected source revision"); "Existing image has an unexpected source revision");
String digests = capture("docker", "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", ref); String digests =
capture("docker", "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", ref);
check(digests.contains(repository + "@sha256:"), "Missing registry digest"); check(digests.contains(repository + "@sha256:"), "Missing registry digest");
Files.writeString(artifacts.resolve(repository.endsWith("backend") ? "backend-digests.txt" : "frontend-digests.txt"), digests + "\n"); Files.writeString(
artifacts.resolve(repository.endsWith("backend") ? "backend-digests.txt" : "frontend-digests.txt"),
digests + "\n");
} }
synchronized void closeDockerConfig() throws IOException { synchronized void closeDockerConfig() throws IOException {
if (isolatedConfig != null) { deleteTree(isolatedConfig); isolatedConfig = null; } if (isolatedConfig != null) {
deleteTree(isolatedConfig);
isolatedConfig = null;
}
} }
static void copyTree(Path source, Path destination) throws IOException { static void copyTree(Path source, Path destination) throws IOException {
try (var paths = Files.walk(source)) { try (var paths = Files.walk(source)) {
for (Path path : paths.toList()) { for (Path path : paths.toList()) {
@@ -216,8 +427,11 @@ class Pipeline {
} }
} }
} }
static void deleteTree(Path root) throws IOException { static void deleteTree(Path root) throws IOException {
if (!Files.exists(root)) return; if (!Files.exists(root)) return;
try (var paths = Files.walk(root)) { for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); } try (var paths = Files.walk(root)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
}
} }
} }

View File

@@ -8,154 +8,280 @@ import java.time.Duration;
import java.util.*; import java.util.*;
class Setup { class Setup {
static Path home() { return Path.of(System.getProperty("user.home")); } static Path home() {
static String required(Map<String,String> env, String key) { return Path.of(System.getProperty("user.home"));
}
static String required(Map<String, String> env, String key) {
String value = env.get(key); String value = env.get(key);
if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key); if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key);
return value; return value;
} }
static String secret(Map<String,String> env, String key) throws IOException {
Path path = Path.of(required(env,key)); static String secret(Map<String, String> env, String key) throws IOException {
Path path = Path.of(required(env, key));
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path); Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path);
if (permissions.stream().anyMatch(p -> p.name().startsWith("GROUP_") || p.name().startsWith("OTHERS_"))) if (permissions.stream()
.anyMatch(p -> p.name().startsWith("GROUP_") || p.name().startsWith("OTHERS_")))
throw new IllegalArgumentException(key + " must refer to an owner-only file (mode 600)"); throw new IllegalArgumentException(key + " must refer to an owner-only file (mode 600)");
String token = Files.readString(path).strip(); String token = Files.readString(path).strip();
if (token.isEmpty()) throw new IllegalArgumentException(key + " is empty"); if (token.isEmpty()) throw new IllegalArgumentException(key + " is empty");
return token; return token;
} }
static class ApiFailure extends IOException { static class ApiFailure extends IOException {
final int status; final int status;
ApiFailure(int status) { super("Gitea HTTP " + status); this.status = status; }
ApiFailure(int status) {
super("Gitea HTTP " + status);
this.status = status;
}
} }
static Object api(String server, String token, String method, String path, Object body) throws Exception { static Object api(String server, String token, String method, String path, Object body) throws Exception {
Pipeline.check(token.matches("[a-zA-Z0-9_-]+"), "Invalid token format"); Pipeline.check(token.matches("[a-zA-Z0-9_-]+"), "Invalid token format");
HttpRequest request = HttpRequest.newBuilder(URI.create(server.replaceAll("/+$", "") + path)) HttpRequest request = HttpRequest.newBuilder(URI.create(server.replaceAll("/+$", "") + path))
.timeout(Duration.ofSeconds(30)).header("Authorization", "token " + token) .timeout(Duration.ofSeconds(30))
.header("Authorization", "token " + token)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(Json.write(body))) .method(
method,
body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(Json.write(body)))
.build(); .build();
try (HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build()) { try (HttpClient client =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build()) {
var response = client.send(request, HttpResponse.BodyHandlers.ofString()); var response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) throw new ApiFailure(response.statusCode()); if (response.statusCode() < 200 || response.statusCode() >= 300)
throw new ApiFailure(response.statusCode());
return response.body().isBlank() ? null : Json.parse(response.body()); return response.body().isBlank() ? null : Json.parse(response.body());
} }
} }
static void configureGitea(Map<String,String> env) throws Exception {
String admin = secret(env,"GITEA_ADMIN_TOKEN_FILE"), registry = secret(env,"REGISTRY_TOKEN_FILE"); static void configureGitea(Map<String, String> env) throws Exception {
String user = required(env,"REGISTRY_USER"), platform = required(env,"TARGET_PLATFORM"); String admin = secret(env, "GITEA_ADMIN_TOKEN_FILE"), registry = secret(env, "REGISTRY_TOKEN_FILE");
Pipeline.check(Set.of("linux/amd64","linux/arm64").contains(platform), "Set TARGET_PLATFORM from the verified Synology architecture"); String user = required(env, "REGISTRY_USER"), platform = required(env, "TARGET_PLATFORM");
Pipeline.check(
Set.of("linux/amd64", "linux/arm64").contains(platform),
"Set TARGET_PLATFORM from the verified Synology architecture");
String server = "http://192.168.2.28:3000", base = "/api/v1/repos/wanjakrah/agiledancers"; String server = "http://192.168.2.28:3000", base = "/api/v1/repos/wanjakrah/agiledancers";
api(server,admin,"PATCH",base,Map.of("has_actions",true)); api(server, admin, "PATCH", base, Map.of("has_actions", true));
api(server,admin,"PUT",base + "/actions/secrets/REGISTRY_USER",Map.of("data",user)); api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_USER", Map.of("data", user));
api(server,admin,"PUT",base + "/actions/secrets/REGISTRY_TOKEN",Map.of("data",registry)); api(server, admin, "PUT", base + "/actions/secrets/REGISTRY_TOKEN", Map.of("data", registry));
String variable = base + "/actions/variables/TARGET_PLATFORM"; String variable = base + "/actions/variables/TARGET_PLATFORM";
boolean exists; boolean exists;
try { api(server,admin,"GET",variable,null); exists = true; } try {
catch (ApiFailure e) { if (e.status != 404) throw e; exists = false; } api(server, admin, "GET", variable, null);
api(server,admin,exists ? "PUT" : "POST",variable,Map.of("value",platform)); exists = true;
Map<String,Object> response = Json.object(api(server,admin,"POST",base + "/actions/runners/registration-token",null)); } catch (ApiFailure e) {
if (e.status != 404) throw e;
exists = false;
}
api(server, admin, exists ? "PUT" : "POST", variable, Map.of("value", platform));
Map<String, Object> response =
Json.object(api(server, admin, "POST", base + "/actions/runners/registration-token", null));
Path directory = home().resolve(".config/agiledancers-runner"); Path directory = home().resolve(".config/agiledancers-runner");
privateDirectory(directory); privateDirectory(directory);
privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token"))); privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token")));
System.out.println("Actions, registry secrets and platform configured; runner token saved outside Git."); System.out.println("Actions, registry secrets and platform configured; runner token saved outside Git.");
} }
static void configureDocker(Path file) throws IOException { static void configureDocker(Path file) throws IOException {
Map<String,Object> data = Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>(); Map<String, Object> data =
Files.exists(file) ? Json.object(Json.parse(Files.readString(file))) : new LinkedHashMap<>();
Object entry = data.getOrDefault("insecure-registries", new ArrayList<>()); Object entry = data.getOrDefault("insecure-registries", new ArrayList<>());
if (!(entry instanceof List<?>)) throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged"); if (!(entry instanceof List<?>))
List<Object> registries = new ArrayList<>((List<?>)entry); throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
if (registries.contains("192.168.2.28:3000")) { System.out.println("Internal registry already configured; no change"); return; } List<Object> registries = new ArrayList<>((List<?>) entry);
registries.add("192.168.2.28:3000"); data.put("insecure-registries",registries); if (registries.contains("192.168.2.28:3000")) {
Path absolute = file.toAbsolutePath(), parent = absolute.getParent(); Files.createDirectories(parent); System.out.println("Internal registry already configured; no change");
return;
}
registries.add("192.168.2.28:3000");
data.put("insecure-registries", registries);
Path absolute = file.toAbsolutePath(), parent = absolute.getParent();
Files.createDirectories(parent);
if (Files.exists(file)) { if (Files.exists(file)) {
Path backup = file.resolveSibling(file.getFileName() + ".before-agiledancers"); Path backup = file.resolveSibling(file.getFileName() + ".before-agiledancers");
if (!Files.exists(backup)) Files.copy(file,backup,StandardCopyOption.COPY_ATTRIBUTES); if (!Files.exists(backup)) Files.copy(file, backup, StandardCopyOption.COPY_ATTRIBUTES);
} }
Path temp = Files.createTempFile(parent,"daemon-", ".json"); Path temp = Files.createTempFile(parent, "daemon-", ".json");
try { try {
Files.writeString(temp,Json.write(data) + "\n"); Files.writeString(temp, Json.write(data) + "\n");
if (Files.exists(file)) Files.setPosixFilePermissions(temp,Files.getPosixFilePermissions(file)); if (Files.exists(file)) Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(file));
Files.move(temp,absolute,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING); Files.move(temp, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} finally { Files.deleteIfExists(temp); } } finally {
Files.deleteIfExists(temp);
}
System.out.println("Internal registry added; restart/apply Docker Engine configuration to activate"); System.out.println("Internal registry added; restart/apply Docker Engine configuration to activate");
} }
static void installRunner(Path root, Map<String,String> env, Commands commands, boolean prepareOnly) throws Exception {
Pipeline.check(System.getProperty("os.name").equals("Linux") && Set.of("amd64","x86_64").contains(System.getProperty("os.arch")), static void installRunner(Path root, Map<String, String> env, Commands commands, boolean prepareOnly)
throws Exception {
Pipeline.check(
System.getProperty("os.name").equals("Linux")
&& Set.of("amd64", "x86_64").contains(System.getProperty("os.arch")),
"Installer targets the existing Linux x86_64 mini-PC"); "Installer targets the existing Linux x86_64 mini-PC");
Path runnerDir = home().resolve(".local/share/agiledancers-runner"), configDir = home().resolve(".config/agiledancers-runner"); Path runnerDir = home().resolve(".local/share/agiledancers-runner"),
configDir = home().resolve(".config/agiledancers-runner");
Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user"); Path bin = home().resolve(".local/bin"), units = home().resolve(".config/systemd/user");
for (Path path : List.of(runnerDir,configDir,bin,units)) privateDirectory(path); for (Path path : List.of(runnerDir, configDir, bin, units)) privateDirectory(path);
for (String tool : List.of("node","java","mvn","docker","git","systemctl")) executable(env,tool); for (String tool : List.of("node", "java", "mvn", "docker", "git", "systemctl")) executable(env, tool);
String javaHome = System.getProperty("java.home"); String javaHome = System.getProperty("java.home");
String version = commands.execute(List.of("node","--version"),root,env,null,false).checked(); String version = commands.execute(List.of("node", "--version"), root, env, null, false)
.checked();
String[] parts = version.replaceFirst("^v", "").split("\\."); String[] parts = version.replaceFirst("^v", "").split("\\.");
int major = Integer.parseInt(parts[0]), minor = Integer.parseInt(parts[1]), patch = Integer.parseInt(parts[2]); int major = Integer.parseInt(parts[0]), minor = Integer.parseInt(parts[1]), patch = Integer.parseInt(parts[2]);
Pipeline.check((major == 22 && (minor > 22 || minor == 22 && patch >= 3)) || (major == 24 && minor >= 15) || major >= 26, Pipeline.check(
(major == 22 && (minor > 22 || minor == 22 && patch >= 3))
|| (major == 24 && minor >= 15)
|| major >= 26,
"Supported Node version required"); "Supported Node version required");
Path runner = bin.resolve("act_runner"); Path runner = bin.resolve("act_runner");
if (!Files.isExecutable(runner)) { if (!Files.isExecutable(runner)) {
URI uri = URI.create("https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-linux-amd64"); URI uri = URI.create("https://dl.gitea.com/act_runner/0.2.13/act_runner-0.2.13-linux-amd64");
byte[] bytes; byte[] bytes;
try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build()) { try (HttpClient client = HttpClient.newBuilder()
var response = client.send(HttpRequest.newBuilder(uri).timeout(Duration.ofMinutes(2)).build(),HttpResponse.BodyHandlers.ofByteArray()); .followRedirects(HttpClient.Redirect.NORMAL)
Pipeline.check(response.statusCode() == 200, "Runner download failed"); bytes = response.body(); .build()) {
var response = client.send(
HttpRequest.newBuilder(uri)
.timeout(Duration.ofMinutes(2))
.build(),
HttpResponse.BodyHandlers.ofByteArray());
Pipeline.check(response.statusCode() == 200, "Runner download failed");
bytes = response.body();
} }
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); String hash = HexFormat.of()
Pipeline.check(hash.equals("3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2"),"Runner checksum mismatch"); .formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
Files.write(runner,bytes); Files.setPosixFilePermissions(runner,PosixFilePermissions.fromString("rwxr-xr-x")); Pipeline.check(
hash.equals("3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2"),
"Runner checksum mismatch");
Files.write(runner, bytes);
Files.setPosixFilePermissions(runner, PosixFilePermissions.fromString("rwxr-xr-x"));
} }
String actual = commands.execute(List.of(runner.toString(),"--version"),root,env,null,false).checked(); String actual = commands.execute(List.of(runner.toString(), "--version"), root, env, null, false)
.checked();
Pipeline.check(actual.contains("v0.2.13"), "Expected runner v0.2.13"); Pipeline.check(actual.contains("v0.2.13"), "Expected runner v0.2.13");
privateWrite(runnerDir.resolve("config.yaml"),Files.readString(root.resolve("ci/runner/config.yaml"))); privateWrite(runnerDir.resolve("config.yaml"), Files.readString(root.resolve("ci/runner/config.yaml")));
Files.copy(root.resolve("ci/runner/agiledancers-runner.service"),units.resolve("agiledancers-runner.service"),StandardCopyOption.REPLACE_EXISTING); Files.copy(
Files.copy(root.resolve("ci/runner/WaitForDocker.java"),runnerDir.resolve("WaitForDocker.java"),StandardCopyOption.REPLACE_EXISTING); root.resolve("ci/runner/agiledancers-runner.service"),
units.resolve("agiledancers-runner.service"),
StandardCopyOption.REPLACE_EXISTING);
Files.copy(
root.resolve("ci/runner/WaitForDocker.java"),
runnerDir.resolve("WaitForDocker.java"),
StandardCopyOption.REPLACE_EXISTING);
String dockerHost = env.get("DOCKER_HOST"); String dockerHost = env.get("DOCKER_HOST");
if (dockerHost == null || dockerHost.isBlank()) dockerHost = commands.execute(List.of("docker","context","inspect","--format","{{.Endpoints.docker.Host}}"),root,env,null,false).checked(); if (dockerHost == null || dockerHost.isBlank())
String path = javaHome + "/bin:" + executable(env,"node").getParent() + ":" + executable(env,"mvn").getParent() + ":/usr/local/bin:/usr/bin:/bin"; dockerHost = commands.execute(
privateWrite(configDir.resolve("environment"),"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost) + "\n"); List.of("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"),
root,
env,
null,
false)
.checked();
String path = javaHome + "/bin:" + executable(env, "node").getParent() + ":"
+ executable(env, "mvn").getParent() + ":/usr/local/bin:/usr/bin:/bin";
privateWrite(
configDir.resolve("environment"),
"JAVA_HOME=" + quoted(javaHome) + "\nPATH=" + quoted(path) + "\nDOCKER_HOST=" + quoted(dockerHost)
+ "\n");
boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock"); boolean desktop = dockerHost.endsWith("/.docker/desktop/docker.sock");
Path overrides = units.resolve("agiledancers-runner.service.d"); Path overrides = units.resolve("agiledancers-runner.service.d");
Path desktopOverride = overrides.resolve("10-agiledancers-docker-desktop.conf"); Path desktopOverride = overrides.resolve("10-agiledancers-docker-desktop.conf");
if (desktop) { if (desktop) {
Files.createDirectories(overrides); Files.createDirectories(overrides);
Files.copy(root.resolve("ci/runner/docker-desktop.conf"),desktopOverride,StandardCopyOption.REPLACE_EXISTING); Files.copy(
root.resolve("ci/runner/docker-desktop.conf"),
desktopOverride,
StandardCopyOption.REPLACE_EXISTING);
} else { } else {
Files.deleteIfExists(desktopOverride); Files.deleteIfExists(desktopOverride);
} }
commands.execute(List.of("systemctl","--user","daemon-reload"),root,env,null,true).checked(); commands.execute(List.of("systemctl", "--user", "daemon-reload"), root, env, null, true)
if (prepareOnly) { System.out.println("Runner installed; registration and startup still required."); return; } .checked();
commands.execute(List.of("docker","info"),root,env,null,false).checked(); if (prepareOnly) {
System.out.println("Runner installed; registration and startup still required.");
return;
}
commands.execute(List.of("docker", "info"), root, env, null, false).checked();
if (!Files.exists(runnerDir.resolve(".runner"))) { if (!Files.exists(runnerDir.resolve(".runner"))) {
Path tokenPath = Path.of(env.getOrDefault("RUNNER_REGISTRATION_TOKEN_FILE",configDir.resolve("registration-token").toString())); Path tokenPath = Path.of(env.getOrDefault(
Map<String,String> tokenEnv = Map.of("RUNNER_TOKEN_FILE",tokenPath.toString()); "RUNNER_REGISTRATION_TOKEN_FILE",
String token = secret(tokenEnv,"RUNNER_TOKEN_FILE"); configDir.resolve("registration-token").toString()));
commands.execute(List.of(runner.toString(),"register","--no-interactive","--instance","http://192.168.2.28:3000", Map<String, String> tokenEnv = Map.of("RUNNER_TOKEN_FILE", tokenPath.toString());
"--name","agiledancers-mini-pc","--labels","agiledancers-mini-pc:host","--token",token),runnerDir,env,null,true).checked(); String token = secret(tokenEnv, "RUNNER_TOKEN_FILE");
Files.setPosixFilePermissions(runnerDir.resolve(".runner"),PosixFilePermissions.fromString("rw-------")); commands.execute(
List.of(
runner.toString(),
"register",
"--no-interactive",
"--instance",
"http://192.168.2.28:3000",
"--name",
"agiledancers-mini-pc",
"--labels",
"agiledancers-mini-pc:host",
"--token",
token),
runnerDir,
env,
null,
true)
.checked();
Files.setPosixFilePermissions(runnerDir.resolve(".runner"), PosixFilePermissions.fromString("rw-------"));
} }
if (desktop) { if (desktop) {
commands.execute(List.of("systemctl","--user","enable","--now","docker-desktop.service"),root,env,null,true).checked(); commands.execute(
List.of("systemctl", "--user", "enable", "--now", "docker-desktop.service"),
root,
env,
null,
true)
.checked();
// Replace any previous default.target link with the desktop-login target. // Replace any previous default.target link with the desktop-login target.
commands.execute(List.of("systemctl","--user","disable","agiledancers-runner.service"),root,env,null,true).checked(); commands.execute(
List.of("systemctl", "--user", "disable", "agiledancers-runner.service"),
root,
env,
null,
true)
.checked();
} }
commands.execute(List.of("systemctl","--user","enable","--now","agiledancers-runner.service"),root,env,null,true).checked(); commands.execute(
List.of("systemctl", "--user", "enable", "--now", "agiledancers-runner.service"),
root,
env,
null,
true)
.checked();
System.out.println("Runner registered and started; keep the mini-PC and Docker available."); System.out.println("Runner registered and started; keep the mini-PC and Docker available.");
} }
static Path executable(Map<String,String> env, String name) {
static Path executable(Map<String, String> env, String name) {
for (String dir : env.getOrDefault("PATH", "").split(File.pathSeparator)) { for (String dir : env.getOrDefault("PATH", "").split(File.pathSeparator)) {
Path path = Path.of(dir,name); if (Files.isRegularFile(path) && Files.isExecutable(path)) return path.toAbsolutePath(); Path path = Path.of(dir, name);
if (Files.isRegularFile(path) && Files.isExecutable(path)) return path.toAbsolutePath();
} }
throw new IllegalArgumentException("Missing executable: " + name); throw new IllegalArgumentException("Missing executable: " + name);
} }
static String quoted(String value) { static String quoted(String value) {
Pipeline.check(!value.contains("\n") && !value.contains("\r"), "Invalid service environment value"); Pipeline.check(!value.contains("\n") && !value.contains("\r"), "Invalid service environment value");
return "\"" + value.replace("\\","\\\\").replace("\"","\\\"") + "\""; return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
} }
static void privateDirectory(Path path) throws IOException { static void privateDirectory(Path path) throws IOException {
Files.createDirectories(path); Files.setPosixFilePermissions(path,PosixFilePermissions.fromString("rwx------")); Files.createDirectories(path);
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rwx------"));
} }
static void privateWrite(Path path, String text) throws IOException { static void privateWrite(Path path, String text) throws IOException {
if (!Files.exists(path)) Files.createFile(path,PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); if (!Files.exists(path))
Files.setPosixFilePermissions(path,PosixFilePermissions.fromString("rw-------")); Files.writeString(path,text); Files.createFile(path, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-------"));
Files.writeString(path, text);
} }
} }

49
pom.xml
View File

@@ -24,6 +24,11 @@
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.platform.version>3.40.1</quarkus.platform.version> <quarkus.platform.version>3.40.1</quarkus.platform.version>
<format.mode>apply</format.mode>
<format.skip>false</format.skip>
<palantir-java-format.version>2.101.0</palantir-java-format.version>
<rewrite-core.version>8.63.4</rewrite-core.version>
<ortools-java.version>9.15.6755</ortools-java.version> <ortools-java.version>9.15.6755</ortools-java.version>
<lombok.version>1.18.48</lombok.version> <lombok.version>1.18.48</lombok.version>
@@ -77,6 +82,50 @@
<build> <build>
<plugins> <plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
<inherited>false</inherited>
<executions>
<execution>
<id>format</id>
<phase>validate</phase>
<goals><goal>exec</goal></goals>
</execution>
</executions>
<configuration>
<skip>${format.skip}</skip>
<executable>${java.home}/bin/java</executable>
<includeProjectDependencies>false</includeProjectDependencies>
<includePluginDependencies>true</includePluginDependencies>
<arguments>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</argument>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</argument>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</argument>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</argument>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</argument>
<argument>--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</argument>
<argument>--class-path</argument><classpath/>
<argument>${maven.multiModuleProjectDirectory}/tooling/format/Format.java</argument>
<argument>${maven.multiModuleProjectDirectory}</argument>
<argument>${format.mode}</argument>
</arguments>
</configuration>
<dependencies>
<dependency>
<groupId>com.palantir.javaformat</groupId>
<artifactId>palantir-java-format</artifactId>
<version>${palantir-java-format.version}</version>
</dependency>
<dependency>
<groupId>org.openrewrite</groupId>
<artifactId>rewrite-core</artifactId>
<version>${rewrite-core.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>

0
specs/S14_add-sonar Normal file
View File

View File

@@ -1 +1,43 @@
Create a file (or append to it) CODING_STANDARDS.md and have the coding standards in there, for the review skill. As developer
I want anyone, including agents to follow coding standards for the agiledancers-be folder
so that the code stays readable and maintainable.
The coding style should be desribed in CODING_STANDARDS.md. If the file does not exist, create it. If the file does exist, don't remove instructions for the front-end (Angular). This spec will ony do coding-standards for the backend (Java).
Make sure agents use these coding_standards as wel when coding or reviewing (with the code-review skill in the skills-folder).
For the guidelines we start with the guidelines described at https://www.geeksforgeeks.org/java/coding-guidelines-in-java/ as a basis.
When we have a collision with the Java Palentir formatter this project uses, we will always favour the Palentir formatter.
For curly braces, we use the variant: A curly brace is applied at the end of the line that starts the class, method, loop, etc.
Block comments will be enwrapped with /* and */. The actual text is on sererate lines and indented. Example:
/*
This is an example of a blocked comment.
It may have more than one line.
*/
if-, for-, and while-loops must always have curly braces.
No star-imports. All imports have to be explicit.
static imports are only allowed in test classes.
De volgende metrics moeten gehaald worden, met een streefwarde, een review-signal, en een absoluut maximum:
Metric | Streefwaarde | Reviewsignaal | Blokkerende grens
Cyclomatic complexity per methode/constructor | ≤ 5 | > 7 | > 10
Cognitive complexity per methode | ≤ 10 | > 10 | > 15
Methodlengte, zonder lege regels en commentaar | ≤ 20 regels | > 30 | > 50
Parameters per methode | ≤ 3 | > 4 | > 5
Parameters per constructor | ≤ 4 | > 5 | > 7
Geïnjecteerde dependencies per klasse | ≤ 4 | > 5 | > 7
Fan-out per klasse, unieke gebruikte types | ≤ 10 | > 15 | alleen review
Depth of Inheritance Tree, eigen superclasslagen, exclusief Object | ≤ 1 | > 2 | > 3
Nesting van control flow | ≤ 2 niveaus | > 3 | > 4
Duplicated lines density | ≤ 1% | > 1% | > 3%
Klasselengte, zonder lege regels en commentaar | ≤ 200 regels | > 300 | > 500
Publieke methoden per klasse, exclusief triviale accessors | ≤ 10 | > 15 | Geen automatische grens
De code-review skill wordt hier ook van op de hoogte gebracht.
De test-coverage moet minimaal 80% zijn. Dit mag gehaald worden met een combinatie van unit- en e2e-testen. unit- en e2e-testen mogen afzonderlijk onder de 80% blijven.
Indien mogelijk, wordt de ArchitectureTest geupdate of wordt er een nieuwe test gemaakt om zaken te controleren die met een test te controleren zijn. Verder mogen checks uitgevoerd worden met CheckStyle, FindBugs and PMD, plus eventuele andere checks die ze aanbevelen. Tot slot mogen er PIT-testen worden gemaakt om de code-coverage te checken.
Het toeveogen van een geconfigureerde Sonar in de pipeline is out-of-scope. Dit doen we later met spec S14.
The current code-base should be updated to adhere to these coding standards.

View File

@@ -0,0 +1,96 @@
# S8 implementation evidence
Measured on 2026-10-30 on Linux x86_64 (kernel 7.0.0-38-generic), AMD Ryzen 7
7730U, 16 logical CPUs; Ubuntu OpenJDK 25.0.4.1, Maven 3.9.16. These are local
wall-clock measurements, not universal performance guarantees.
## Integration and scope
- exec-maven-plugin 3.6.3; OpenRewrite core 8.63.4; Palantir Java Format 2.101.0.
- Custom `Format.PalantirRecipe` processes OpenRewrite PlainText source files
using Palantir's `formatSourceAndFixImports`, Palantir style, `formatJavadoc(false)`.
No Java type attribution, OpenRewrite Java parser or AutoFormat is involved.
- 73 maintained Java files: 65 backend production/test files, 7 CI/runner helpers,
and the formatter helper. Recursive discovery excludes generated build output.
- Root validate executes before compilation; compile/test/verify/install all reach
validate. CI explicitly passes check mode to clean verify. Standalone commands
run only the root exec execution and do not compile the backend.
- Bootstrap was first executed with neither Palantir nor OpenRewrite in the local
Maven repository. The helper is source-launched, with dependencies supplied by
Maven's plugin classpath. There is no project recipe artifact to preinstall.
The isolated regression fixture also has no compiled adapter or backend module.
## Correctness
- Full `mvn -B -ntp clean verify` passed after formatting: 33 backend tests,
zero failures/errors/skips. Both benchmark clean verifications also pass.
- `java ci/Ci.java test`: 17 passed, including assertion of the exact check-only
Maven invocation and that Maven failure removes the success marker and prevents
image build/publication.
- `python3 tooling/format/test.py` checks an isolated source-launcher checkout:
read-only check failure with path and apply command; native unused-import
removal; UTF-8 non-ASCII preservation; check/apply agreement; second uncached
check with no differences; malformed Java fails with its path and leaves the
source bytes unchanged; a newly introduced file cannot evade the content cache.
- All current Java files have been processed by the real engine on JDK 25,
including records, switch expressions/multiple labels, pattern instanceof,
lambdas and source-launcher CI classes. Tests compile/run the resulting sources.
This demonstrates the syntax used here, not every possible Java 25 construct.
- Palantir uses javac internals (explicit JVM exports are in the root POM) and its
modern AST visitor. Recheck formatter compatibility when upgrading the JDK.
Compact source files / instance main methods are a known upstream limitation
([upstream issue](https://github.com/palantir/palantir-java-format/issues/1602));
they are not used here. Unsupported future syntax fails, rather than being
silently excluded. Extra Markdown/Javadoc formatting is disabled.
- The initial Java rewrite is committed separately from integration changes;
its only transformations are the formatter's native output and import handling.
## Performance
`python3 tooling/format/benchmark.py` runs sequentially, with normal dependencies
and OS filesystem caches warm. Each configuration receives a compile warmup.
The baseline temporarily removes the entire exec plugin declaration from the
root POM, then restores its original bytes before the formatting build. Therefore
these comparisons include plugin loading, dependency resolution, JVM launch,
source-launcher compilation, hashing, cache IO and recipe execution. They do not
compare only the inner formatter function or a skipped plugin against itself.
The successful-content cache is warm for unchanged builds; it is part of the
integration. Changed/new files are processed by Palantir. Clean builds remove the
cache and process every maintained Java file. Each run's stdout and elapsed time
are saved under `target/`; the benchmark restores the root POM and changed helper.
| Repeated compile | Baseline (s) | Integration (s) | Added overhead (s) |
| --- | ---: | ---: | ---: |
| 1 | 4.887 | 6.414 | 1.527 |
| 2 | 4.821 | 5.964 | 1.143 |
| 3 | 4.559 | 6.024 | 1.465 |
Maximum observed added overhead: **1.527 seconds**, below the agreed three-second
limit. Baseline compile warmup: 5.307 s; integration warmup: 7.604 s.
The changed-file case appends a newline to `ci/runner/WaitForDocker.java`, then
runs `mvn -B -ntp compile`: **5.506 s**, successful; Palantir processes that
changed file. The script restores its original bytes afterward.
Clean baseline `mvn -B -ntp clean verify`: **24.797 s**. Clean integration
`mvn -B -ntp -Dformat.mode=check clean verify`: **27.747 s**, all 73 files
processed, no differences, all tests pass. The three-second criterion applies
to repeated unchanged builds, not the clean-build comparison.
### First-use downloads (separate measurement)
A disposable root POM with the same pinned plugin/dependencies and an empty
`-Dmaven.repo.local` replaces only the Java executable with `/usr/bin/true`.
Thus dependency resolution runs without compiling the launcher or executing the
formatter/application. Command:
`mvn -N -B -ntp -Dmaven.repo.local=<empty-temp-repository> org.codehaus.mojo:exec-maven-plugin:3.6.3:exec@format`.
Cold resolution took **9.041 s**; the same command with that repository populated
took **1.826 s**. The **7.215 s** cold-versus-warm difference represents first-use
network/repository population overhead in this environment, separately from the
warm-cache build measurements above. Cold timing also includes root POM/BOM and
plugin bootstrap resolution; it is not reported as pure formatter execution.
Reproduce with `python3 tooling/format/benchmark-downloads.py` (Linux, network
access). The initial real source-launcher bootstrap/check also successfully
processed all files; its expected failure listed formatting differences, rather
than a missing project recipe or parser/bootstrap error.

View File

@@ -1,6 +1,6 @@
# S8 — Palantir formatting for Java # S8 — Palantir formatting for Java
Status: REFINED Status: IMPLEMENTED
Agreed after the grill-me review; shared understanding confirmed. Agreed after the grill-me review; shared understanding confirmed.
@@ -163,30 +163,30 @@ style or silently take precedence over the Maven result.
## Acceptance criteria ## Acceptance criteria
- [ ] The actual Palantir engine formats all maintained backend Java sources, - [x] The actual Palantir engine formats all maintained backend Java sources,
Java tests and `ci/*.java`; generated code is excluded. Java tests and `ci/*.java`; generated code is excluded.
- [ ] OpenRewrite integration, custom adapter/recipe and bootstrap are implemented - [x] OpenRewrite integration, custom adapter/recipe and bootstrap are implemented
and reproducible from a clean checkout without manual recipe installation. and reproducible from a clean checkout without manual recipe installation.
- [ ] Plugin, formatter and adapter dependencies are pinned, with matching local - [x] Plugin, formatter and adapter dependencies are pinned, with matching local
and CI configuration. and CI configuration.
- [ ] Local Maven builds apply formatting before compilation; `compile`, `test`, - [x] Local Maven builds apply formatting before compilation; `compile`, `test`,
`verify` and `install` reach the intended integration. `verify` and `install` reach the intended integration.
- [ ] Standalone apply and check commands work without the full application build. - [x] Standalone apply and check commands work without the full application build.
- [ ] CI checks only, leaves maintained sources unchanged, reports formatting - [x] CI checks only, leaves maintained sources unchanged, reports formatting
differences and fails before image publication. differences and fails before image publication.
- [ ] Import ordering and unused-import removal follow Palantir; optional extra - [x] Import ordering and unused-import removal follow Palantir; optional extra
Javadoc reformatting remains disabled and no unrelated refactoring is added. Javadoc reformatting remains disabled and no unrelated refactoring is added.
- [ ] All currently used Java 25 syntax is handled, or causes an explicit failure - [x] All currently used Java 25 syntax is handled, or causes an explicit failure
requiring resolution rather than silent exclusion. requiring resolution rather than silent exclusion.
- [ ] Apply/check results agree and a second apply produces no differences. - [x] Apply/check results agree and a second apply produces no differences.
- [ ] Initial formatting is isolated in a formatting-only commit; application - [x] Initial formatting is isolated in a formatting-only commit; application
builds and tests remain green. builds and tests remain green.
- [ ] Equivalent repeated unchanged-build measurements demonstrate at most three - [x] Equivalent repeated unchanged-build measurements demonstrate at most three
seconds of additional integration overhead. Changed-file and clean-build seconds of additional integration overhead. Changed-file and clean-build
measurements and one-time downloads are reported separately. measurements and one-time downloads are reported separately.
- [ ] Failure to meet the performance bound is reported before changing the route - [x] Failure to meet the performance bound is reported before changing the route
or bound; a writing formatter is not killed to force compliance. or bound; a writing formatter is not killed to force compliance.
- [ ] README instructions cover local and CI commands, bootstrap, optional IDE - [x] README instructions cover local and CI commands, bootstrap, optional IDE
use, authoritative Maven formatting and verification evidence. use, authoritative Maven formatting and verification evidence.
## Implementation handoff ## Implementation handoff
@@ -196,3 +196,5 @@ create an adapter, change Maven/CI behavior or reformat source files. Recipe loa
Java 25 compatibility and performance must be demonstrated during implementation; Java 25 compatibility and performance must be demonstrated during implementation;
there is no pre-existing project benchmark establishing the preferred route's there is no pre-existing project benchmark establishing the preferred route's
performance. performance.
Implementation and measured acceptance evidence: [S8-formatting-evidence.md](../S8-formatting-evidence.md).

View File

@@ -1,6 +1,6 @@
# S9 — Schedule dance groups across show dayparts with OR-Tools # S9 — Schedule dance groups across show dayparts with OR-Tools
Status: IN_REVIEW Status: IMPLEMENTED
Agreed after the grill-me review; shared understanding confirmed. Agreed after the grill-me review; shared understanding confirmed.

131
tooling/format/Format.java Normal file
View File

@@ -0,0 +1,131 @@
import com.palantir.javaformat.java.Formatter;
import com.palantir.javaformat.java.JavaFormatterOptions;
import java.nio.file.*;
import java.security.MessageDigest;
import java.util.*;
import org.openrewrite.*;
import org.openrewrite.internal.InMemoryLargeSourceSet;
import org.openrewrite.text.*;
/** Source-launcher bootstrap: no project-specific artifact is resolved or installed. */
public class Format {
public static void main(String[] args) throws Exception {
Path root = Path.of(args[0]).toAbsolutePath().normalize();
boolean check = switch (args[1]) {
case "check" -> true;
case "apply" -> false;
default -> throw new IllegalArgumentException("format.mode must be apply or check");
};
Path cacheFile = root.resolve("target/palantir-cache.properties");
var cache = new Properties();
if (Files.exists(cacheFile)) {
try (var input = Files.newInputStream(cacheFile)) {
cache.load(input);
}
}
String configuration = digest(Files.readString(root.resolve("pom.xml"))
+ Files.readString(root.resolve("tooling/format/Format.java"))
+ System.getProperty("java.version")
+ System.getProperty("java.class.path"));
if (!configuration.equals(cache.getProperty("configuration"))) cache.clear();
cache.setProperty("configuration", configuration);
List<SourceFile> sources = new ArrayList<>();
for (String directory :
List.of("agiledancers-be/src/main/java", "agiledancers-be/src/test/java", "ci", "tooling/format")) {
try (var paths = Files.walk(root.resolve(directory))) {
for (Path path : paths.filter(p -> p.toString().endsWith(".java"))
.sorted()
.toList()) {
try {
String content = Files.readString(path);
String key = root.relativize(path).toString();
if (!digest(content).equals(cache.getProperty(key))) {
sources.add(PlainText.builder()
.sourcePath(root.relativize(path))
.text(content)
.build());
}
} catch (Exception error) {
throw new IllegalStateException(path + ": " + error.getMessage(), error);
}
}
}
}
var context = new InMemoryExecutionContext(error -> {
throw new IllegalStateException("Formatting failed: " + error.getMessage(), error);
});
var results = new PalantirRecipe()
.run(new InMemoryLargeSourceSet(sources), context)
.getChangeset()
.getAllResults();
for (Result result : results) {
System.out.println((check ? "Needs formatting: " : "Formatted: ")
+ result.getAfter().getSourcePath());
}
if (check && !results.isEmpty()) {
throw new IllegalStateException("Run mvn -N exec:exec@format to apply Palantir formatting.");
}
// Complete parsing/formatting before writing any maintained file. Each replacement is atomic.
if (!check) {
for (Result result : results) {
Path destination = root.resolve(result.getAfter().getSourcePath());
Path temporary = Files.createTempFile(destination.getParent(), ".palantir-", ".tmp");
try {
Files.writeString(temporary, result.getAfter().printAll());
Files.setPosixFilePermissions(temporary, Files.getPosixFilePermissions(destination));
Files.move(
temporary,
destination,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(temporary);
}
}
}
for (SourceFile source : sources) {
cache.setProperty(
source.getSourcePath().toString(), digest(Files.readString(root.resolve(source.getSourcePath()))));
}
Files.createDirectories(cacheFile.getParent());
try (var output = Files.newOutputStream(cacheFile)) {
cache.store(output, "Successfully formatted content only");
}
System.out.println(
"Palantir " + args[1] + ": " + sources.size() + " Java files, " + results.size() + " differences");
}
static String digest(String value) throws Exception {
return HexFormat.of()
.formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
}
public static final class PalantirRecipe extends Recipe {
public String getDisplayName() {
return "Palantir Java Format";
}
public String getDescription() {
return "Apply the pinned Palantir engine and its native import handling.";
}
public TreeVisitor<?, ExecutionContext> getVisitor() {
return new PlainTextVisitor<ExecutionContext>() {
final Formatter formatter = Formatter.createFormatter(JavaFormatterOptions.builder()
.style(JavaFormatterOptions.Style.PALANTIR)
.formatJavadoc(false)
.build());
@Override
public PlainText visitText(PlainText text, ExecutionContext context) {
try {
return text.withText(formatter.formatSourceAndFixImports(text.getText()));
} catch (Exception | LinkageError error) {
throw new IllegalStateException(text.getSourcePath() + ": " + error, error);
}
}
};
}
}
}

View File

@@ -0,0 +1,31 @@
"""Measure dependency resolution separately; run from the root with network access.
A disposable copy replaces the Java executable with /usr/bin/true, keeping the
pinned exec-plugin dependencies. No formatter/application code executes. The
second invocation uses the populated repository; neither invocation touches the
normal Maven cache. Logs and measurements are written under target/.
"""
from pathlib import Path
import json
import shutil
import subprocess
import tempfile
import time
root = Path(__file__).resolve().parents[2]
(root / "target").mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="s8-resolution-") as temporary:
fixture = Path(temporary)
shutil.copytree(root / ".mvn", fixture / ".mvn")
pom = (root / "pom.xml").read_text().replace("${java.home}/bin/java", "/usr/bin/true")
(fixture / "pom.xml").write_text(pom)
command = ["mvn", "-N", "-B", "-ntp", "-Dmaven.repo.local=" + str(fixture / "repository"),
"org.codehaus.mojo:exec-maven-plugin:3.6.3:exec@format"]
for label in ("cold-resolution", "warm-resolution"):
start = time.monotonic()
result = subprocess.run(command, cwd=fixture, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
elapsed = time.monotonic() - start
(root / "target" / ("s8-" + label + ".log")).write_text(result.stdout)
print(json.dumps(dict(label=label, seconds=round(elapsed, 3), exit=result.returncode)), flush=True)
if result.returncode:
raise RuntimeError(result.stdout)

View File

@@ -0,0 +1,49 @@
"""Run sequential, warm-cache end-to-end Maven comparisons from the repository root."""
from pathlib import Path
import json
import subprocess
import time
root = Path(__file__).resolve().parents[2]
results = []
pom = root / "pom.xml"
original_pom = pom.read_bytes()
pom_text = original_pom.decode()
start = pom_text.rfind(" <plugin>", 0, pom_text.index("<artifactId>exec-maven-plugin</artifactId>"))
end = pom_text.index(" </plugin>", start) + len(" </plugin>\n")
baseline_pom = (pom_text[:start] + pom_text[end:]).encode()
def run(label, *arguments):
command = ["mvn", "-B", "-ntp", *arguments]
start = time.monotonic()
baseline = "baseline" in label
try:
if baseline:
pom.write_bytes(baseline_pom)
result = subprocess.run(command, cwd=root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
finally:
pom.write_bytes(original_pom)
elapsed = time.monotonic() - start
(root / "target").mkdir(exist_ok=True)
(root / "target" / ("s8-" + label + ".log")).write_text(result.stdout)
row = dict(label=label, command=command, seconds=round(elapsed, 3), exit=result.returncode)
results.append(row)
print(json.dumps(row), flush=True)
if result.returncode:
raise RuntimeError(result.stdout)
run("warmup", "compile")
run("baseline-warmup", "compile")
for number in range(3):
run("baseline-" + str(number), "compile")
run("formatted-" + str(number), "compile")
source = root / "ci/runner/WaitForDocker.java"
original = source.read_bytes()
try:
source.write_bytes(original + b"\n")
run("changed-file", "compile")
finally:
source.write_bytes(original)
run("clean-baseline", "clean", "verify")
run("clean-formatted", "-Dformat.mode=check", "clean", "verify")
(root / "target/s8-benchmark.json").write_text(json.dumps(results, indent=2) + "\n")

53
tooling/format/test.py Normal file
View File

@@ -0,0 +1,53 @@
"""Integration checks against an isolated checkout; run python3 tooling/format/test.py."""
from pathlib import Path
import shutil
import subprocess
import tempfile
root = Path(__file__).resolve().parents[2]
with tempfile.TemporaryDirectory(prefix="palantir-test-") as temporary:
fixture = Path(temporary)
for name in ("pom.xml", ".mvn", "tooling/format"):
source = root / name
destination = fixture / name
if source.is_dir():
shutil.copytree(source, destination)
else:
shutil.copy2(source, destination)
for name in ("agiledancers-be/src/main/java", "agiledancers-be/src/test/java", "ci/runner"):
(fixture / name).mkdir(parents=True)
source = fixture / "ci/runner/Example.java"
original = "import java.util.List;\nclass Example{String text=\"héllo\";int value(){return 1;}}\n"
source.write_text(original)
def run(mode, success=True):
result = subprocess.run(
["mvn", "-N", "-B", "-ntp", "exec:exec@format", "-Dformat.mode=" + mode],
cwd=fixture, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
assert (result.returncode == 0) == success, result.stdout
return result.stdout
output = run("check", False)
assert "ci/runner/Example.java" in output and "mvn -N exec:exec@format" in output
assert source.read_text() == original
run("apply")
formatted = source.read_text()
assert "import java.util.List" not in formatted and "héllo" in formatted
run("check")
run("apply")
assert source.read_text() == formatted
# Force a full (uncached) second pass to prove engine idempotence.
(fixture / "target/palantir-cache.properties").unlink()
run("check")
source.write_text(formatted + "class Broken { ??? }\n")
invalid = source.read_bytes()
output = run("apply", False)
assert "ci/runner/Example.java" in output
assert source.read_bytes() == invalid
# A new file is discovered even after a successful cached check.
source.write_text(formatted)
extra = fixture / "ci/New.java"
extra.write_text("class New{}")
run("check", False)
assert extra.read_text() == "class New{}"
print("PASS: check is read-only, apply/check agree, imports, UTF-8, idempotence, parser errors, new files")