2 Commits

Author SHA1 Message Date
7e8b3a2cc7 S8 apply Palantir formatting to maintained Java sources
All checks were successful
Build, test and publish / verify (push) Successful in 1m39s
2026-10-30 15:50:51 +01:00
327a365550 S8 integrate pinned Palantir formatting through OpenRewrite 2026-10-30 15:50:51 +01:00
63 changed files with 2387 additions and 725 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,
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
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;
import lombok.*;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import lombok.*;
@Getter
@Setter
@@ -19,9 +18,7 @@ public abstract class AbstractEntity {
}
private <T> Set<T> emptyIfNull(Set<T> set) {
return set == null ?
new HashSet<>() :
set;
return set == null ? new HashSet<>() : set;
}
protected <T> Set<T> unmodifiableNullSafe(Set<T> set) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,16 +24,18 @@ public class Ci {
case "attempt" -> {
Path file = Path.of(Setup.required(env, "GITHUB_ENV"));
String id = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSSSSSSSS")
.withZone(ZoneOffset.UTC).format(Instant.now());
Files.writeString(file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
.withZone(ZoneOffset.UTC)
.format(Instant.now());
Files.writeString(
file, "CI_RUN_ATTEMPT=" + id + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
case "configure-gitea" -> Setup.configureGitea(env);
case "configure-docker" -> {
if (args.length != 2) throw new IllegalArgumentException("Supply the daemon.json path");
Setup.configureDocker(Path.of(args[1]));
}
case "install-runner" -> Setup.installRunner(root, env, commands,
args.length == 2 && args[1].equals("--prepare-only"));
case "install-runner" ->
Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
case "test" -> CiTests.run();
default -> throw new IllegalArgumentException(usage());
}
@@ -42,13 +44,16 @@ public class Ci {
System.exit(e.exitCode);
} catch (Exception e) {
// Never dump HTTP bodies, process arguments, environment or tokens.
System.err.println(e instanceof IllegalArgumentException || e instanceof Setup.ApiFailure ? e.getMessage()
: "CI failed (" + e.getClass().getSimpleName() + "). Check diagnostics and configuration.");
System.err.println(
e instanceof IllegalArgumentException || e instanceof Setup.ApiFailure
? e.getMessage()
: "CI failed (" + e.getClass().getSimpleName() + "). Check diagnostics and configuration.");
System.exit(1);
}
}
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 {
static final String SHA = "a".repeat(40);
static int count;
interface Test { void run() throws Exception; }
interface Test {
void run() 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) {
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 {
try { action.run(); } catch (Exception expected) { return; }
try {
action.run();
} catch (Exception expected) {
return;
}
throw new AssertionError("Expected failure");
}
static void run() throws Exception {
count = 0;
test("JSON preserves configuration values and escaped text", () -> {
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)));
equal("line\nquote\"€",Json.object(data).get("text"));
Object data = Json.parse(json);
equal(data, Json.parse(Json.write(data)));
equal("line\nquote\"€", Json.object(data).get("text"));
});
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));
});
test("Docker configuration merge is idempotent and preserves backup", () -> {
@@ -33,149 +48,288 @@ class CiTests {
try {
Path file = dir.resolve("daemon.json");
String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}";
Files.writeString(file,original);
Setup.configureDocker(file); String first = Files.readString(file); Setup.configureDocker(file);
equal(first,Files.readString(file));
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); }
Files.writeString(file, original);
Setup.configureDocker(file);
String first = Files.readString(file);
Setup.configureDocker(file);
equal(first, Files.readString(file));
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", () -> {
Path dir = Files.createTempDirectory("ci-docker-test-");
try {
Path file = dir.resolve("daemon.json"); String original = "{\"insecure-registries\":\"invalid\"}";
Files.writeString(file,original); failure(() -> Setup.configureDocker(file)); equal(original,Files.readString(file));
} finally { Pipeline.deleteTree(dir); }
Path file = dir.resolve("daemon.json");
String original = "{\"insecure-registries\":\"invalid\"}";
Files.writeString(file, original);
failure(() -> Setup.configureDocker(file));
equal(original, Files.readString(file));
} finally {
Pipeline.deleteTree(dir);
}
});
test("Feature branches never contact registry", () -> {
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", () -> {
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", () -> {
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", () -> {
try (Fixture f = new Fixture()) {
f.fake.pushFailure = "frontend:" + SHA; failure(f.pipeline::publish);
equal(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());
f.fake.pushFailure = "frontend:" + SHA;
failure(f.pipeline::publish);
equal(
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", () -> {
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", () -> {
try (Fixture f = new Fixture()) {
f.fake.pullFailure = "backend:" + SHA; failure(f.pipeline::publish);
equal(0L,f.fake.calls.stream().filter(a -> a.contains("push") && a.getLast().endsWith(":main")).count());
f.fake.pullFailure = "backend:" + SHA;
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", () -> {
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", () -> {
try (Fixture f = new Fixture()) {
f.fake.dockerAvailable = false;
try { f.pipeline.verify(); 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());
try {
f.pipeline.verify();
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", () -> {
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", () -> {
try (Fixture f = new Fixture()) {
f.fake.mavenFailure = true;
try { f.pipeline.verify(); throw new AssertionError("Expected failure"); }
catch (Commands.Failure error) { equal(42,error.exitCode); }
equal(false,Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
equal(0L,f.calls("build")); equal(0L,f.calls("push"));
try {
f.pipeline.verify();
throw new AssertionError("Expected failure");
} 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", () -> {
try (Fixture f = new Fixture()) {
f.fake.lintFailure = true;
try { f.pipeline.verifyFrontend(); throw new AssertionError("Expected failure"); }
catch (Commands.Failure error) { equal(1,error.exitCode); }
try {
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);
}
});
test("Frontend verification checks before testing and building", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.verifyFrontend();
equal(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());
equal(
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", () -> {
Path dir = Files.createTempDirectory("ci-process-test-");
try {
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));
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");
} catch (Commands.Failure error) { equal(124,error.exitCode); }
} finally { Pipeline.deleteTree(dir); }
} catch (Commands.Failure error) {
equal(124, error.exitCode);
}
} finally {
Pipeline.deleteTree(dir);
}
});
System.out.println(count + " CI tests passed");
}
static class Fixture implements AutoCloseable {
final Path root = Files.createTempDirectory("ci-publication-test-");
final FakeCommands fake = new FakeCommands();
final Pipeline pipeline;
Fixture() throws Exception {
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",
"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");
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",
"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 {
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 = "";
boolean manifestError, mavenFailure, lintFailure;
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));
if (args.getFirst().equals("git")) return new Result(0,SHA);
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.getFirst().equals("docker")) return new Result(0,"");
if (args.getFirst().equals("git")) return new Result(0, SHA);
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.getFirst().equals("docker")) return new Result(0, "");
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("context")) return new Result(0,"unix:///tmp/test-docker.sock");
if (command.equals("info"))
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 (manifestError) return new Result(1,"unauthorized");
return images.containsKey(ref) ? new Result(0,"{}") : new Result(1,"manifest unknown");
if (manifestError) return new Result(1, "unauthorized");
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 (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1,"push failed");
images.put(ref,tags.getOrDefault(ref,SHA));
if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1, "push failed");
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("image")) return new Result(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,"");
if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure))
return new Result(1, "pull failed");
if (command.equals("image"))
return new Result(
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();
}
}
static class Failure extends RuntimeException {
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;
volatile Process active;
Path log;
void renew(Duration timeout) { deadline = Instant.now().plus(timeout); }
void cancel() { Process process = active; if (process != null) stop(process); }
Commands(Duration timeout) { deadline = Instant.now().plus(timeout); }
void renew(Duration 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 {
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().putAll(env);
Process process = builder.start();
@@ -36,22 +53,30 @@ class Commands {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<?> reader = executor.submit(() -> {
try (InputStream stream = process.getInputStream();
OutputStream file = destination == null ? OutputStream.nullOutputStream()
: Files.newOutputStream(destination, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
OutputStream file = destination == null
? OutputStream.nullOutputStream()
: Files.newOutputStream(
destination, StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
byte[] buffer = new byte[8192];
int count;
while ((count = stream.read(buffer)) != -1) {
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);
}
} catch (IOException e) { throw new UncheckedIOException(e); }
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
try {
try (OutputStream stdin = process.getOutputStream()) {
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);
throw new Failure(124, "CI time limit exceeded");
}

View File

@@ -5,93 +5,145 @@ import java.util.*;
final class Json {
static Object parse(String 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();
return result;
}
@SuppressWarnings("unchecked")
static Map<String, Object> object(Object value) {
if (!(value instanceof Map<?, ?>)) throw new IllegalArgumentException("Expected a JSON object");
return (Map<String, Object>) value;
}
static String write(Object value) {
if (value == null) return "null";
if (value instanceof String text) {
StringBuilder out = new StringBuilder("\"");
for (char c : text.toCharArray()) {
switch (c) {
case '"' -> out.append("\\\""); case '\\' -> out.append("\\\\");
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); }
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
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();
}
if (value instanceof Boolean || value instanceof BigDecimal || value instanceof Integer || value instanceof Long)
return value.toString();
if (value instanceof Boolean
|| value instanceof BigDecimal
|| value instanceof Integer
|| value instanceof Long) return value.toString();
if (value instanceof Map<?, ?> map) {
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) {
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");
}
private static final class Parser {
final String source; int pos;
Parser(String source) { 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; }
final String source;
int pos;
Parser(String source) {
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() {
space(); if (pos >= source.length()) throw invalid();
space();
if (pos >= source.length()) throw invalid();
char c = source.charAt(pos);
if (c == '"') return string();
if (take('{')) {
Map<String,Object> out = new LinkedHashMap<>();
Map<String, Object> out = new LinkedHashMap<>();
if (take('}')) return out;
do {
space(); if (pos >= source.length() || source.charAt(pos) != '"') throw invalid();
String key = string(); if (!take(':') || out.containsKey(key)) throw invalid();
space();
if (pos >= source.length() || source.charAt(pos) != '"') throw invalid();
String key = string();
if (!take(':') || out.containsKey(key)) throw invalid();
out.put(key, value());
} while (take(','));
if (!take('}')) throw invalid(); return out;
if (!take('}')) throw invalid();
return out;
}
if (take('[')) {
List<Object> out = new ArrayList<>(); if (take(']')) return out;
do { out.add(value()); } while (take(','));
if (!take(']')) throw invalid(); return out;
List<Object> out = new ArrayList<>();
if (take(']')) return out;
do {
out.add(value());
} while (take(','));
if (!take(']')) throw invalid();
return out;
}
for (String token : List.of("true", "false", "null")) {
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;
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();
return new BigDecimal(number);
}
String string() {
pos++; StringBuilder out = new StringBuilder();
pos++;
StringBuilder out = new StringBuilder();
while (pos < source.length()) {
char c = source.charAt(pos++);
if (c == '"') return out.toString();
if (c < 32) throw invalid();
if (c != '\\') { out.append(c); continue; }
if (c != '\\') {
out.append(c);
continue;
}
if (pos >= source.length()) throw invalid();
char escaped = source.charAt(pos++);
switch (escaped) {
case '"', '\\', '/' -> out.append(escaped);
case 'b' -> out.append('\b'); case 'f' -> out.append('\f');
case 'n' -> out.append('\n'); case 'r' -> out.append('\r'); case 't' -> out.append('\t');
case 'b' -> out.append('\b');
case 'f' -> out.append('\f');
case 'n' -> out.append('\n');
case 'r' -> out.append('\r');
case 't' -> out.append('\t');
case 'u' -> {
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();
out.append((char) Integer.parseInt(digits,16)); pos += 4;
out.append((char) Integer.parseInt(digits, 16));
pos += 4;
}
default -> throw invalid();
}

View File

@@ -7,12 +7,12 @@ import java.util.*;
/** Verification, publication and cleanup shared by local use and Gitea Actions. */
class Pipeline {
final Path root, artifacts;
final Map<String,String> env;
final Map<String, String> env;
final Commands commands;
final String commit, runName, backendImage, frontendImage;
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.env = new HashMap<>(environment);
this.commands = commands;
@@ -23,190 +23,401 @@ class Pipeline {
runName = "agiledancers-" + id + "-" + attempt;
backendImage = "agiledancers-ci-backend:" + 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 {
if (env.getOrDefault(key, "").isBlank()) env.put(key, defaultValue.get());
return env.get(key);
}
static void check(boolean condition, String message) {
if (!condition) throw new IllegalArgumentException(message);
}
Commands.Result result(boolean echo, String... args) throws Exception {
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 {
String target = Setup.required(env,"TARGET_PLATFORM");
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)");
String target = Setup.required(env, "TARGET_PLATFORM");
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)");
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;
check(target.equals(nativePlatform), "Target " + target + " differs from Docker host " + nativePlatform);
}
void isolateDocker() throws Exception {
value("DOCKER_HOST", () -> capture("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"));
env.remove("DOCKER_CONTEXT");
isolatedConfig = Files.createTempDirectory("agiledancers-docker-");
env.put("DOCKER_CONFIG", isolatedConfig.toString());
}
void verify() throws Exception {
Files.deleteIfExists(artifacts.resolve("verified-commit"));
env.put("CI", "true");
commands.log = artifacts.resolve("verify.log"); Files.writeString(commands.log, "");
Thread shutdown = new Thread(() -> {
commands.cancel();
try { cleanup(); closeDockerConfig(); } catch (Exception e) { System.err.println("CI shutdown cleanup failed"); }
}, "ci-cleanup");
commands.log = artifacts.resolve("verify.log");
Files.writeString(commands.log, "");
Thread shutdown = new Thread(
() -> {
commands.cancel();
try {
cleanup();
closeDockerConfig();
} catch (Exception e) {
System.err.println("CI shutdown cleanup failed");
}
},
"ci-cleanup");
Runtime.getRuntime().addShutdownHook(shutdown);
Exception failure = null;
try {
requirePlatform(); isolateDocker();
requirePlatform();
isolateDocker();
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();
Path frontend = root.resolve("agiledancers-fe");
verifyFrontend();
String platform = env.get("TARGET_PLATFORM");
run("docker", "build", "--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");
run(
"docker",
"build",
"--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"), "");
run("docker", "network", "create", "--label", "agiledancers.ci.run=" + runName, runName);
run("docker", "run", "-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);
run(
"docker",
"run",
"-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();
String user = capture("id", "-u") + ":" + capture("id", "-g");
run("docker", "run", "--name", 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");
run(
"docker",
"run",
"--name",
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");
} catch (Exception e) { failure = e; throw e; }
finally {
try { cleanup(); } catch (Exception e) { if (failure != null) failure.addSuppressed(e); else throw e; }
finally {
} catch (Exception e) {
failure = e;
throw e;
} finally {
try {
cleanup();
} catch (Exception e) {
if (failure != null) failure.addSuppressed(e);
else throw e;
} finally {
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 {
Path frontend = root.resolve("agiledancers-fe");
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", "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();
commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true)
.checked();
commands.execute(
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 {
for (int i = 0; i < 60; i++) {
if (result(false,"docker", "exec", runName + "-frontend", "wget", "-q", "-O", "-",
"http://127.0.0.1:8080/api/dance-schools").code() == 0) return;
if (result(
false,
"docker",
"exec",
runName + "-frontend",
"wget",
"-q",
"-O",
"-",
"http://127.0.0.1:8080/api/dance-schools")
.code()
== 0) return;
Thread.sleep(2000);
}
throw new IllegalArgumentException("Application readiness timed out");
}
synchronized void cleanup() throws Exception {
// A separate cleanup budget remains available after the build deadline.
commands.renew(Duration.ofMinutes(2));
List<String> failures = new ArrayList<>();
for (String path : List.of("agiledancers-be/target/surefire-reports", "agiledancers-be/target/failsafe-reports",
"agiledancers-fe/unit-results", "agiledancers-fe/test-results", "agiledancers-fe/playwright-report")) {
for (String path : List.of(
"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))) {
try { copyTree(root.resolve(path), artifacts.resolve(path)); } catch (IOException e) { failures.add(path); }
}
}
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");
try {
copyTree(root.resolve(path), artifacts.resolve(path));
} catch (IOException e) {
failures.add(path);
}
}
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"));
} 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));
}
void publish() throws Exception {
if (!"refs/heads/main".equals(env.get("CI_REF"))) { System.out.println("Branch is not main; no publication"); return; }
check(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");
if (!"refs/heads/main".equals(env.get("CI_REF"))) {
System.out.println("Branch is not main; no publication");
return;
}
check(
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();
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);
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");
String registry = uri.getRawAuthority();
String owner = repository.split("/")[0].toLowerCase(Locale.ROOT);
check(owner.matches("[a-z0-9][a-z0-9_.-]*"), "Invalid registry namespace");
Path lockDir = Path.of(env.getOrDefault("CI_PUBLISH_LOCK_DIR",
env.getOrDefault("XDG_STATE_HOME", Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci"));
Path lockDir = Path.of(env.getOrDefault(
"CI_PUBLISH_LOCK_DIR",
env.getOrDefault(
"XDG_STATE_HOME",
Setup.home().resolve(".local/state").toString()) + "/agiledancers-ci"));
Files.createDirectories(lockDir);
// Shared host lock: Gitea 1.25 ignores workflow concurrency.
try (FileChannel channel = FileChannel.open(lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock lock = awaitLock(channel)) {
try (FileChannel channel = FileChannel.open(
lockDir.resolve("publish.lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
FileLock lock = awaitLock(channel)) {
isolateDocker();
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-";
ensureImage(prefix + "backend", backendImage);
ensureImage(prefix + "frontend", frontendImage);
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;
run("docker", "tag", image + ":" + commit, image + ":main");
run("docker", "push", image + ":main");
run("docker", "pull", "--platform", env.get("TARGET_PLATFORM"), image + ":main");
}
} finally { closeDockerConfig(); }
} finally {
closeDockerConfig();
}
}
}
FileLock awaitLock(FileChannel channel) throws Exception {
Instant end = Instant.now().plusSeconds(600);
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);
}
throw new IllegalArgumentException("Publication lock timed out");
}
String mainHead() throws Exception {
Map<String,Object> branch = Json.object(Setup.api(env.get("GITEA_SERVER_URL"),
env.get("REGISTRY_TOKEN"), "GET", "/api/v1/repos/" + env.get("CI_REPOSITORY") + "/branches/main", null));
Map<String, Object> branch = Json.object(Setup.api(
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"));
}
void ensureImage(String repository, String source) throws Exception {
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);
else if (manifest.output().toLowerCase(Locale.ROOT).matches("(?s).*(manifest unknown|no such manifest).*")) {
run("docker", "tag", source, ref); run("docker", "push", ref);
} else throw new IllegalArgumentException("Registry manifest lookup failed; authentication/network errors are not missing tags");
run("docker", "tag", source, ref);
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);
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");
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");
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 {
if (isolatedConfig != null) { deleteTree(isolatedConfig); isolatedConfig = null; }
if (isolatedConfig != null) {
deleteTree(isolatedConfig);
isolatedConfig = null;
}
}
static void copyTree(Path source, Path destination) throws IOException {
try (var paths = Files.walk(source)) {
for (Path path : paths.toList()) {
@@ -216,8 +427,11 @@ class Pipeline {
}
}
}
static void deleteTree(Path root) throws IOException {
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.*;
class Setup {
static Path home() { return Path.of(System.getProperty("user.home")); }
static String required(Map<String,String> env, String key) {
static Path home() {
return Path.of(System.getProperty("user.home"));
}
static String required(Map<String, String> env, String key) {
String value = env.get(key);
if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key);
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);
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)");
String token = Files.readString(path).strip();
if (token.isEmpty()) throw new IllegalArgumentException(key + " is empty");
return token;
}
static class ApiFailure extends IOException {
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 {
Pipeline.check(token.matches("[a-zA-Z0-9_-]+"), "Invalid token format");
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")
.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();
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());
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());
}
}
static void configureGitea(Map<String,String> env) throws Exception {
String admin = secret(env,"GITEA_ADMIN_TOKEN_FILE"), registry = secret(env,"REGISTRY_TOKEN_FILE");
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");
static void configureGitea(Map<String, String> env) throws Exception {
String admin = secret(env, "GITEA_ADMIN_TOKEN_FILE"), registry = secret(env, "REGISTRY_TOKEN_FILE");
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";
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_TOKEN",Map.of("data",registry));
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_TOKEN", Map.of("data", registry));
String variable = base + "/actions/variables/TARGET_PLATFORM";
boolean exists;
try { api(server,admin,"GET",variable,null); exists = true; }
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));
try {
api(server, admin, "GET", variable, null);
exists = true;
} 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");
privateDirectory(directory);
privateWrite(directory.resolve("registration-token"), Objects.toString(response.get("token")));
System.out.println("Actions, registry secrets and platform configured; runner token saved outside Git.");
}
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<>());
if (!(entry instanceof List<?>)) throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
List<Object> registries = new ArrayList<>((List<?>)entry);
if (registries.contains("192.168.2.28:3000")) { 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 (!(entry instanceof List<?>))
throw new IllegalArgumentException("Existing insecure-registries must be an array; unchanged");
List<Object> registries = new ArrayList<>((List<?>) entry);
if (registries.contains("192.168.2.28:3000")) {
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)) {
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 {
Files.writeString(temp,Json.write(data) + "\n");
if (Files.exists(file)) Files.setPosixFilePermissions(temp,Files.getPosixFilePermissions(file));
Files.move(temp,absolute,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);
} finally { Files.deleteIfExists(temp); }
Files.writeString(temp, Json.write(data) + "\n");
if (Files.exists(file)) Files.setPosixFilePermissions(temp, Files.getPosixFilePermissions(file));
Files.move(temp, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(temp);
}
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");
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");
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 (Path path : List.of(runnerDir, configDir, bin, units)) privateDirectory(path);
for (String tool : List.of("node", "java", "mvn", "docker", "git", "systemctl")) executable(env, tool);
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("\\.");
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");
Path runner = bin.resolve("act_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");
byte[] bytes;
try (HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).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();
try (HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.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));
Pipeline.check(hash.equals("3acac8b506ac8cadc88a55155b5d6378f0fab0b8f62d1e0c0450f4ccd69733e2"),"Runner checksum mismatch");
Files.write(runner,bytes); Files.setPosixFilePermissions(runner,PosixFilePermissions.fromString("rwxr-xr-x"));
String hash = HexFormat.of()
.formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
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");
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(root.resolve("ci/runner/WaitForDocker.java"),runnerDir.resolve("WaitForDocker.java"),StandardCopyOption.REPLACE_EXISTING);
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(
root.resolve("ci/runner/WaitForDocker.java"),
runnerDir.resolve("WaitForDocker.java"),
StandardCopyOption.REPLACE_EXISTING);
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();
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");
if (dockerHost == null || dockerHost.isBlank())
dockerHost = commands.execute(
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");
Path overrides = units.resolve("agiledancers-runner.service.d");
Path desktopOverride = overrides.resolve("10-agiledancers-docker-desktop.conf");
if (desktop) {
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 {
Files.deleteIfExists(desktopOverride);
}
commands.execute(List.of("systemctl","--user","daemon-reload"),root,env,null,true).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();
commands.execute(List.of("systemctl", "--user", "daemon-reload"), root, env, null, true)
.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"))) {
Path tokenPath = Path.of(env.getOrDefault("RUNNER_REGISTRATION_TOKEN_FILE",configDir.resolve("registration-token").toString()));
Map<String,String> tokenEnv = Map.of("RUNNER_TOKEN_FILE",tokenPath.toString());
String token = secret(tokenEnv,"RUNNER_TOKEN_FILE");
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-------"));
Path tokenPath = Path.of(env.getOrDefault(
"RUNNER_REGISTRATION_TOKEN_FILE",
configDir.resolve("registration-token").toString()));
Map<String, String> tokenEnv = Map.of("RUNNER_TOKEN_FILE", tokenPath.toString());
String token = secret(tokenEnv, "RUNNER_TOKEN_FILE");
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) {
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.
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.");
}
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)) {
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);
}
static String quoted(String 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 {
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 {
if (!Files.exists(path)) Files.createFile(path,PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Files.setPosixFilePermissions(path,PosixFilePermissions.fromString("rw-------")); Files.writeString(path,text);
if (!Files.exists(path))
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>
<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>
<lombok.version>1.18.48</lombok.version>
@@ -77,6 +82,50 @@
<build>
<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>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>

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
Status: REFINED
Status: IN_REVIEW
Agreed after the grill-me review; shared understanding confirmed.
@@ -163,30 +163,30 @@ style or silently take precedence over the Maven result.
## 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.
- [ ] 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.
- [ ] Plugin, formatter and adapter dependencies are pinned, with matching local
- [x] Plugin, formatter and adapter dependencies are pinned, with matching local
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.
- [ ] Standalone apply and check commands work without the full application build.
- [ ] CI checks only, leaves maintained sources unchanged, reports formatting
- [x] Standalone apply and check commands work without the full application build.
- [x] CI checks only, leaves maintained sources unchanged, reports formatting
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.
- [ ] 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.
- [ ] Apply/check results agree and a second apply produces no differences.
- [ ] Initial formatting is isolated in a formatting-only commit; application
- [x] Apply/check results agree and a second apply produces no differences.
- [x] Initial formatting is isolated in a formatting-only commit; application
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
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.
- [ ] 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.
## 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;
there is no pre-existing project benchmark establishing the preferred route's
performance.
Implementation and measured acceptance evidence: [S8-formatting-evidence.md](../S8-formatting-evidence.md).

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")