14 Commits

Author SHA1 Message Date
b0d6592761 Merge pull request 'S6: manual versioned Synology deployment' (#12) from feature/S6_deployment into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m6s
Reviewed-on: #12
2026-11-03 14:51:13 +01:00
60496de8bb Record successful S6 feature build validation [skip ci] 2026-11-03 14:48:59 +01:00
525e9f9335 Implement manual versioned Synology deployments for S6
All checks were successful
Build, test and publish / verify (push) Successful in 6m34s
2026-11-03 14:16:52 +01:00
c9db528756 refinement of spec S6
All checks were successful
Build, test and publish / verify (push) Successful in 5m49s
2026-11-03 13:30:47 +01:00
b1544357bf Merge pull request 'ortools-documentation' (#11) from ortools-documentation into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m9s
Reviewed-on: #11
2026-11-03 13:09:09 +01:00
3a5203eb47 some renaming of variables
All checks were successful
Build, test and publish / verify (push) Successful in 5m55s
2026-11-03 12:58:15 +01:00
a7f7730ea0 S18 Added OR Tools rules for scheduling show
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s
2026-11-03 11:03:47 +01:00
c965f9b41d Merge pull request 'refinement2' (#10) from refinement2 into main
All checks were successful
Build, test and publish / verify (push) Successful in 4m45s
Reviewed-on: #10
2026-11-01 21:29:04 +01:00
8fc430537b refinement S16 - database storage
All checks were successful
Build, test and publish / verify (push) Successful in 5m26s
2026-11-01 21:28:42 +01:00
fc307de64e backlog management
All checks were successful
Build, test and publish / verify (push) Successful in 5m38s
2026-11-01 20:51:27 +01:00
f281200f47 Merge pull request 'feature/S7-backend-coding-standards' (#9) from feature/S7-backend-coding-standards into main
Some checks failed
Build, test and publish / verify (push) Failing after 5m15s
Reviewed-on: #9
2026-11-01 20:40:45 +01:00
ff6a0ae0a9 review finished
All checks were successful
Build, test and publish / verify (push) Successful in 5m40s
2026-11-01 17:08:46 +01:00
aae5a97200 S7 review findings
Some checks failed
Build, test and publish / verify (push) Failing after 36s
2026-10-30 23:39:14 +01:00
f910f1e535 updated coding standards
All checks were successful
Build, test and publish / verify (push) Successful in 4m43s
2026-10-30 19:58:03 +01:00
122 changed files with 7695 additions and 892 deletions

View File

@@ -1,7 +1,6 @@
**/target
.git
.idea
.mvn
*.iml
**/node_modules
@@ -16,3 +15,9 @@ agiledancers-be/target/*
!agiledancers-be/target/quarkus-app
**/unit-results
# CI sources are build inputs; runner credentials and local state are not.
ci/runner/.env
ci/runner/.runner
ci/runner/data
ci/runner/local

View File

@@ -0,0 +1,68 @@
name: Deploy production
on:
workflow_dispatch:
inputs:
mode:
description: 'Release mode — deployment can erase data until S16 is implemented'
required: true
type: choice
default: new
options:
- new
- retry
increment:
description: 'New release only: semantic version increment (first release is v1.0.0)'
required: true
type: choice
default: patch
options:
- patch
- minor
- major
version:
description: 'Retry only: existing version tag, e.g. v1.2.3; leave empty for new release'
required: false
type: string
# Gitea 1.25 ignores concurrency. The helper acquires a NAS-wide mkdir lock.
jobs:
deploy:
if: gitea.ref == 'refs/heads/main'
runs-on: agiledancers-mini-pc
env:
CI: 'true'
GITEA_SERVER_URL: ${{ gitea.server_url }}
CI_REPOSITORY: ${{ gitea.repository }}
CI_COMMIT: ${{ gitea.sha }}
CI_REF: ${{ gitea.ref }}
CI_EVENT: ${{ gitea.event_name }}
CI_RUN_ID: ${{ gitea.run_id }}
steps:
- name: Check out trusted main workflow revision
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Identify workflow attempt
run: java ci/Ci.java attempt
- name: Release verified images — data can be lost until S16
env:
RELEASE_MODE: ${{ inputs.mode }}
RELEASE_INCREMENT: ${{ inputs.increment }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }}
DEPLOY_DIRECTORY: ${{ vars.DEPLOY_DIRECTORY }}
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: java ci/Ci.java deploy
- name: Retain deployment diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
with:
name: deployment-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
path: ci/artifacts/deployment/
if-no-files-found: warn
retention-days: 14

View File

@@ -0,0 +1,42 @@
name: Rollback production
on:
workflow_dispatch:
jobs:
rollback:
if: gitea.ref == 'refs/heads/main'
runs-on: agiledancers-mini-pc
env:
CI: 'true'
GITEA_SERVER_URL: ${{ gitea.server_url }}
CI_REPOSITORY: ${{ gitea.repository }}
CI_COMMIT: ${{ gitea.sha }}
CI_REF: ${{ gitea.ref }}
CI_EVENT: ${{ gitea.event_name }}
CI_RUN_ID: ${{ gitea.run_id }}
steps:
- name: Check out trusted main workflow revision
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Identify workflow attempt
run: java ci/Ci.java attempt
- name: Restore previous successful release — data can be lost until S16
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT }}
DEPLOY_DIRECTORY: ${{ vars.DEPLOY_DIRECTORY }}
DEPLOY_REGISTRY: ${{ vars.DEPLOY_REGISTRY }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: java ci/Ci.java rollback
- name: Retain deployment diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
with:
name: rollback-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
path: ci/artifacts/deployment/
if-no-files-found: warn
retention-days: 14

View File

@@ -12,3 +12,11 @@
`grilling` automatically to ordinary development requests.
- Keep the upstream skill files unchanged. Source revision and license are
recorded in `skills/upstream.json` and `skills/LICENSE.mattpocock`.
# Backend changes and reviews
- Before changing backend Java, read `CODING_STANDARDS.md`.
- For reviews, use `skills/code-review/SKILL.md` together with
`docs/backend-review.md`. Keep upstream skills unchanged.
- Obtain maintainer agreement before adding any standards waiver; record its
narrow scope and reason alongside the affected code or configuration.

255
CODING_STANDARDS.md Normal file
View File

@@ -0,0 +1,255 @@
# Backend coding standards
This document is the authoritative policy for handwritten production and test Java
in `agiledancers-be`. Generated code and Java helpers outside that module are out
of scope. Existing frontend conventions in README.md and agiledancers-fe remain in force.
## Scope and authority
- Cover handwritten production and test Java in `agiledancers-be`. Exclude
generated code. Dependency injection and production architecture rules apply
where relevant. Frontend and Java helpers outside the backend are out of scope.
- [Java coding guidelines](https://www.geeksforgeeks.org/java/coding-guidelines-in-java/)
supplied source material. Only the rules adopted in this local document apply;
changes to the article do not change project policy.
- The project's pinned Palantir formatter wins formatting conflicts, including
comment layout. S8 remains responsible for formatter integration.
- Do not adopt unrelated static-analysis rule bundles.
## Java conventions
- Types use UpperCamelCase; methods and variables use lowerCamelCase; constants
use UPPER_SNAKE_CASE; package names are lowercase.
- One top-level type per file; explicit member visibility; `@Override` on
overriding methods. Do not invent visibility modifiers where Java forbids them.
- Opening braces stay on the declaration/control-statement line. All `if`, `for`
and `while` bodies require braces, including enhanced for and do/while bodies.
- No wildcard imports. Static imports are allowed only in test classes.
- Comments explain intent or constraints. Document public contracts with Javadoc
where their meaning is not clear from the declaration; do not require boilerplate
documentation for every accessor.
- Block comments use the following layout, subject to Palantir formatting:
```java
/*
This is an example of a block comment.
It may have more than one line.
*/
```
- Prefer records for immutable data carriers. Classes are allowed when mutation,
framework requirements or inheritance make records unsuitable. Preserve API and
serialization behavior. Record immutability is shallow: protect mutable
components appropriately.
- Require `final` for parameters, local variables and fields wherever they can
remain assigned once, including loop variables and catch parameters where Java
permits it. Field finality must remain compatible with framework injection and
lifecycle requirements. Permit reassignment when the algorithm needs it; do not
introduce artificial holder objects to avoid reassignment. This rule does not
require final classes or methods.
- Use explicit types unless the initializer visibly identifies the type. `var` is
allowed with `new School(...)` or an explicit cast, but not for method results,
literals or other inferred expressions. For example,
`final var school = new School(...)` is allowed, while a repository result uses
`final School school = repository.find(id)`. Borderline cases require review.
- No additional member-ordering or line-width policy beyond Palantir.
## Metrics
Targets guide development. Review signals require assessment, not automatic
failure. Blocking limits fail verification. Strict inequalities matter: a value
exactly at a blocking limit passes.
| Metric | Target | Review signal | Blocking limit |
| --- | ---: | ---: | ---: |
| Cyclomatic complexity per method/constructor | ≤ 5 | > 7 | > 10 |
| Cognitive complexity per method/constructor | ≤ 10 | > 10 | > 15 |
| Method/constructor length, code lines | ≤ 20 | > 30 | > 50 |
| Parameters per method | ≤ 3 | > 4 | > 5 |
| Parameters per explicitly written constructor | ≤ 4 | > 5 | > 7 |
| Injected dependencies per class | ≤ 4 | > 5 | > 7 |
| Fan-out per class, unique used types | ≤ 10 | > 15 | Review only |
| Own superclass depth, excluding Object | ≤ 1 | > 2 | > 3 |
| Control-flow nesting | ≤ 2 | > 3 | > 4 |
| Production duplicated lines density | ≤ 1% | > 1% | > 3% |
| Class length, code lines | ≤ 200 | > 300 | > 500 |
| Declared public methods per class, excluding trivial accessors | ≤ 10 | > 15 | Review only |
### Measurement definitions
- Use the pinned PMD algorithms as authoritative for cyclomatic and cognitive
complexity. Document their exact configuration, including switch and boolean
treatment. Do not accidentally introduce a class-level aggregate complexity gate.
- Measure each method, constructor and explicitly written record constructor
independently. Lambda bodies contribute to their enclosing executable;
methods in nested/local classes are separate executables.
- Parameter limits exclude implicit record constructors and generated members.
Record component counts remain a design review concern.
- Length means physical source lines after Palantir formatting, excluding blank
and comment-only lines. Declaration and brace-only lines count; a line containing
both code and comments counts. Do not substitute statement counts or NCSS.
Measure nested types separately and exclude them from enclosing class length.
- Count distinct injected collaborators by resolved type and qualifier. Include
constructor, field, setter, inherited and implicit constructor injection.
Repeated injection of the same type and qualifier counts once; differently
qualified instances of the same type count separately.
- Fan-out counts distinct explicitly referenced resolved types, including JDK and
framework types. Exclude primitives, the class itself and generated references.
- Inheritance depth counts project-owned superclass layers; exclude Object,
interfaces and external framework ancestry.
- Nesting combines `if`, loops, `switch` and `catch`, with the first structure at
depth one. An `else if` chain stays at one level. `else`, `try` and `finally`
add no level themselves. Lambdas retain surrounding depth; methods in nested
classes start fresh.
- Public-method counts include explicitly declared public instance and static
methods; exclude constructors, inherited methods and trivial accessors. A
trivial accessor only reads or assigns its corresponding field, with no
validation, branching or other work.
- Duplication limits apply only to production code. Duplication in tests is allowed
and has no target, review signal or blocking limit.
- Detect production clones with CPD, minimum 100 tokens, without ignoring
identifier or literal differences. Density is
`100 × distinct duplicated code-line locations / all production code-line locations`.
Exclude blank/comment-only lines from numerator and denominator. Count every
occurrence, including the first; merge overlapping ranges to count each source
line once. This project metric need not equal a future Sonar metric.
## Enforcement and developer workflow
- Use explicitly pinned Java-25-compatible Checkstyle and PMD/CPD versions.
Retain ArchUnit for architecture checks and add small targeted custom checks
where standard rules cannot accurately express the agreed semantics.
- Automate every blocking metric and explicit rule where reliable checking is
available. Identify rules requiring manual review explicitly; do not claim
they are automated. Review signals must appear in reports even when builds pass.
- Run standards checks during `mvn verify`; provide documented standalone commands.
Ordinary compile/test commands retain the current S8 formatting behavior.
- Standards checks report violations without rewriting code. CI passes these
checks before image publication. Parser failures or unsupported Java constructs
fail with the affected path and actionable diagnostics, rather than being skipped.
- Add a root AGENTS.md instruction requiring agents to read CODING_STANDARDS.md
before changing backend Java. Project review instructions must explicitly cover
manual rules, review signals in changed code and approved waivers, without
repeating checks already enforced by tooling.
- Keep upstream skill files unchanged. Put project-specific code-review guidance
in a companion file referenced by project instructions and used alongside
`skills/code-review/SKILL.md`.
- Waivers must be explicit, narrowly scoped and carry a recorded reason alongside
code or configuration. Agents need maintainer agreement before introducing one.
No blanket suppressions, exclusions of difficult files or weakened thresholds
merely to make verification pass.
## Coverage
- Require at least 80% backend line coverage across handwritten production code
for the module as a whole, not separately per class or package. Compare the
unrounded percentage against 80%.
- Local `mvn verify` must reach 80% using backend unit/integration tests alone.
This intentionally tightens the original combined-tests-only requirement.
- CI additionally instruments the backend exercised by Playwright, merges that
coverage with backend unit/integration coverage and gates the combined result.
The Playwright contribution need not separately reach 80%.
- Exclude generated code only; do not broadly exclude DTOs, configuration or
difficult code. Publish line and branch coverage reports; branch coverage has
no agreed blocking percentage.
- PIT measures mutation-test effectiveness, not line coverage, and is outside
S7's required implementation. Configuring Sonar in the pipeline remains out of
scope for S14. FindBugs/SpotBugs is not an additional required check suite.
## Implemented checks and manual review
Checkstyle **12.3.1**, PMD/CPD **7.28.0**, and JaCoCo **0.8.14** are pinned.
`tooling/standards/checkstyle.xml` selects individual naming, structure, import and brace checks; no general analysis bundle is enabled.
`tooling/standards/src/main/java/Standards.java` supplies the targeted measurements.
ArchUnit remains in the backend test suite for architectural boundaries.
PMD uses Java 25, the running JDK's `jrt-fs.jar`, compiled backend classes and the
resolved test dependency classpath. Cyclomatic complexity uses PMD's default
options: boolean paths count, assertions do not. Decisions include if/loops,
catch, ternary and throw; non-default switch alternatives count individually,
including comma-separated labels. There is no aggregate class-complexity gate.
Cognitive complexity uses PMD's boolean-operation sequences, nesting, switch,
recursion and flow rules. The scoped visitors retain those algorithms while
including lambdas in their enclosing executable and excluding nested/local type
bodies, whose methods are measured independently. Compact record constructors
use the same visitors and start with cyclomatic complexity one.
Custom checks measure physical code lines from regular parser tokens, including
brace/declaration lines and mixed code/comment lines. They count multiline literal
lines while excluding physically blank lines. Class length excludes nested-type
tokens, preserving lines shared with enclosing code. Other custom measurements
implement the definitions above: nesting, resolved type fan-out, project-owned
superclass depth, public non-accessor methods, and injected collaborators.
Injection includes inherited fields/setters, explicit `@Inject` constructors,
single constructors on bean classes, records' implicit canonical constructors,
and Lombok required/all-argument constructor injection. Qualifier keys use resolved
annotation names and values, including defaults; repeat sites are deduplicated.
The CPD configuration preserves identifiers/literals, uses 100 tokens and disables
skip blocks. Its line-location union counts overlapping occurrences only once.
Checkstyle and targeted checks enforce import/brace/naming/visibility rules,
`@Override`, visible constructor/cast/new-array `var` initializer forms, final parameters, locals (including
loops/catches and lambdas) and initialized fields. PMD's three selected finality
rules additionally cover control-flow assignment and private constructor-initialized
fields. Framework/lifecycle requirements, generated Lombok setters, and external
writes to public/protected fields need review before changing field finality.
No tool can infer whether a comment explains intent, whether a public contract
needs Javadoc, whether a record is appropriate, or whether mutable components are
sufficiently protected. These are **manual rules**, as are ambiguous type inference
and architecture/DI design decisions. Review all `DESIGN` record-component entries
and `REVIEW` signals, even when a build passes, using `docs/backend-review.md`.
Reports never rewrite Java. Formatter application remains S8's separate validate
execution. Generated output under `target` and generated source directories are
outside the handwritten `src/main/java` and `src/test/java` scope. No maintained
file, DTO, configuration class or package is excluded from standards or coverage.
JaCoCo filters compiler/Lombok-generated members; no handwritten-code exclusions
are configured. Line and branch results are published, with only lines gated.
Exact metric waivers live in `tooling/standards/waivers.tsv`; each records a metric,
fully resolved executable signature (or class), exact retained value and the
maintainer agreement/reason. No wildcard waivers are allowed, and a changed value
fails again. A waived blocking result remains visible as `WAIVER`. PMD suppression
annotations/comments cannot silently waive a rule. The only approved waiver is the eight-parameter `Group` canonical constructor,
which preserves its JSON contract while defensively copying both lists. Adding a waiver always requires maintainer agreement.
## Commands and reports
Run from the root with JDK 25:
```sh
mvn -Dformat.mode=check clean verify
# Prepare standalone analysis without running tests:
mvn -pl agiledancers-be test-compile dependency:build-classpath@standards-classpath
mvn -f tooling/standards/pom.xml test exec:java
# Exact local coverage gate after a test/report build:
java tooling/coverage/Coverage.java agiledancers-be/target/site/jacoco/jacoco.xml
```
`verify` runs backend tests, builds resolved analysis inputs, generates JaCoCo
reports, runs checker regression tests and standards analysis, then gates exact
module-wide line coverage. `compile` and `test` retain existing formatting behavior
and do not run the verify-only standards checks. Standalone analysis requires its
classpath preparation; unresolved types fail with the path and preparation advice.
Full metrics and findings: `agiledancers-be/target/standards/report.txt`.
Checker regression results: `tooling/standards/target/surefire-reports`.
Coverage HTML/XML/CSV: `agiledancers-be/target/site/jacoco` (local backend tests)
and `agiledancers-be/target/site/jacoco-combined` (merged browser contribution).
The gate compares integer line counts (`5 * covered >= 4 * total`), rejecting an
empty report and percentages that would round up to 80%.
CI first passes local verify. It mounts the pinned JaCoCo agent into its disposable
backend test container, exposes the agent only on the isolated Docker network,
and uses the pinned CLI to dump `target/playwright.exec` after Playwright. A missing
or empty dump fails. The following commands merge both datasets and gate the result
before the verified-commit marker can authorize publication:
```sh
mvn -pl agiledancers-be jacoco:merge@combined jacoco:report@combined-report exec:exec@combined-coverage
```
CI retains both coverage reports, execution data, standards findings and regression
results with its usual diagnostics artifacts. The agent is mounted only for tests;
published images contain no agent. PIT and Sonar configuration remain outside S7.
See `specs/S7-validation.md` for migration evidence and retained-signal assessment.

View File

@@ -1,6 +1,9 @@
FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /workspace
COPY pom.xml ./
COPY .mvn .mvn
COPY tooling tooling
COPY ci ci
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
COPY agiledancers-be/src agiledancers-be/src
RUN mvn -B -ntp clean verify

View File

@@ -154,23 +154,35 @@ dancers; deleting a school also deletes its dancers, groups and shows.
Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
with unique date/type combinations. The displayed date range comes from these
dayparts. Each show stores `appearances` for every group type. Omitting counts
initializes defaults on creation or the initial daypart configuration; later
edits preserve saved counts and reject counts outside 1..N. With no dayparts,
shows remain unscheduled and saved counts are retained.
dayparts. Each show stores `preferredDayParts`, defaulting to two or one for
one daypart. Ordinary groups, including PRE_EDUCATION, perform in exactly preferredDayParts
dayparts; preschool groups once; showcases every daypart. See
[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints,
coach exceptions, ordering and optimization rules.
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` uses OR-Tools to
assign every current school group to its required number of distinct dayparts,
minimizing the busiest/quietest appearance gap. It replaces assignments only on
a valid, current result. The response distinguishes optimal balance from a valid
result whose optimality is unproven. Concurrent runs return `409`; changed inputs
also reject the result with `409`. Failed runs preserve existing assignments.
Scheduling runs synchronously with a 30-minute solver limit; development and
production proxies allow 31 minutes. The UI warns before edits that may invalidate
existing schedules and offers explicit scheduling again afterward.
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` generates an ordered
draft using OR-Tools with a total thirty-minute time limit. A feasible result is
accepted without claiming optimality. Concurrent runs and stale results return
`409`; failures retain existing assignments. Development and production proxies
allow 31 minutes.
School JSON exports use format version **2**, including dated dayparts, assignments,
and all appearance counts. Version 1 files are intentionally unsupported.
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;
unknown IDs and malformed lists are rejected. `POST .../shows/{showId}/finalize`
checks every hard rule before marking a schedule final. Show responses include
`preferredDayParts`, `finalized` and `violations`. Relevant edits invalidate final
status. The UI supports ordering and moving performances, and displays violations.
`GET .../coaches` lists coach registrations. `POST .../coaches` with
`{"dancerId":1}` registers a dancer as a coach. `PUT .../coaches/{coachId}` uses
the same body to link an existing coach. `DELETE .../coaches/{coachId}/dancer`
removes the identity link. The dancers screen supports maintaining these links.
School JSON exports use format version **3**, including preferredDayParts, schedule status and
coach-to-dancer links. Version 2 remains importable; its schedules migrate to
drafts with default preferredDayParts. Version 1 is unsupported. Legacy `appearances` fields are
retained for file compatibility; S18 scheduling uses preferredDayParts and fixed type exceptions.
The REST `appearances` input is deprecated; use `preferredDayParts`.
Run the HTTP integration tests with:
@@ -254,6 +266,11 @@ Only successful `main` runs publish images to the Synology registry. See
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
for the current operational status.
Production releases and rollback use separate manual Gitea workflows. See the
[deployment runbook](docs/production-deployment.md) for Synology provisioning,
semantic version tags, release/retry/rollback and the accepted data-loss limitation
until S16, and [S6 evidence](docs/s6-validation.md) for deployment readiness.
### Java formatting (S8)
Maven is the authoritative formatting path. The root build runs the actual
@@ -308,3 +325,12 @@ 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/`.
## Backend coding standards (S7)
Read [CODING_STANDARDS.md](CODING_STANDARDS.md) before changing backend Java.
It defines the agreed rules, exact metric limits, manual review duties, pinned
analysis tools, coverage policy and standalone commands. `mvn verify` runs standards
checks and requires at least 80% module-wide backend line coverage. CI also collects
and merges coverage from Playwright before publication. Reports are retained with
CI diagnostics. See [S7 validation](specs/S7-validation.md) for current migration status.

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
package nl.agilecc.agiledancers.model;
import lombok.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -9,4 +14,7 @@ import lombok.*;
public class Coach extends AbstractEntity {
@ToString.Include
private final String name;
@Setter
private Dancer dancer;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,12 @@ package nl.agilecc.agiledancers.model;
import java.util.List;
import java.util.Map;
import lombok.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
@Getter
@@ -13,6 +18,12 @@ public class Show extends AbstractEntity {
@Setter
private String name;
@Setter
private Integer preferredDayParts;
@Setter
private boolean finalized;
@Setter
private Map<DanceGroupType, Integer> appearances;

View File

@@ -1,3 +1,21 @@
package nl.agilecc.agiledancers.model.scheduling;
public record GroupRequirement(long groupId, int appearances) {}
import java.util.Set;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
public record GroupRequirement(
long groupId,
int appearances,
boolean showcase,
Set<Long> dancerIds,
Set<DanceGroupStyle> styles,
double averageAge) {
public GroupRequirement {
dancerIds = Set.copyOf(dancerIds);
styles = Set.copyOf(styles);
}
public GroupRequirement(final long groupId, final int appearances) {
this(groupId, appearances, false, Set.of(), Set.of(), 0);
}
}

View File

@@ -0,0 +1,117 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Shared hard-rule validation for solver results and manually edited schedules. */
public final class ScheduleRules {
private ScheduleRules() {}
public static List<String> violations(final SchedulingInput input, final List<List<Long>> parts) {
final List<String> errors = new ArrayList<>();
if (input.dayParts() == 0 || input.preferredDayParts() < 1 || input.preferredDayParts() > input.dayParts()) {
errors.add("Configure dayparts and preferredDayParts between 1 and the number of dayparts.");
}
if (parts == null || parts.size() != input.dayParts() || parts.stream().anyMatch(java.util.Objects::isNull)) {
errors.add("Assignments must contain one list for every daypart.");
return List.copyOf(errors);
}
final Map<Long, GroupRequirement> groups = new HashMap<>();
input.groups().forEach((final GroupRequirement g) -> groups.put(g.groupId(), g));
for (int p = 0; p < parts.size(); p++) {
validatePart(parts.get(p), groups, p, errors);
}
appearances(input, parts, errors);
attendance(input, parts, errors);
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
if (max - min > 2) {
errors.add("Daypart performance counts differ by more than two.");
}
return List.copyOf(errors);
}
private static void validatePart(
final List<Long> ids, final Map<Long, GroupRequirement> groups, final int part, final List<String> errors) {
if (new HashSet<>(ids).size() != ids.size() || !groups.keySet().containsAll(ids)) {
errors.add("Daypart " + (part + 1) + " contains duplicate, unknown or empty groups.");
return;
}
final List<GroupRequirement> ordered = ids.stream().map(groups::get).toList();
showcases(ordered, part, errors);
gaps(ordered, part, errors);
}
private static void showcases(final List<GroupRequirement> groups, final int part, final List<String> errors) {
final long count = groups.stream().filter(GroupRequirement::showcase).count();
if (count == 0) {
return;
}
final boolean first = groups.getFirst().showcase();
final boolean last = groups.getLast().showcase();
if ((count == 1 && !first && !last) || (count >= 2 && (!first || !last))) {
errors.add("Daypart " + (part + 1) + " must place showcases at its opening/closing.");
}
}
private static void gaps(final List<GroupRequirement> groups, final int part, final List<String> errors) {
final Map<Long, Integer> last = new HashMap<>();
for (int i = 0; i < groups.size(); i++) {
for (final long dancer : groups.get(i).dancerIds()) {
final Integer previous = last.put(dancer, i);
if (previous != null && i - previous < 3) {
errors.add("Daypart " + (part + 1) + ": dancer " + dancer
+ " needs two intervening performances before group "
+ groups.get(i).groupId() + ".");
}
}
}
}
private static void appearances(
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
for (final GroupRequirement group : input.groups()) {
if (parts.stream()
.filter((final List<Long> ids) -> ids.contains(group.groupId()))
.count()
!= group.appearances()) {
errors.add("Group " + group.groupId() + " requires " + group.appearances() + " dayparts.");
}
}
}
private static void attendance(
final SchedulingInput input, final List<List<Long>> parts, final List<String> errors) {
final Set<Long> exempt = new HashSet<>(input.coachDancerIds());
input.groups().stream()
.filter(GroupRequirement::showcase)
.forEach((final GroupRequirement g) -> exempt.addAll(g.dancerIds()));
final Map<Long, Set<Integer>> attendance = new HashMap<>();
for (final GroupRequirement group : input.groups()) {
recordAttendance(group, parts, attendance);
}
attendance.forEach((final Long dancer, final Set<Integer> dayparts) -> {
if (!exempt.contains(dancer) && dayparts.size() > input.preferredDayParts()) {
errors.add(
"Dancer " + dancer + " exceeds preferredDayParts=" + input.preferredDayParts() + " dayparts.");
}
});
}
private static void recordAttendance(
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
for (int p = 0; p < parts.size(); p++) {
if (parts.get(p).contains(group.groupId())) {
for (final long dancer : group.dancerIds()) {
attendance
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
.add(p);
}
}
}
}
}

View File

@@ -1,9 +1,27 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.List;
import java.util.Set;
public record SchedulingInput(long showId, long revision, int dayParts, List<GroupRequirement> groups) {
/**
* Immutable solver snapshot.
*
* @param coachDancerIds dancer registration IDs linked to coaches, used for participation exemptions
*/
public record SchedulingInput(
long showId,
long revision,
int dayParts,
List<GroupRequirement> groups,
int preferredDayParts,
Set<Long> coachDancerIds) {
public SchedulingInput {
groups = List.copyOf(groups);
coachDancerIds = Set.copyOf(coachDancerIds);
}
public SchedulingInput(
final long showId, final long revision, final int dayParts, final List<GroupRequirement> groups) {
this(showId, revision, dayParts, groups, Math.min(2, dayParts), Set.of());
}
}

View File

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

View File

@@ -0,0 +1,20 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.HashSet;
import java.util.Set;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
public final class VarietyScore {
private VarietyScore() {}
/** Integer CP-SAT score: each equally weighted component ranges from zero to one million. */
public static long penalty(final GroupRequirement a, final GroupRequirement b) {
final Set<DanceGroupStyle> union = new HashSet<>(a.styles());
union.addAll(b.styles());
final Set<DanceGroupStyle> shared = new HashSet<>(a.styles());
shared.retainAll(b.styles());
final double style = union.isEmpty() ? 0 : (double) shared.size() / union.size();
final double age = Math.max(0, 1 - Math.abs(a.averageAge() - b.averageAge()) / 5);
return Math.round(1_000_000 * style) + Math.round(1_000_000 * age);
}
}

View File

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

View File

@@ -0,0 +1,48 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.CoachRequest;
import nl.agilecc.agiledancers.rest.apimodel.CoachView;
import nl.agilecc.agiledancers.service.CoachLinks;
@Path("/api/dance-schools/{schoolId}/coaches")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CoachResource {
private @Inject CoachLinks links;
private @PathParam("schoolId") long schoolId;
@GET
public List<CoachView> list() {
return links.list(schoolId).stream().map(CoachView::of).toList();
}
@POST
public CoachView create(final @NotNull @Valid CoachRequest request) {
return CoachView.of(links.link(schoolId, null, request.dancerId()));
}
@PUT
@Path("/{id}")
public CoachView link(final @PathParam("id") long id, final @NotNull @Valid CoachRequest request) {
return CoachView.of(links.link(schoolId, id, request.dancerId()));
}
@DELETE
@Path("/{id}/dancer")
public void unlink(final @PathParam("id") long id) {
links.unlink(schoolId, id);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,59 +3,98 @@ package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.service.SchedulingService;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowSchedules;
@Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ShowResource {
@Inject
SchoolManagementService service;
private @Inject SchoolManagementService service;
@Inject
SchedulingService scheduling;
private @Inject SchedulingService scheduling;
@PathParam("schoolId")
long schoolId;
private @Inject ShowSchedules schedules;
private @PathParam("schoolId") long schoolId;
@GET
public List<ShowView> list() {
return service.shows(schoolId).stream().map(ShowView::of).toList();
return service.shows(schoolId).stream().map(this::view).toList();
}
@GET
@Path("/{id}")
public ShowView get(@PathParam("id") long id) {
return ShowView.of(service.show(schoolId, id));
public ShowView get(final @PathParam("id") long id) {
return view(service.show(schoolId, id));
}
@POST
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
var entity = service.createShow(schoolId, request.name(), parts(request), request.appearances());
public Response create(final @NotNull @Valid ShowRequest request, final @Context UriInfo uriInfo) {
final Show entity = schedules.configure(schoolId, null, settings(request));
return Response.created(uriInfo.getAbsolutePathBuilder()
.path(entity.getId().toString())
.build())
.entity(ShowView.of(entity))
.entity(view(entity))
.build();
}
@PUT
@Path("/{id}")
public ShowView update(@PathParam("id") long id, @NotNull @Valid ShowRequest request) {
return ShowView.of(service.updateShow(schoolId, id, request.name(), parts(request), request.appearances()));
public ShowView update(final @PathParam("id") long id, final @NotNull @Valid ShowRequest request) {
return view(schedules.configure(schoolId, id, settings(request)));
}
private static List<DayPart> parts(ShowRequest request) {
private ShowView view(final Show show) {
return ShowView.of(show, schedules.violations(schoolId, show.getId()));
}
private static Show settings(final ShowRequest request) {
return Show.builder()
.name(request.name())
.preferredDayParts(request.preferredDayParts())
.dayParts(parts(request))
.build();
}
@PUT
@Path("/{id}/assignments")
public ShowView assignments(final @PathParam("id") long id, final @NotNull @Valid ScheduleRequest request) {
return view(schedules.saveDraft(schoolId, id, request.assignments()));
}
@POST
@Path("/{id}/finalize")
@Consumes(MediaType.WILDCARD)
public ShowView finalizeSchedule(final @PathParam("id") long id) {
return view(schedules.finalizeSchedule(schoolId, id));
}
private static List<DayPart> parts(final ShowRequest request) {
return request.dayParts().stream()
.map(p -> DayPart.builder()
.map((final DayPartRequest p) -> DayPart.builder()
.date(p.date())
.type(p.type())
.danceGroups(List.of())
@@ -66,15 +105,15 @@ public class ShowResource {
@POST
@Path("/{id}/schedule")
@Consumes(MediaType.WILDCARD)
public SchedulingView schedule(@PathParam("id") long id) {
var result = scheduling.schedule(schoolId, id);
public SchedulingView schedule(final @PathParam("id") long id) {
final SchedulingResult result = scheduling.schedule(schoolId, id);
return new SchedulingView(
result.optimal(), result.optimal() ? "Optimal balance" : "Valid result; optimal balance not proven");
result.optimal(), result.optimal() ? "Optimal schedule" : "Valid schedule; optimality not proven");
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
public Response delete(final @PathParam("id") long id) {
service.deleteShow(schoolId, id);
return Response.noContent().build();
}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotNull;
public record CoachRequest(@NotNull Long dancerId) {}

View File

@@ -0,0 +1,12 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.Coach;
public record CoachView(long id, String name, Long dancerId) {
public static CoachView of(final Coach coach) {
return new CoachView(
coach.getId(),
coach.getName(),
coach.getDancer() == null ? null : coach.getDancer().getId());
}
}

View File

@@ -4,7 +4,7 @@ import java.time.LocalDate;
import nl.agilecc.agiledancers.model.Dancer;
public record DancerView(long id, String name, LocalDate birthDate) {
public static DancerView of(Dancer dancer) {
public static DancerView of(final Dancer dancer) {
return new DancerView(dancer.getId(), dancer.getName(), dancer.getBirthDate());
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record ScheduleRequest(@NotNull List<List<Long>> assignments) {
public ScheduleRequest {
assignments = assignments == null
? null
: assignments.stream()
.map((final List<Long> p) ->
p == null ? null : java.util.Collections.unmodifiableList(new java.util.ArrayList<>(p)))
.toList();
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
@Singleton
public class CoachLinks {
@Inject
private SchoolManagementService schools;
public List<Coach> list(final long schoolId) {
return List.copyOf(schools.school(schoolId).getCoaches());
}
public Coach link(final long schoolId, final Long coachId, final long dancerId) {
synchronized (schools) {
final DanceSchool school = schools.school(schoolId);
final Dancer dancer = schools.dancer(schoolId, dancerId);
final Coach coach = coachId == null ? create(school, dancer) : required(school, coachId);
coach.setDancer(dancer);
school.getShows().forEach(schools::touch);
return coach;
}
}
private static Coach create(final DanceSchool school, final Dancer dancer) {
final Coach coach = Coach.builder().name(dancer.getName()).build();
coach.setId(school.getCoaches().stream().mapToLong(Coach::getId).max().orElse(0) + 1);
school.addCoach(coach);
return coach;
}
private static Coach required(final DanceSchool school, final long id) {
return school.getCoaches().stream()
.filter((final Coach c) -> c.getId() == id)
.findFirst()
.orElseThrow(() -> new ManagementException(ManagementExceptionKind.NOT_FOUND, "Unknown coach"));
}
public void unlink(final long schoolId, final long id) {
synchronized (schools) {
final DanceSchool school = schools.school(schoolId);
required(school, id).setDancer(null);
school.getShows().forEach(schools::touch);
}
}
}

View File

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

View File

@@ -1,31 +1,24 @@
package nl.agilecc.agiledancers.service;
import java.util.LinkedHashSet;
import java.util.Set;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.repository.DanceGroupRepository;
import nl.agilecc.agiledancers.service.model.GroupDetails;
public class DanceGroupService {
private final DanceGroupRepository danceGroupRepository = new DanceGroupRepository();
public DanceGroup add(
String category,
DanceGroupType type,
int startAge,
int endAge,
Set<Dancer> dancers,
Set<DanceGroupStyle> styles,
Coach coach) {
DanceGroup danceGroup = DanceGroup.builder()
.category(category)
.type(type)
.startAge(startAge)
.endAge(endAge)
public DanceGroup add(final GroupDetails details, final Set<Dancer> dancers, final Coach coach) {
final DanceGroup danceGroup = DanceGroup.builder()
.category(details.category())
.type(details.type())
.startAge(details.startAge())
.endAge(details.endAge())
.dancers(dancers)
.styles(styles)
.styles(details.styles() == null ? null : new LinkedHashSet<>(details.styles()))
.coach(coach)
.build();

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
package nl.agilecc.agiledancers.service;
import java.time.LocalDate;
import java.time.Period;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public final class SchedulingInputs {
private SchedulingInputs() {}
public static int preferred(final Show show) {
if (show.getPreferredDayParts() != null) {
return show.getPreferredDayParts();
}
return (show.getDayParts().size() == 1 ? 1 : 2);
}
public static SchedulingInput of(final DanceSchool school, final Show show, final long revision) {
final int preferredDayParts = preferred(show);
final LocalDate date = show.getDayParts().stream()
.map(DayPart::getDate)
.min(LocalDate::compareTo)
.orElse(LocalDate.now());
final List<GroupRequirement> groups = school.getDanceGroups().stream()
.filter((final DanceGroup g) -> !g.getDancers().isEmpty())
.map((final DanceGroup g) -> requirement(g, show, date))
.toList();
final Set<Long> coaches = school.getCoaches().stream()
.map(Coach::getDancer)
.filter(java.util.Objects::nonNull)
.map(Dancer::getId)
.collect(Collectors.toSet());
return new SchedulingInput(
show.getId(), revision, show.getDayParts().size(), groups, preferredDayParts, coaches);
}
private static GroupRequirement requirement(final DanceGroup group, final Show show, final LocalDate date) {
final int count = switch (group.getType()) {
case PRE_SCHOOLER -> 1;
case SHOW_CASE -> show.getDayParts().size();
default -> preferred(show);
};
final double age = group.getDancers().stream()
.mapToInt((final Dancer d) ->
Period.between(d.getBirthDate(), date).getYears())
.average()
.orElse(0);
return new GroupRequirement(
group.getId(),
count,
group.getType() == DanceGroupType.SHOW_CASE,
group.getDancers().stream().map(Dancer::getId).collect(Collectors.toSet()),
group.getStyles(),
age);
}
}

View File

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

View File

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

View File

@@ -1,45 +1,77 @@
package nl.agilecc.agiledancers.service;
import java.util.*;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public final class ShowConfiguration {
private ShowConfiguration() {}
public static Map<DanceGroupType, Integer> defaults(int n) {
Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
if (n > 0)
for (var type : DanceGroupType.values())
public static List<DayPart> retainAssignments(final Show show, final List<DayPart> parts) {
return parts.stream()
.map((final DayPart p) -> DayPart.builder()
.date(p.getDate())
.type(p.getType())
.danceGroups(show.getDayParts().stream()
.filter((final DayPart old) ->
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
.findFirst()
.map(DayPart::getDanceGroups)
.orElse(List.of()))
.build())
.toList();
}
public static Map<DanceGroupType, Integer> defaults(final int n) {
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
if (n > 0) {
for (final DanceGroupType type : DanceGroupType.values()) {
counts.put(
type,
switch (type) {
case PRE_SCHOOLER -> 1;
case SHOW_CASE, PRE_EDUCATION -> n;
case SHOW_CASE -> n;
default -> Math.min(2, n);
});
}
}
return counts;
}
public static void validate(List<DayPart> parts, Map<DanceGroupType, Integer> counts) {
public static void validate(final List<DayPart> parts, final Map<DanceGroupType, Integer> counts) {
validateParts(parts);
if (counts == null) {
invalidCounts();
}
if (parts.isEmpty() && counts.isEmpty()) {
return;
}
if (counts.size() != DanceGroupType.values().length
|| Arrays.stream(DanceGroupType.values())
.anyMatch((final DanceGroupType t) -> counts.get(t) == null
|| counts.get(t) < 1
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) {
invalidCounts();
}
}
public static void validateParts(final List<DayPart> parts) {
if (parts == null
|| parts.size() > 6
|| parts.stream().anyMatch(p -> p == null || p.getDate() == null || p.getType() == null)
|| parts.stream().anyMatch((final DayPart p) -> p == null || p.getDate() == null || p.getType() == null)
|| parts.stream()
.map(p -> p.getDate() + ":" + p.getType())
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
.distinct()
.count()
!= parts.size())
!= parts.size()) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"Configure zero to six dayparts with unique date/type combinations.");
if (counts == null) invalidCounts();
if (parts.isEmpty() && counts.isEmpty()) return;
if (counts.size() != DanceGroupType.values().length
|| Arrays.stream(DanceGroupType.values())
.anyMatch(t -> counts.get(t) == null
|| counts.get(t) < 1
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) invalidCounts();
}
}
private static void invalidCounts() {

View File

@@ -0,0 +1,125 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.ArrayList;
import java.util.List;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
@Singleton
public class ShowSchedules {
@Inject
private SchoolManagementService schools;
public ShowSchedules() {}
public ShowSchedules(final SchoolManagementService schools) {
this.schools = schools;
}
/** Retains assignments by date/type; removing dayparts may retain an invalid preferredDayParts in the draft. */
public Show configure(final long schoolId, final Long showId, final Show settings) {
synchronized (schools) {
ShowConfiguration.validateParts(settings.getDayParts());
final Show existing = showId == null ? null : schools.show(schoolId, showId);
final int preferredDayParts = settings.getPreferredDayParts() == null
? (existing == null
? (settings.getDayParts().size() == 1 ? 1 : 2)
: SchedulingInputs.preferred(existing))
: settings.getPreferredDayParts();
validatePreferredDayParts(
preferredDayParts, existing, settings.getDayParts().size());
final Show show = existing == null
? schools.createShow(schoolId, settings.getName(), settings.getDayParts(), null)
: existing;
final boolean changed = preferredDayParts != SchedulingInputs.preferred(show)
|| !keys(show.getDayParts()).equals(keys(settings.getDayParts()));
show.setDayParts(ShowConfiguration.retainAssignments(show, settings.getDayParts()));
show.setName(settings.getName());
show.setPreferredDayParts(preferredDayParts);
if (changed) {
schools.touch(show);
}
return show;
}
}
private static List<String> keys(final List<DayPart> parts) {
return parts.stream()
.map((final DayPart p) -> p.getDate() + ":" + p.getType())
.toList();
}
private static void validatePreferredDayParts(final int preferredDayParts, final Show existing, final int parts) {
if (preferredDayParts < 1
|| preferredDayParts > 6
|| (parts > 0
&& preferredDayParts > parts
&& (existing == null || preferredDayParts != SchedulingInputs.preferred(existing)))) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"preferredDayParts must be between 1 and the number of dayparts. Retained preferredDayParts must"
+ " be corrected before scheduling.");
}
}
/** Saves even rule-breaking drafts, but rejects malformed lists and foreign group references atomically. */
public Show saveDraft(final long schoolId, final long showId, final List<List<Long>> assignments) {
synchronized (schools) {
final Show show = schools.show(schoolId, showId);
if (assignments == null || assignments.size() != show.getDayParts().size()) {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Provide every daypart.");
}
final List<DayPart> replacement = new ArrayList<>();
for (int p = 0; p < assignments.size(); p++) {
final DayPart old = show.getDayParts().get(p);
final List<Long> ids = assignments.get(p);
if (ids == null || ids.stream().anyMatch(java.util.Objects::isNull)) {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "Invalid group IDs.");
}
final List<DanceGroup> groups = ids.stream()
.map((final Long id) -> schools.group(schoolId, id))
.toList();
replacement.add(DayPart.builder()
.date(old.getDate())
.type(old.getType())
.danceGroups(groups)
.build());
}
show.setDayParts(List.copyOf(replacement));
schools.touch(show);
return show;
}
}
/** Marks a schedule final only after checking all hard rules against current school data. */
public Show finalizeSchedule(final long schoolId, final long showId) {
synchronized (schools) {
final Show show = schools.show(schoolId, showId);
final List<String> errors = violations(schoolId, showId);
if (!errors.isEmpty()) {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, String.join(" ", errors));
}
show.setFinalized(true);
return show;
}
}
public List<String> violations(final long schoolId, final long showId) {
synchronized (schools) {
final Show show = schools.show(schoolId, showId);
final SchedulingInput input = SchedulingInputs.of(schools.school(schoolId), show, 0);
return ScheduleRules.violations(
input,
show.getDayParts().stream()
.map((final DayPart p) -> p.getDanceGroups().stream()
.map(DanceGroup::getId)
.toList())
.toList());
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,29 @@
package nl.agilecc.agiledancers.service.model;
import java.time.LocalDate;
import java.util.*;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.ShowConfiguration;
/** Version 2 file format. IDs are local to the file, never server identifiers. */
/** Version 3 file format; version 2 imports remain supported. IDs are local to the file, never server identifiers. */
public record SchoolSnapshot(
Integer formatVersion,
UUID key,
@@ -17,23 +32,31 @@ public record SchoolSnapshot(
List<Trainer> coaches,
List<Group> groups,
List<Performance> shows) {
public static SchoolSnapshot of(DanceSchool s) {
Map<Dancer, String> dancers = ids(s.getDancers());
Map<Coach, String> coaches = ids(s.getCoaches());
Map<DanceGroup, String> groups = ids(s.getDanceGroups());
Map<Show, String> shows = ids(s.getShows());
public SchoolSnapshot {
dancers = dancers == null ? null : Collections.unmodifiableList(new ArrayList<>(dancers));
coaches = coaches == null ? null : Collections.unmodifiableList(new ArrayList<>(coaches));
groups = groups == null ? null : Collections.unmodifiableList(new ArrayList<>(groups));
shows = shows == null ? null : Collections.unmodifiableList(new ArrayList<>(shows));
}
public static SchoolSnapshot of(final DanceSchool s) {
final Map<Dancer, String> dancers = ids(s.getDancers());
final Map<Coach, String> coaches = ids(s.getCoaches());
final Map<DanceGroup, String> groups = ids(s.getDanceGroups());
final Map<Show, String> shows = ids(s.getShows());
return new SchoolSnapshot(
2,
3,
s.getKey(),
s.getName(),
s.getDancers().stream()
.map(d -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
.map((final Dancer d) -> new Person(dancers.get(d), d.getName(), d.getBirthDate()))
.toList(),
s.getCoaches().stream()
.map(c -> new Trainer(coaches.get(c), c.getName()))
.map((final Coach c) -> new Trainer(
coaches.get(c), c.getName(), c.getDancer() == null ? null : dancers.get(c.getDancer())))
.toList(),
s.getDanceGroups().stream()
.map(g -> new Group(
.map((final DanceGroup g) -> new Group(
groups.get(g),
g.getCategory(),
g.getType(),
@@ -44,57 +67,84 @@ public record SchoolSnapshot(
g.getCoach() == null ? null : coaches.get(g.getCoach())))
.toList(),
s.getShows().stream()
.map(show -> new Performance(
.map((final Show show) -> new Performance(
shows.get(show),
show.getName(),
show.getDayParts().stream()
.map(p -> new Part(
.map((final DayPart p) -> new Part(
p.getDate(),
p.getType(),
p.getDanceGroups().stream()
.map(groups::get)
.toList()))
.toList(),
show.getAppearances()))
show.getAppearances(),
nl.agilecc.agiledancers.service.SchedulingInputs.preferred(show),
show.isFinalized()))
.toList());
}
private static <T> Map<T, String> ids(Collection<T> values) {
Map<T, String> result = new IdentityHashMap<>();
for (T value : values) result.put(value, Integer.toString(result.size() + 1));
private static <T> Map<T, String> ids(final Collection<T> values) {
final Map<T, String> result = new IdentityHashMap<>();
for (final T value : values) {
result.put(value, Integer.toString(result.size() + 1));
}
return result;
}
/** Validates all references and values, then rebuilds an independent graph with temporary IDs. */
public DanceSchool restore() {
check(Objects.equals(formatVersion, 2), "Niet-ondersteunde formatVersion: " + formatVersion);
check(
Objects.equals(formatVersion, 2) || Objects.equals(formatVersion, 3),
"Niet-ondersteunde formatVersion: " + formatVersion);
check(key != null, "key ontbreekt");
text(name, "schoolnaam");
DanceSchool school = DanceSchool.builder().key(key).name(name).build();
Map<String, Dancer> dancerMap = new LinkedHashMap<>();
for (Person d : list(dancers, "dancers")) {
final DanceSchool school = DanceSchool.builder().key(key).name(name).build();
final Map<String, Dancer> dancerMap = restoreDancers(school);
final Map<String, Coach> coachMap = restoreCoaches(school, dancerMap);
final Map<String, DanceGroup> groupMap = restoreGroups(school, dancerMap, coachMap);
restoreShows(school, groupMap);
return school;
}
private Map<String, Dancer> restoreDancers(final DanceSchool school) {
final Map<String, Dancer> dancerMap = new LinkedHashMap<>();
for (final Person d : list(dancers, "dancers")) {
check(d != null, "Lege danser");
text(d.name(), "dansernaam");
check(
d.birthDate() != null && !d.birthDate().isAfter(LocalDate.now()),
"Ongeldige geboortedatum voor " + d.name());
Dancer dancer =
final Dancer dancer =
Dancer.builder().name(d.name()).birthDate(d.birthDate()).build();
// Assign temporary distinct IDs before inserting entities into sets.
dancer.setId((long) dancerMap.size());
put(dancerMap, d.id(), dancer);
school.addDancer(dancer);
}
Map<String, Coach> coachMap = new LinkedHashMap<>();
for (Trainer c : list(coaches, "coaches")) {
return dancerMap;
}
private Map<String, Coach> restoreCoaches(final DanceSchool school, final Map<String, Dancer> dancerMap) {
final Map<String, Coach> coachMap = new LinkedHashMap<>();
for (final Trainer c : list(coaches, "coaches")) {
check(c != null, "Lege coach");
text(c.name(), "coachnaam");
Coach coach = Coach.builder().name(c.name()).build();
final Coach coach = Coach.builder()
.name(c.name())
.dancer(c.dancerId() == null ? null : ref(dancerMap, c.dancerId(), "Onbekende coach-danser"))
.build();
coach.setId((long) coachMap.size());
put(coachMap, c.id(), coach);
school.addCoach(coach);
}
Map<String, DanceGroup> groupMap = new LinkedHashMap<>();
for (Group g : list(groups, "groups")) {
return coachMap;
}
private Map<String, DanceGroup> restoreGroups(
final DanceSchool school, final Map<String, Dancer> dancerMap, final Map<String, Coach> coachMap) {
final Map<String, DanceGroup> groupMap = new LinkedHashMap<>();
for (final Group g : list(groups, "groups")) {
check(g != null, "Lege groep");
text(g.category(), "groepsnaam");
check(
@@ -104,7 +154,7 @@ public record SchoolSnapshot(
&& g.startAge() >= 0
&& g.endAge() >= g.startAge(),
"Ongeldig type of leeftijdsbereik voor groep " + g.category());
DanceGroup group = DanceGroup.builder()
final DanceGroup group = DanceGroup.builder()
.category(g.category())
.type(g.type())
.startAge(g.startAge())
@@ -120,20 +170,25 @@ public record SchoolSnapshot(
.build();
check(!group.getStyles().contains(null), "Ongeldige dansstijl");
group.setId((long) groupMap.size());
for (String id : unique(g.dancerIds(), "dancerIds"))
for (final String id : unique(g.dancerIds(), "dancerIds")) {
group.addDancer(ref(dancerMap, id, "groep " + g.category() + " verwijst naar een onbekende danser"));
}
put(groupMap, g.id(), group);
school.addDanceGroup(group);
}
Set<String> showIds = new HashSet<>();
for (Performance show : list(shows, "shows")) {
return groupMap;
}
private void restoreShows(final DanceSchool school, final Map<String, DanceGroup> groupMap) {
final Set<String> showIds = new HashSet<>();
for (final Performance show : list(shows, "shows")) {
check(show != null, "Lege show");
text(show.id(), "show-id");
check(showIds.add(show.id()), "Dubbele show-id " + show.id());
text(show.name(), "shownaam");
List<DayPart> parts = new ArrayList<>();
Set<String> types = new HashSet<>();
for (Part part : list(show.dayParts(), "dayParts")) {
final List<DayPart> parts = new ArrayList<>();
final Set<String> types = new HashSet<>();
for (final Part part : list(show.dayParts(), "dayParts")) {
check(
part != null
&& part.type() != null
@@ -143,34 +198,41 @@ public record SchoolSnapshot(
parts.add(DayPart.builder()
.date(part.date())
.type(part.type())
.danceGroups(unique(part.groupIds(), "groupIds").stream()
.map(id ->
.danceGroups(list(part.groupIds(), "groupIds").stream()
.map((final String id) ->
ref(groupMap, id, "Show " + show.name() + " verwijst naar een onbekende groep"))
.toList())
.build());
}
ShowConfiguration.validate(parts, show.appearances());
Show entity = Show.builder()
ShowConfiguration.validateParts(parts);
final int preferredDayParts =
show.preferredDayParts() == null ? (parts.size() == 1 ? 1 : 2) : show.preferredDayParts();
check(preferredDayParts >= 1 && preferredDayParts <= 6, "preferredDayParts moet tussen 1 en 6 liggen");
final Show entity = Show.builder()
.name(show.name())
.dayParts(parts)
.appearances(Map.copyOf(show.appearances()))
.appearances(
show.appearances() == null
? ShowConfiguration.defaults(parts.size())
: Map.copyOf(show.appearances()))
.preferredDayParts(preferredDayParts)
.finalized(false)
.build();
entity.setId((long) showIds.size());
school.addShow(entity);
}
return school;
}
private static void text(String value, String field) {
private static void text(final String value, final String field) {
check(value != null && !value.isBlank(), field + " ontbreekt");
}
private static <T> List<T> list(List<T> value, String field) {
private static <T> List<T> list(final List<T> value, final String field) {
check(value != null, field + " ontbreekt");
return value;
}
private static List<String> unique(List<String> value, String field) {
private static List<String> unique(final List<String> value, final String field) {
list(value, field);
check(
value.stream().noneMatch(Objects::isNull) && new HashSet<>(value).size() == value.size(),
@@ -178,18 +240,20 @@ public record SchoolSnapshot(
return value;
}
private static <T> void put(Map<String, T> map, String id, T value) {
private static <T> void put(final Map<String, T> map, final String id, final T value) {
text(id, "id");
check(map.putIfAbsent(id, value) == null, "Dubbele id " + id);
}
private static <T> T ref(Map<String, T> map, String id, String error) {
T value = map.get(id);
private static <T> T ref(final Map<String, T> map, final String id, final String error) {
final T value = map.get(id);
check(value != null, error);
return value;
}
private static void check(boolean valid, String message) {
if (!valid) throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, message);
private static void check(final boolean valid, final String message) {
if (!valid) {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, message);
}
}
}

View File

@@ -1,3 +1,7 @@
package nl.agilecc.agiledancers.service.model;
public record Trainer(String id, String name) {}
public record Trainer(String id, String name, String dancerId) {
public Trainer(final String id, final String name) {
this(id, name, null);
}
}

View File

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

View File

@@ -1,45 +1,69 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.Loader;
import com.google.ortools.sat.*;
import com.google.ortools.sat.CpModel;
import com.google.ortools.sat.CpSolver;
import com.google.ortools.sat.CpSolverStatus;
import com.google.ortools.sat.LinearExpr;
import jakarta.inject.Singleton;
import java.util.*;
import java.util.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine {
private final double timeLimitSeconds;
public OrToolsSchedulingEngine() {
this(1800);
}
/** Allows callers to impose a shorter run budget, for example in automated tests. */
public OrToolsSchedulingEngine(final double timeLimitSeconds) {
this.timeLimitSeconds = timeLimitSeconds;
}
@Override
public SchedulingResult solve(SchedulingInput input) {
public SchedulingResult solve(final SchedulingInput input) {
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
Loader.loadNativeLibraries();
CpModel model = new CpModel();
int n = input.dayParts(), groups = input.groups().size();
BoolVar[][] assigned = new BoolVar[groups][n];
for (int g = 0; g < groups; g++) {
for (int p = 0; p < n; p++) assigned[g][p] = model.newBoolVar("g" + g + "p" + p);
model.addEquality(LinearExpr.sum(assigned[g]), input.groups().get(g).appearances());
}
IntVar[] loads = new IntVar[n];
for (int p = 0; p < n; p++) {
BoolVar[] column = new BoolVar[groups];
for (int g = 0; g < groups; g++) column[g] = assigned[g][p];
loads[p] = model.newIntVar(0, groups, "load" + p);
model.addEquality(loads[p], LinearExpr.sum(column));
}
IntVar max = model.newIntVar(0, groups, "max"), min = model.newIntVar(0, groups, "min");
model.addMaxEquality(max, loads);
model.addMinEquality(min, loads);
model.minimize(LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1}));
CpSolver solver = new CpSolver();
solver.getParameters().setMaxTimeInSeconds(1800);
// Quarkus owns process shutdown; do not install a native SIGINT handler.
final CpModel model = new CpModel();
final OrderedAssignments vars = OrderedAssignments.create(model, input);
final LinearExpr attendance = SchedulingObjectives.attendance(model, input, vars);
final LinearExpr variety = SchedulingObjectives.variety(model, input, vars);
final CpSolver solver = new CpSolver();
solver.getParameters().setCatchSigintSignal(false);
// Single-worker search with a fixed seed makes identical inputs repeatable.
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
CpSolverStatus status = solver.solve(model);
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE)
SchedulingResult best = null;
for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) {
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
if (remaining <= 0) {
if (best != null) {
return new SchedulingResult(false, best.assignments());
}
requireSolution(CpSolverStatus.UNKNOWN);
}
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
model.minimize(objective);
final CpSolverStatus status = solver.solve(model);
if (status == CpSolverStatus.UNKNOWN && best != null) {
return new SchedulingResult(false, best.assignments());
}
requireSolution(status);
best = new SchedulingResult(status == CpSolverStatus.OPTIMAL, vars.read(input, solver));
if (status != CpSolverStatus.OPTIMAL) {
return best;
}
model.addEquality(objective, Math.round(solver.objectiveValue()));
}
return best;
}
private static void requireSolution(final CpSolverStatus status) {
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
switch (status) {
@@ -49,14 +73,6 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
+ " preserved.";
default -> "Solver failed (" + status + "). Existing assignments were preserved.";
});
List<List<Long>> parts = new ArrayList<>();
for (int p = 0; p < n; p++) {
List<Long> ids = new ArrayList<>();
for (int g = 0; g < groups; g++)
if (solver.booleanValue(assigned[g][p]))
ids.add(input.groups().get(g).groupId());
parts.add(List.copyOf(ids));
}
return new SchedulingResult(status == CpSolverStatus.OPTIMAL, List.copyOf(parts));
}
}

View File

@@ -0,0 +1,125 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.sat.BoolVar;
import com.google.ortools.sat.CpModel;
import com.google.ortools.sat.IntVar;
import com.google.ortools.sat.LinearExpr;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.IntStream;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
/** Solver-owned variables. Negative positions are distinct sentinels for groups absent from a daypart. */
public record OrderedAssignments(BoolVar[][] present, IntVar[][] position, IntVar[] loads, IntVar spread) {
public static OrderedAssignments create(final CpModel model, final SchedulingInput input) {
final int n = input.groups().size();
final BoolVar[][] present = new BoolVar[n][input.dayParts()];
final IntVar[][] positions = new IntVar[n][input.dayParts()];
for (int g = 0; g < n; g++) {
for (int p = 0; p < input.dayParts(); p++) {
present[g][p] = model.newBoolVar("present" + g + ":" + p);
positions[g][p] = model.newIntVar(-g - 1, n - 1, "position" + g + ":" + p);
model.addGreaterOrEqual(positions[g][p], 0).onlyEnforceIf(present[g][p]);
model.addEquality(positions[g][p], -g - 1).onlyEnforceIf(present[g][p].not());
}
model.addEquality(LinearExpr.sum(present[g]), input.groups().get(g).appearances());
}
final IntVar[] loads = loads(model, present, positions, input.dayParts());
final IntVar max = model.newIntVar(0, n, "max");
final IntVar min = model.newIntVar(0, n, "min");
final IntVar spread = model.newIntVar(0, 2, "spread");
model.addMaxEquality(max, loads);
model.addMinEquality(min, loads);
model.addEquality(spread, LinearExpr.weightedSum(new IntVar[] {max, min}, new long[] {1, -1}));
final OrderedAssignments result = new OrderedAssignments(present, positions, loads, spread);
result.boundaries(model, input);
result.changingTime(model, input);
return result;
}
private static IntVar[] loads(
final CpModel model, final BoolVar[][] present, final IntVar[][] positions, final int parts) {
final IntVar[] loads = new IntVar[parts];
for (int p = 0; p < parts; p++) {
final BoolVar[] column = new BoolVar[present.length];
final IntVar[] order = new IntVar[present.length];
for (int g = 0; g < present.length; g++) {
column[g] = present[g][p];
order[g] = positions[g][p];
}
loads[p] = model.newIntVar(0, present.length, "load" + p);
model.addEquality(loads[p], LinearExpr.sum(column));
model.addAllDifferent(order);
for (int g = 0; g < present.length; g++) {
model.addLessThan(order[g], loads[p]).onlyEnforceIf(column[g]);
}
}
return loads;
}
private void boundaries(final CpModel model, final SchedulingInput input) {
final List<Integer> showcases = IntStream.range(0, input.groups().size())
.filter((final int g) -> input.groups().get(g).showcase())
.boxed()
.toList();
if (showcases.isEmpty()) {
return;
}
for (int p = 0; p < input.dayParts(); p++) {
final List<BoolVar> first = new ArrayList<>();
final List<BoolVar> last = new ArrayList<>();
for (final int g : showcases) {
first.add(equals(model, position[g][p], LinearExpr.constant(0)));
last.add(equals(model, position[g][p], LinearExpr.affine(loads[p], 1, -1)));
}
if (showcases.size() == 1) {
model.addBoolOr(new com.google.ortools.sat.Literal[] {first.getFirst(), last.getFirst()});
} else {
model.addEquality(LinearExpr.sum(first.toArray(BoolVar[]::new)), 1);
model.addEquality(LinearExpr.sum(last.toArray(BoolVar[]::new)), 1);
}
}
}
private static BoolVar equals(final CpModel model, final IntVar position, final LinearExpr value) {
final BoolVar equal = model.newBoolVar("boundary");
model.addEquality(position, value).onlyEnforceIf(equal);
model.addDifferent(position, value).onlyEnforceIf(equal.not());
return equal;
}
private void changingTime(final CpModel model, final SchedulingInput input) {
for (int a = 0; a < input.groups().size(); a++) {
for (int b = a + 1; b < input.groups().size(); b++) {
if (!java.util.Collections.disjoint(
input.groups().get(a).dancerIds(), input.groups().get(b).dancerIds())) {
separate(model, a, b);
}
}
}
}
private void separate(final CpModel model, final int a, final int b) {
for (int p = 0; p < loads.length; p++) {
final IntVar gap = model.newIntVar(0, present.length * 2L, "gap");
model.addAbsEquality(
gap, LinearExpr.weightedSum(new IntVar[] {position[a][p], position[b][p]}, new long[] {1, -1}));
model.addGreaterOrEqual(gap, 3).onlyEnforceIf(new BoolVar[] {present[a][p], present[b][p]});
}
}
public List<List<Long>> read(final SchedulingInput input, final com.google.ortools.sat.CpSolver solver) {
final List<List<Long>> result = new ArrayList<>();
for (int p = 0; p < loads.length; p++) {
final int part = p;
result.add(IntStream.range(0, present.length)
.boxed()
.filter((final Integer g) -> solver.booleanValue(present[g][part]))
.sorted(Comparator.comparingLong((final Integer g) -> solver.value(position[g][part])))
.map((final Integer g) -> input.groups().get(g).groupId())
.toList());
}
return List.copyOf(result);
}
}

View File

@@ -0,0 +1,132 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.sat.BoolVar;
import com.google.ortools.sat.CpModel;
import com.google.ortools.sat.IntVar;
import com.google.ortools.sat.LinearExpr;
import com.google.ortools.sat.LinearExprBuilder;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.IntStream;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
public final class SchedulingObjectives {
private SchedulingObjectives() {}
public static LinearExpr attendance(
final CpModel model, final SchedulingInput input, final OrderedAssignments vars) {
final Set<Long> dancers = new HashSet<>();
final Set<Long> showcases = new HashSet<>();
input.groups().forEach((final GroupRequirement g) -> dancers.addAll(g.dancerIds()));
input.groups().stream()
.filter(GroupRequirement::showcase)
.forEach((final GroupRequirement g) -> showcases.addAll(g.dancerIds()));
dancers.removeAll(input.coachDancerIds());
final LinearExprBuilder penalty = LinearExpr.newBuilder();
for (final long dancer : dancers) {
final IntVar[] parts = dancerParts(model, input, vars, dancer);
if (showcases.contains(dancer)) {
final IntVar excess = model.newIntVar(0, input.dayParts(), "excess");
model.addMaxEquality(excess, new LinearExpr[] {
LinearExpr.affine(LinearExpr.sum(parts), 1, -input.preferredDayParts()), LinearExpr.constant(0)
});
penalty.add(excess);
} else {
model.addLessOrEqual(LinearExpr.sum(parts), input.preferredDayParts());
}
}
return penalty.build();
}
private static IntVar[] dancerParts(
final CpModel model, final SchedulingInput input, final OrderedAssignments vars, final long dancer) {
final List<Integer> groups = IntStream.range(0, input.groups().size())
.filter((final int g) -> !input.groups().get(g).showcase()
&& input.groups().get(g).dancerIds().contains(dancer))
.boxed()
.toList();
final IntVar[] result = new IntVar[input.dayParts()];
for (int p = 0; p < input.dayParts(); p++) {
final int part = p;
result[p] = model.newBoolVar("dancer" + dancer + ":" + p);
if (groups.isEmpty()) {
model.addEquality(result[p], 0);
} else {
model.addMaxEquality(
result[p],
groups.stream()
.map((final Integer g) -> vars.present()[g][part])
.toArray(IntVar[]::new));
}
}
return result;
}
public static LinearExpr variety(final CpModel model, final SchedulingInput input, final OrderedAssignments vars) {
final LinearExprBuilder penalty = LinearExpr.newBuilder();
// Every nonempty daypart has load - 1 adjacencies. Remove their common score
// to avoid exploring equivalent orders when groups have identical penalties.
final long baseline = minimumPenalty(input.groups());
final long performances =
input.groups().stream().mapToLong(GroupRequirement::appearances).sum();
penalty.add(performances * baseline);
for (final IntVar load : vars.loads()) {
final BoolVar active = model.newBoolVar("nonemptyPart");
model.addGreaterThan(load, 0).onlyEnforceIf(active);
model.addEquality(load, 0).onlyEnforceIf(active.not());
penalty.addTerm(active, -baseline);
}
for (int a = 0; a < input.groups().size(); a++) {
for (int b = a + 1; b < input.groups().size(); b++) {
final long score = VarietyScore.penalty(
input.groups().get(a), input.groups().get(b))
- baseline;
if (score > 0) {
addAdjacencies(model, vars, new int[] {a, b}, penalty, score);
}
}
}
return penalty.build();
}
private static long minimumPenalty(final List<GroupRequirement> groups) {
long minimum = 2_000_000;
for (int a = 0; a < groups.size(); a++) {
for (int b = a + 1; b < groups.size(); b++) {
minimum = Math.min(minimum, VarietyScore.penalty(groups.get(a), groups.get(b)));
}
}
return groups.size() < 2 ? 0 : minimum;
}
private static void addAdjacencies(
final CpModel model,
final OrderedAssignments vars,
final int[] pair,
final LinearExprBuilder penalty,
final long score) {
for (int p = 0; p < vars.loads().length; p++) {
penalty.addTerm(adjacent(model, vars, pair[0], pair[1], p), score);
}
}
private static BoolVar adjacent(
final CpModel model, final OrderedAssignments vars, final int a, final int b, final int p) {
final IntVar gap = model.newIntVar(0, vars.present().length * 2L, "adjacencyGap");
model.addAbsEquality(
gap,
LinearExpr.weightedSum(
new IntVar[] {vars.position()[a][p], vars.position()[b][p]}, new long[] {1, -1}));
final BoolVar adjacent = model.newBoolVar("adjacent");
model.addImplication(adjacent, vars.present()[a][p]);
model.addImplication(adjacent, vars.present()[b][p]);
model.addEquality(gap, 1).onlyEnforceIf(adjacent);
model.addDifferent(gap, 1).onlyEnforceIf(new com.google.ortools.sat.Literal[] {
adjacent.not(), vars.present()[a][p], vars.present()[b][p]
});
return adjacent;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,148 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.SchedulingInputs;
import nl.agilecc.agiledancers.service.SchedulingService;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowSchedules;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class S18LifecycleTest {
private final SchoolManagementService schools = new SchoolManagementService();
private final long school = schools.createSchool("Studio").getId();
private final ShowSchedules schedules = new ShowSchedules(schools);
private List<DayPart> parts(final int n) {
return java.util.stream.IntStream.range(0, n)
.mapToObj((final int i) -> DayPart.builder()
.date(LocalDate.of(2027, 1, 1).plusDays(i))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build())
.toList();
}
private Show settings(final int n, final Integer preferredDayParts) {
return Show.builder()
.name("Show")
.dayParts(parts(n))
.preferredDayParts(preferredDayParts)
.build();
}
private DanceGroup group(final DanceGroupType type) {
final DanceGroup group = schools.createGroup(school, new GroupDetails("Group", type, 0, 99, Set.of()));
final long dancer =
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 2)).getId();
schools.addGroupDancer(school, group.getId(), dancer);
return group;
}
public @Test void draftEditingValidationFinalizationAndImportPreserveOrder() {
final DanceGroup group = group(DanceGroupType.REGULAR);
final Show show = schedules.configure(school, null, settings(2, 1));
assertThrows(ManagementException.class, () -> schedules.finalizeSchedule(school, show.getId()));
schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId()), List.of()));
assertTrue(schedules.violations(school, show.getId()).isEmpty());
schedules.finalizeSchedule(school, show.getId());
assertTrue(show.isFinalized());
final SchoolSnapshot snapshot = schools.exportSchool(school);
assertTrue(snapshot.shows().getFirst().finalized());
final Show imported = snapshot.restore().getShows().iterator().next();
assertFalse(imported.isFinalized());
assertEquals(1, imported.getPreferredDayParts());
assertEquals(1, imported.getDayParts().getFirst().getDanceGroups().size());
schedules.configure(school, show.getId(), settings(2, 1));
assertTrue(show.isFinalized());
schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(group.getId())));
assertFalse(show.isFinalized());
schedules.finalizeSchedule(school, show.getId());
schools.removeGroupDancer(
school, group.getId(), group.getDancers().iterator().next().getId());
assertFalse(show.isFinalized());
assertFalse(schedules.violations(school, show.getId()).isEmpty());
}
public @Test void invalidDraftsAreSavedButStructuralErrorsAreAtomic() {
final DanceGroup group = group(DanceGroupType.REGULAR);
final Show show = schedules.configure(school, null, settings(2, 1));
schedules.saveDraft(school, show.getId(), List.of(List.of(group.getId(), group.getId()), List.of()));
assertFalse(schedules.violations(school, show.getId()).isEmpty());
final SchoolSnapshot before = schools.exportSchool(school);
assertThrows(ManagementException.class, () -> schedules.saveDraft(school, show.getId(), List.of()));
assertThrows(
ManagementException.class,
() -> schedules.saveDraft(school, show.getId(), List.of(List.of(), List.of(999L))));
assertEquals(before, schools.exportSchool(school));
}
public @Test void xDefaultsRetainedOnDaypartReductionAndInvalidConfigurationIsAtomic() {
final Show one = schedules.configure(school, null, settings(1, null));
assertEquals(1, one.getPreferredDayParts());
final Show show = schedules.configure(school, null, settings(3, null));
assertEquals(2, show.getPreferredDayParts());
schedules.configure(school, show.getId(), settings(1, null));
assertEquals(2, show.getPreferredDayParts());
assertThrows(ManagementException.class, () -> schools.beginScheduling(school, show.getId()));
schedules.configure(school, show.getId(), settings(1, 1));
assertTrue(schedules.violations(school, show.getId()).isEmpty());
final int before = schools.shows(school).size();
assertThrows(ManagementException.class, () -> schedules.configure(school, null, settings(1, 2)));
assertEquals(before, schools.shows(school).size());
schedules.configure(school, show.getId(), settings(0, null));
assertFalse(schedules.violations(school, show.getId()).isEmpty());
}
public @Test void timeoutPreservesAssignmentsAndIsNotReportedAsInfeasible() {
group(DanceGroupType.REGULAR);
final Show show = schedules.configure(school, null, settings(1, 1));
new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
final SchoolSnapshot before = schools.exportSchool(school);
final ManagementException error = assertThrows(
ManagementException.class,
() -> new SchedulingService(schools, new OrToolsSchedulingEngine(0)).schedule(school, show.getId()));
assertTrue(error.getMessage().contains("limit"));
assertFalse(error.getMessage().contains("infeasible"));
assertEquals(before, schools.exportSchool(school));
}
public @Test void emptyGroupsSkippedAndAgeUsesFirstShowDateAcrossDates() {
final DanceGroup ordinary = group(DanceGroupType.PRE_EDUCATION);
group(DanceGroupType.PRE_SCHOOLER);
schools.createGroup(school, new GroupDetails("Empty", DanceGroupType.SHOW_CASE, 0, 99, Set.of()));
final Show show = schedules.configure(school, null, settings(3, 1));
final SchedulingInput input = schools.beginScheduling(school, show.getId());
assertEquals(2, input.groups().size());
assertEquals(26, input.groups().getFirst().averageAge());
assertEquals(1, input.groups().getFirst().appearances());
schools.endScheduling(show.getId());
new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
assertFalse(show.isFinalized());
final SchedulingInput stale = schools.beginScheduling(school, show.getId());
final SchedulingResult result = new OrToolsSchedulingEngine().solve(stale);
schools.updateDancer(
school, ordinary.getDancers().iterator().next().getId(), "Dancer", LocalDate.of(2001, 1, 1));
assertThrows(ManagementException.class, () -> schools.finishScheduling(school, stale, result));
schools.endScheduling(show.getId());
assertEquals(
2, SchedulingInputs.of(schools.school(school), show, 0).groups().size());
}
}

View File

@@ -0,0 +1,136 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import io.quarkus.test.junit.QuarkusTest;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
@QuarkusTest
class S18ResourceTest {
private static final String ROOT = "/api/dance-schools";
private static long create(final String url, final Object body) {
return given().contentType("application/json")
.body(body)
.post(url)
.then()
.statusCode(201)
.extract()
.jsonPath()
.getLong("id");
}
private static long dancer(final String school, final String name) {
return create(school + "/dancers", Map.of("name", name, "birthDate", "1990-01-01"));
}
private static long group(final String school, final long dancer) {
final long group = create(
school + "/dance-groups",
Map.of("category", "Group", "type", "REGULAR", "startAge", 0, "endAge", 99, "styles", List.of()));
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
.then()
.statusCode(204);
return group;
}
private static String show(final String school) {
return school + "/shows/"
+ create(
school + "/shows",
Map.of(
"name",
"Show",
"preferredDayParts",
1,
"dayParts",
List.of(Map.of("date", "2027-01-01", "type", "MORNING"))));
}
public @Test void draftApiFinalizesOnlyValidSchedulesAndRelevantEditsInvalidateThem() {
final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 lifecycle"));
try {
final long dancer = dancer(school, "Dancer");
final long group = group(school, dancer);
final String show = show(school);
given().post(show + "/finalize").then().statusCode(400);
given().contentType("application/json")
.body(Map.of("assignments", List.of(List.of(group))))
.put(show + "/assignments")
.then()
.statusCode(200)
.body("finalized", equalTo(false), "violations", empty());
given().post(show + "/finalize").then().statusCode(200).body("finalized", equalTo(true));
given().delete(school + "/dance-groups/" + group + "/dancers/" + dancer)
.then()
.statusCode(204);
given().get(show).then().body("finalized", equalTo(false), "violations", hasSize(1));
given().contentType("application/json")
.body(Map.of("assignments", List.of(List.of(group, group))))
.put(show + "/assignments")
.then()
.statusCode(200);
final String snapshot = given().get(school + "/export").asString();
given().contentType("application/json")
.body(snapshot)
.post(ROOT + "/import/preview")
.then()
.statusCode(200);
} finally {
given().delete(school).then().statusCode(204);
}
}
public @Test void coachIdentityIsExplicitSchoolScopedAndRoundTrips() {
final String school = ROOT + "/" + create(ROOT, Map.of("name", "S18 coaches"));
try {
final long dancer = dancer(school, "Coach");
group(school, dancer);
final String show = show(school);
given().post(show + "/schedule").then().statusCode(200);
given().post(show + "/finalize").then().statusCode(200);
final long coach = given().contentType("application/json")
.body(Map.of("dancerId", dancer))
.post(school + "/coaches")
.then()
.statusCode(200)
.extract()
.jsonPath()
.getLong("id");
given().get(show).then().body("finalized", equalTo(false));
given().get(school + "/coaches").then().body("[0].dancerId", equalTo((int) dancer));
final String exported = given().get(school + "/export").asString();
given().contentType("application/json")
.body(exported)
.post(ROOT + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
.then()
.statusCode(200);
given().get(school + "/export").then().body("coaches[0].dancerId", equalTo("1"));
final long importedDancer =
given().get(school + "/dancers").jsonPath().getLong("[0].id");
given().contentType("application/json")
.body(Map.of("dancerId", importedDancer))
.put(school + "/coaches/0")
.then()
.statusCode(200);
given().delete(school + "/coaches/0/dancer").then().statusCode(204);
given().contentType("application/json")
.body(Map.of("dancerId", -1))
.post(school + "/coaches")
.then()
.statusCode(404);
given().contentType("application/json")
.body(Map.of("dancerId", importedDancer))
.put(school + "/coaches/9999")
.then()
.statusCode(404);
} finally {
given().delete(school).then().statusCode(204);
}
}
}

View File

@@ -0,0 +1,133 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class S18SolverTest {
private GroupRequirement group(final long id, final int count, final boolean showcase, final long dancer) {
return new GroupRequirement(id, count, showcase, Set.of(dancer), Set.of(), 20);
}
private SchedulingResult solve(final SchedulingInput input) {
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input);
assertTrue(ScheduleRules.violations(input, result.assignments()).isEmpty());
assertTrue(result.optimal());
return result;
}
public @Test void showcasesBookendEveryPartAndSharedDancersHaveChangingSpace() {
final List<GroupRequirement> groups = List.of(
group(1, 2, true, 1),
group(2, 2, true, 2),
group(3, 2, true, 3),
group(4, 1, false, 1),
group(5, 1, false, 5),
group(6, 1, false, 6),
group(7, 1, false, 7));
final SchedulingInput input = new SchedulingInput(1, 0, 2, groups, 1, Set.of());
final SchedulingResult result = solve(input);
for (final List<Long> part : result.assignments()) {
assertTrue(part.getFirst() <= 3);
assertTrue(part.getLast() <= 3);
}
assertEquals(
1,
result.assignments().stream()
.filter((final List<Long> p) -> p.contains(4L))
.count());
}
public @Test void ordinaryDancerMustShareDaypartsAcrossTheirGroups() {
final SchedulingInput input = new SchedulingInput(
1,
0,
2,
List.of(group(1, 1, false, 1), group(2, 1, false, 1), group(3, 1, false, 3), group(4, 1, false, 4)),
1,
Set.of());
assertThrows(ManagementException.class, () -> solve(input));
final SchedulingInput coach = new SchedulingInput(1, 0, 2, input.groups(), 1, Set.of(1L));
final SchedulingResult result = solve(coach);
assertTrue(result.assignments().stream().allMatch((final List<Long> p) -> p.size() == 2));
}
public @Test void showcaseExcessTakesPriorityOverPerfectBalance() {
final SchedulingInput input = new SchedulingInput(
1,
0,
2,
List.of(
group(1, 2, true, 1),
group(2, 1, false, 1),
group(3, 1, false, 1),
group(4, 1, false, 4),
group(5, 1, false, 5),
group(6, 1, false, 6),
group(7, 1, false, 7),
group(8, 1, false, 8),
group(9, 1, false, 9),
group(10, 1, false, 10),
group(11, 1, false, 11)),
1,
Set.of());
final SchedulingResult result = solve(input);
assertTrue(result.assignments().stream().anyMatch((final List<Long> p) -> p.containsAll(List.of(2L, 3L))));
assertEquals(
2,
Math.abs(result.assignments().get(0).size()
- result.assignments().get(1).size()));
}
public @Test void changingTimeAppliesToCoachesAndShowcasesWithoutExceptions() {
final SchedulingInput input =
new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 1)), 1, Set.of(1L));
assertThrows(ManagementException.class, () -> solve(input));
assertTrue(ScheduleRules.violations(input, List.of(List.of(1L, 2L))).stream()
.anyMatch((final String s) -> s.contains("two intervening")));
}
public @Test void singleShowcaseCanUseEitherBoundaryAndNoShowcaseHasNoBoundaryRule() {
solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, true, 1), group(2, 1, false, 2)), 1, Set.of()));
solve(new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of()));
}
public @Test void sharedValidatorRejectsMalformedAndIncompleteDrafts() {
final SchedulingInput input = new SchedulingInput(1, 0, 1, List.of(group(1, 1, false, 1)), 1, Set.of());
assertFalse(ScheduleRules.violations(input, null).isEmpty());
assertFalse(ScheduleRules.violations(input, List.of()).isEmpty());
assertFalse(ScheduleRules.violations(input, List.of(List.of())).isEmpty());
assertFalse(ScheduleRules.violations(input, List.of(List.of(1L, 1L))).isEmpty());
assertFalse(ScheduleRules.violations(input, List.of(List.of(99L))).isEmpty());
final SchedulingInput empty = new SchedulingInput(1, 0, 0, List.of(), 1, Set.of());
assertFalse(ScheduleRules.violations(empty, List.of()).isEmpty());
}
public @Test void varietyUsesStyleOverlapAndFiveYearLinearAgePenalty() {
final GroupRequirement hiphop =
new GroupRequirement(1, 1, false, Set.of(1L), Set.of(DanceGroupStyle.HIPHOP), 10);
final GroupRequirement mixed = new GroupRequirement(
2, 1, false, Set.of(2L), Set.of(DanceGroupStyle.HIPHOP, DanceGroupStyle.MODERN), 12.5);
final GroupRequirement modern =
new GroupRequirement(3, 1, false, Set.of(3L), Set.of(DanceGroupStyle.MODERN), 15);
assertEquals(1_000_000, VarietyScore.penalty(hiphop, mixed));
assertEquals(0, VarietyScore.penalty(hiphop, modern));
final List<Long> order = solve(new SchedulingInput(1, 0, 1, List.of(hiphop, mixed, modern), 1, Set.of()))
.assignments()
.getFirst();
assertEquals(1, Math.abs(order.indexOf(1L) - order.indexOf(3L)));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ async function confirmDelete(page: Page) {
test('manages a school, shared memberships and shows through the real REST API', async ({
page,
request,
}) => {
const schoolName = `E2E Studio ${Date.now()}`;
const browserErrors: string[] = [];
@@ -107,6 +108,26 @@ test('manages a school, shared memberships and shows through the real REST API',
).toBeVisible();
await page.screenshot({ path: 'test-results/groups-desktop.png', fullPage: true });
// Two independent performances give Sam time to change between the shared groups.
for (const name of ['Modern adults', 'Streetdance adults']) {
const dancerResponse = await request.post(
`${backend}/api/dance-schools/${schoolToDelete}/dancers`,
{
data: { name, birthDate: '1990-01-01' },
},
);
const dancer = (await dancerResponse.json()) as { id: number };
const groupResponse = await request.post(
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups`,
{
data: { category: name, type: 'REGULAR', startAge: 0, endAge: 99, styles: ['MODERN'] },
},
);
const group = (await groupResponse.json()) as { id: number };
await request.put(
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups/${group.id}/dancers/${dancer.id}`,
);
}
await tab(page, 'Shows');
await page.getByRole('button', { name: 'Show toevoegen' }).click();
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
@@ -120,8 +141,19 @@ test('manages a school, shared memberships and shows through the real REST API',
await expect(page.locator('.date-tile')).toContainText('jul 2027');
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: /Schedule groups/ }).click();
await expect(page.getByText('Planning voldoet aan alle optredensaantallen.')).toBeVisible();
await expect(page.getByRole('status').filter({ hasText: 'Optimale balans.' })).toBeVisible();
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
await expect(
page.getByRole('status').filter({ hasText: 'Optimaal conceptrooster.' }),
).toBeVisible();
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
const scheduleDialog = page.getByRole('dialog');
await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click();
await expect(scheduleDialog.getByText('Definitief', { exact: false }).first()).toBeVisible();
await scheduleDialog.getByRole('button', { name: 'Optreden verwijderen' }).first().click();
await scheduleDialog.getByRole('button', { name: 'Concept opslaan' }).click();
await expect(scheduleDialog.locator('.alert.error').first()).toBeVisible();
await expect(scheduleDialog.getByRole('button', { name: 'Definitief maken' })).toBeDisabled();
await scheduleDialog.getByRole('button', { name: 'Gereed', exact: true }).click();
await page.getByRole('button', { name: 'Zomershow bewerken' }).click();
await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click();
await page.getByLabel('Datum', { exact: true }).nth(1).fill('2027-07-02');
@@ -148,7 +180,7 @@ test('manages a school, shared memberships and shows through the real REST API',
await tab(page, 'Dansers');
await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click();
await confirmDelete(page);
await expect(page.getByText('De dansvloer is nog leeg')).toBeVisible();
await expect(page.getByRole('cell', { name: 'Sam Testdanser', exact: true })).toHaveCount(0);
await tab(page, 'Dansgroepen');
await expect(page.getByText(/0 dansers/)).toHaveCount(2);
await page.getByRole('button', { name: 'Hiphop Selectie verwijderen' }).click();
@@ -166,6 +198,66 @@ test('manages a school, shared memberships and shows through the real REST API',
expect(browserErrors).toEqual([]);
});
test('links a coach and finalizes performances spread across multiple dates', async ({
page,
request,
}) => {
const schoolResponse = await request.post(`${backend}/api/dance-schools`, {
data: { name: `Coach show ${Date.now()}` },
});
const school = (await schoolResponse.json()) as School;
schoolToDelete = school.id;
const base = `${backend}/api/dance-schools/${school.id}`;
const dancerResponse = await request.post(`${base}/dancers`, {
data: { name: 'Dancing coach', birthDate: '1990-01-01' },
});
const dancer = (await dancerResponse.json()) as { id: number };
const groupIds: number[] = [];
for (const name of ['Preschool', 'Adults']) {
const response = await request.post(`${base}/dance-groups`, {
data: {
category: name,
type: name === 'Preschool' ? 'PRE_SCHOOLER' : 'REGULAR',
startAge: 0,
endAge: 99,
styles: [],
},
});
const group = (await response.json()) as { id: number };
groupIds.push(group.id);
await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`);
}
const showResponse = await request.post(`${base}/shows`, {
data: {
name: 'Coach across dates',
preferredDayParts: 1,
dayParts: [
{ date: '2027-06-01', type: 'AFTERNOON' },
{ date: '2027-06-02', type: 'AFTERNOON' },
],
},
});
const show = (await showResponse.json()) as { id: number };
await request.put(`${base}/shows/${show.id}/assignments`, {
data: { assignments: groupIds.map((id) => [id]) },
});
await page.goto(`/schools/${school.id}/shows`);
await expect(page.getByText(/exceeds preferredDayParts=1/)).toBeVisible();
await tab(page, 'Dansers');
await page.getByText('Coaches koppelen aan dansers', { exact: true }).click();
await page.getByLabel('Danser als coach registreren').selectOption({ label: 'Dancing coach' });
await expect(page.getByRole('combobox', { name: 'Dancing coach', exact: true })).toBeVisible();
await tab(page, 'Shows');
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }).click();
await expect(
page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }),
).toBeDisabled();
const finalResponse = await request.get(`${base}/shows/${show.id}`);
expect(((await finalResponse.json()) as { finalized: boolean }).finalized).toBe(true);
});
test('shows a useful connection error and recovers on retry', async ({ page }) => {
await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed'));
await page.goto('/schools');

View File

@@ -27,11 +27,19 @@ export interface DayPart {
type: DayPartType;
danceGroupIds: number[];
}
export interface Coach {
id: number;
name: string;
dancerId: number | null;
}
export interface Show {
id: number;
name: string;
dayParts: DayPart[];
appearances: Partial<Record<GroupType, number>>;
preferredDayParts: number;
finalized: boolean;
violations: string[];
}
export interface SchoolRequest {
name: string;
@@ -51,6 +59,7 @@ export interface ShowRequest {
name: string;
dayParts: { date: string; type: DayPartType }[];
appearances?: Partial<Record<GroupType, number>>;
preferredDayParts?: number;
}
export type ResourceKind = 'school' | 'dancer' | 'group' | 'show';
export type EditorTarget =
@@ -63,6 +72,7 @@ export interface SchoolData {
dancers: Dancer[];
groups: DanceGroup[];
shows: Show[];
coaches: Coach[];
}
export const GROUP_TYPES: { value: GroupType; label: string }[] = [
{ value: 'REGULAR', label: 'Regulier' },

View File

@@ -24,7 +24,14 @@ describe('SchoolApi contract', () => {
http.expectOne('/api/dance-schools/7/dance-groups').flush([]);
expect(received).not.toHaveBeenCalled();
http.expectOne('/api/dance-schools/7/shows').flush([]);
expect(received).toHaveBeenCalledWith({ school, dancers: [], groups: [], shows: [] });
http.expectOne('/api/dance-schools/7/coaches').flush([]);
expect(received).toHaveBeenCalledWith({
school,
dancers: [],
groups: [],
shows: [],
coaches: [],
});
});
it('updates an existing entity even when its id is zero', () => {

View File

@@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { forkJoin, Observable } from 'rxjs';
import {
Coach,
DanceGroup,
Dancer,
DancerRequest,
@@ -29,6 +30,7 @@ export class SchoolApi {
dancers: this.http.get<Dancer[]>(`${base}/dancers`),
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
shows: this.http.get<Show[]>(`${base}/shows`),
coaches: this.http.get<Coach[]>(`${base}/coaches`),
});
}
saveSchool(body: SchoolRequest, id?: number): Observable<School> {
@@ -52,6 +54,20 @@ export class SchoolApi {
{ timeout: 1860000 },
);
}
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> {
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, {
assignments,
});
}
finalize(schoolId: number, showId: number): Observable<Show> {
return this.http.post<Show>(`${this.base}/${schoolId}/shows/${showId}/finalize`, null);
}
linkCoach(schoolId: number, dancerId: number, coachId?: number): Observable<Coach> {
return this.save<Coach>(`${this.base}/${schoolId}/coaches`, { dancerId }, coachId);
}
unlinkCoach(schoolId: number, coachId: number): Observable<void> {
return this.http.delete<void>(`${this.base}/${schoolId}/coaches/${coachId}/dancer`);
}
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
kind

View File

@@ -151,6 +151,7 @@
</section>
}
@case ('dancers') {
<app-coach-editor [data]="context" (changed)="reload()" />
<div class="section-heading">
<div>
<h2>Dansers</h2>
@@ -350,6 +351,16 @@
</div>
<div class="show-details">
<h3>{{ show.name }}</h3>
<p>
{{ show.finalized ? 'Definitief' : 'Concept' }} · preferredDayParts =
{{ show.preferredDayParts }}
</p>
@for (violation of show.violations; track $index) {
<p class="alert error">{{ violation }}</p>
}
<button type="button" class="button secondary" (click)="scheduleEditing.set(show)">
Indeling en volgorde bewerken
</button>
<p>{{ dateRange(show) }}</p>
@for (part of show.dayParts; track $index) {
<p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</p>
@@ -357,7 +368,7 @@
<p role="status">
{{
scheduleValid(show)
? 'Planning voldoet aan alle optredensaantallen.'
? 'Planning voldoet aan alle harde regels.'
: 'Geen geldige planning. Plan de groepen opnieuw in.'
}}
</p>
@@ -365,7 +376,10 @@
type="button"
class="button primary"
[disabled]="
scheduling().includes(show.id) || !context.groups.length || !show.dayParts.length
scheduling().includes(show.id) ||
!context.groups.length ||
!show.dayParts.length ||
show.preferredDayParts > show.dayParts.length
"
(click)="schedule(show)"
>
@@ -426,6 +440,15 @@
(saved)="saved()"
/>
}
@if (scheduleEditing(); as show) {
<app-schedule-editor
[show]="show"
[groups]="context.groups"
[schoolId]="context.school.id"
(closed)="scheduleEditing.set(null)"
(saved)="reload()"
/>
}
@if (members(); as group) {
<app-group-members
[group]="group"

View File

@@ -17,6 +17,8 @@ import { apiError } from '../core/api-error';
import { SchoolApi } from '../core/school-api';
import { EntityEditor } from '../shared/entity-editor';
import { ConfirmDialog } from '../shared/confirm-dialog';
import { ScheduleEditor } from '../shared/schedule-editor';
import { CoachEditor } from '../shared/coach-editor';
import { GroupMembers } from '../shared/group-members';
interface DeleteTarget {
@@ -29,6 +31,8 @@ interface DeleteTarget {
@Component({
selector: 'app-school-page',
imports: [
ScheduleEditor,
CoachEditor,
SchoolTransfer,
DatePipe,
RouterLink,
@@ -50,6 +54,7 @@ export class SchoolPage implements OnInit {
readonly error = signal('');
readonly notice = signal('');
readonly editor = signal<EditorTarget | null>(null);
readonly scheduleEditing = signal<Show | null>(null);
readonly members = signal<DanceGroup | null>(null);
readonly deleting = signal<DeleteTarget | null>(null);
readonly scheduling = signal<number[]>([]);
@@ -86,6 +91,7 @@ export class SchoolPage implements OnInit {
this.search.set('');
this.editor.set(null);
this.members.set(null);
this.scheduleEditing.set(null);
this.deleting.set(null);
this.section.set(params.get('section') ?? 'overview');
return this.api.schoolData(Number(params.get('schoolId'))).pipe(
@@ -121,22 +127,9 @@ export class SchoolPage implements OnInit {
return !first ? 'No dates configured' : first === last ? first : `${first} – ${last}`;
}
scheduleValid(show: Show): boolean {
const groups = this.data()?.groups ?? [];
return (
!!groups.length &&
!!show.dayParts.length &&
groups.every(
(g) =>
show.dayParts.filter((p) => p.danceGroupIds.includes(g.id)).length ===
show.appearances[g.type],
) &&
show.dayParts.every(
(p) =>
new Set(p.danceGroupIds).size === p.danceGroupIds.length &&
p.danceGroupIds.every((id) => groups.some((g) => g.id === id)),
)
);
return show.violations?.length === 0;
}
groupNamesFor(ids: number[]): string {
return (
ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') ||
@@ -163,7 +156,9 @@ export class SchoolPage implements OnInit {
try {
const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
this.notice.set(
result.optimal ? 'Optimale balans.' : 'Geldige planning; optimale balans niet bewezen.',
result.optimal
? 'Optimaal conceptrooster.'
: 'Geldig conceptrooster; optimaliteit niet bewezen.',
);
await this.reload();
} catch (error: unknown) {
@@ -221,6 +216,7 @@ export class SchoolPage implements OnInit {
this.deleting.set(target);
}
membershipChanged(ids: number[]): void {
void this.reload();
const groupId = this.members()?.id;
this.data.update((data) =>
data

View File

@@ -0,0 +1,70 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { SchoolData } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { apiError } from '../core/api-error';
@Component({
selector: 'app-coach-editor',
imports: [FormsModule],
template: `
<details>
<summary>Coaches koppelen aan dansers</summary>
<p>
Coaches mogen in alle dagdelen dansen. Voeg ze als danser toe aan elke groep waarin ze
meedansen. Ook voor coaches blijven twee andere optredens tussen hun dansen verplicht.
</p>
@if (error()) {
<p role="alert" class="alert error">{{ error() }}</p>
}
<fieldset [disabled]="busy()">
@for (coach of data().coaches; track coach.id) {
<label
>{{ coach.name }}
<select [ngModel]="coach.dancerId" (ngModelChange)="link($event, coach.id)">
<option [ngValue]="null">Geen danser gekoppeld</option>
@for (dancer of data().dancers; track dancer.id) {
<option [ngValue]="dancer.id">{{ dancer.name }}</option>
}
</select>
</label>
}
<label
>Danser als coach registreren
<select [ngModel]="null" (ngModelChange)="link($event)">
<option [ngValue]="null">Kies een danser</option>
@for (dancer of data().dancers; track dancer.id) {
<option [ngValue]="dancer.id">{{ dancer.name }}</option>
}
</select>
</label>
</fieldset>
</details>
`,
})
export class CoachEditor {
readonly data = input.required<SchoolData>();
readonly changed = output<void>();
readonly busy = signal(false);
readonly error = signal('');
private readonly api = inject(SchoolApi);
async link(dancerId: number | null, coachId?: number): Promise<void> {
if (this.busy() || (dancerId === null && coachId === undefined)) return;
this.busy.set(true);
this.error.set('');
try {
await firstValueFrom<unknown>(
dancerId === null
? this.api.unlinkCoach(this.data().school.id, coachId!)
: this.api.linkCoach(this.data().school.id, dancerId, coachId),
);
this.changed.emit();
} catch (error: unknown) {
this.error.set(apiError(error));
} finally {
this.busy.set(false);
}
}
}

View File

@@ -70,6 +70,9 @@ describe('EntityEditor', () => {
const show: Show = {
id: 3,
name: 'Summer',
preferredDayParts: 2,
finalized: false,
violations: [],
dayParts: [
{ date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] },
{ date: '2027-06-02', type: 'AFTERNOON', danceGroupIds: [4] },
@@ -77,7 +80,7 @@ describe('EntityEditor', () => {
appearances: { REGULAR: 2, SELECTION: 2, PRE_SCHOOLER: 1, SHOW_CASE: 2, PRE_EDUCATION: 2 },
};
it('initializes defaults for every type and rejects duplicate dated dayparts and fractional counts', () => {
it('defaults preferredDayParts to two and rejects duplicate dated dayparts and fractional preferredDayParts', () => {
const component = editor({ kind: 'show' });
component.name = 'Show';
expect(component.validDetails()).toBe(true);
@@ -87,31 +90,45 @@ describe('EntityEditor', () => {
component.dayParts[1].date = '2027-06-02';
component.addPart();
component.dayParts[2].date = '2027-06-03';
expect(component.appearances).toEqual({
REGULAR: 2,
SELECTION: 2,
PRE_SCHOOLER: 1,
SHOW_CASE: 3,
PRE_EDUCATION: 3,
});
expect(component.preferredDayParts).toBe(2);
expect(component.validDetails()).toBe(true);
component.dayParts[2].date = '2027-06-02';
expect(component.validDetails()).toBe(false);
component.dayParts[2].date = '2027-06-03';
component.appearances.PRE_SCHOOLER = 1.5;
component.preferredDayParts = 1.5;
expect(component.validDetails()).toBe(false);
});
it('preserves saved counts and requires explicit correction when reducing to one daypart', () => {
it('preserves an explicitly chosen preferredDayParts when adding dayparts to a new show', async () => {
api.saveShow.mockReturnValue(of(show));
const component = editor({ kind: 'show' });
component.name = 'Show';
component.addPart();
component.dayParts[0].date = '2027-06-01';
component.addPart();
component.dayParts[1].date = '2027-06-02';
component.preferredDayParts = 1;
component.addPart();
component.dayParts[2].date = '2027-06-03';
expect(component.preferredDayParts).toBe(1);
await component.save();
expect(api.saveShow).toHaveBeenCalledWith(
7,
expect.objectContaining({ preferredDayParts: 1 }),
undefined,
);
});
it('retains preferredDayParts when dayparts are removed so it can be explicitly corrected', () => {
const component = editor({ kind: 'show', value: show });
component.addPart();
component.dayParts[2].date = '2027-06-03';
expect(component.appearances.SHOW_CASE).toBe(2);
expect(component.preferredDayParts).toBe(2);
component.removePart(2);
component.removePart(1);
expect(component.appearances.REGULAR).toBe(2);
expect(component.validDetails()).toBe(false);
component.setSingleCounts();
expect(component.preferredDayParts).toBe(2);
expect(component.validDetails()).toBe(true);
component.preferredDayParts = 1;
expect(component.validDetails()).toBe(true);
});
@@ -120,11 +137,11 @@ describe('EntityEditor', () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
try {
const component = editor({ kind: 'show', value: show });
component.appearances.REGULAR = 1;
component.preferredDayParts = 1;
await component.save();
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
expect(api.saveShow).not.toHaveBeenCalled();
component.appearances.REGULAR = 2;
component.preferredDayParts = 2;
confirm.mockClear();
component.name = 'Renamed';
await component.save();
@@ -147,7 +164,7 @@ describe('EntityEditor', () => {
}
});
it('displays one disabled appearance field for every type with one daypart', async () => {
it('displays one editable preferredDayParts field with a default of one for a one-daypart show', async () => {
// jsdom does not implement native dialog opening.
const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal');
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
@@ -163,8 +180,8 @@ describe('EntityEditor', () => {
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const inputs = Array.from(element.querySelectorAll<HTMLInputElement>('input[type="number"]'));
expect(inputs).toHaveLength(5);
expect(inputs.every((input) => input.disabled && input.value === '1')).toBe(true);
expect(inputs).toHaveLength(1);
expect(inputs.every((input) => !input.disabled && input.value === '1')).toBe(true);
} finally {
if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor);
else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal');

View File

@@ -130,33 +130,22 @@ import { DialogFrame } from './dialog-frame';
</button>
</fieldset>
@if (dayParts.length) {
<fieldset>
<legend>Optredens per groepstype</legend>
@for (option of types; track option.value) {
<label
>{{ option.label
}}<input
type="number"
[name]="'count' + option.value"
[ngModel]="dayParts.length === 1 ? 1 : appearances[option.value]"
(ngModelChange)="appearances[option.value] = $event"
required
min="1"
[max]="dayParts.length"
step="1"
[disabled]="dayParts.length === 1"
/></label>
}
<p class="form-hint">
Bestaande aantallen blijven behouden bij wijzigingen. Corrigeer te hoge aantallen
vóór opslaan.
</p>
@if (dayParts.length === 1 && !validCounts()) {
<button type="button" class="button secondary" (click)="setSingleCounts()">
Alle aantallen op één zetten
</button>
}
</fieldset>
<label
>Gewenst aantal dagdelen (x)
<input
type="number"
name="preferredDayParts"
[(ngModel)]="preferredDayParts"
min="1"
max="6"
step="1"
required
/>
</label>
<p class="form-hint">
Gewone groepen dansen in precies x dagdelen. Kleuters eenmaal; showcases ieder
dagdeel. Is x groter dan het aantal dagdelen, corrigeer x vóór inplannen.
</p>
} @else {
<p class="form-hint">Zonder dagdelen blijft deze show ongepland.</p>
}
@@ -196,8 +185,16 @@ export class EntityEditor implements OnInit {
readonly today = new Date().toLocaleDateString('en-CA');
private readonly api = inject(SchoolApi);
dayParts: DayPart[] = [];
appearances: Partial<Record<GroupType, number>> = {};
private countsInitialized = false;
private preferredDayPartsValue = 2;
private preferredDayPartsEdited = false;
get preferredDayParts(): number {
return this.preferredDayPartsValue;
}
set preferredDayParts(value: number) {
this.preferredDayPartsValue = value;
this.preferredDayPartsEdited = true;
}
name = '';
date = '';
type: GroupType = 'REGULAR';
@@ -223,44 +220,24 @@ export class EntityEditor implements OnInit {
if (target.kind === 'dancer') this.date = target.value.birthDate;
if (target.kind === 'show') {
this.dayParts = target.value.dayParts.map((p) => ({ ...p }));
this.appearances = { ...target.value.appearances };
this.countsInitialized = Object.keys(this.appearances).length > 0;
this.preferredDayParts = target.value.preferredDayParts;
}
}
}
addPart(): void {
if (this.dayParts.length >= 6) return;
const previous = this.dayParts.length;
this.dayParts.push({ date: '', type: 'AFTERNOON', danceGroupIds: [] });
if (!this.countsInitialized) {
for (const { value } of this.types) {
const defaultCount = (n: number) =>
value === 'PRE_SCHOOLER'
? 1
: value === 'SHOW_CASE' || value === 'PRE_EDUCATION'
? n
: Math.min(2, n);
if (
this.appearances[value] === undefined ||
this.appearances[value] === defaultCount(previous)
)
this.appearances[value] = defaultCount(this.dayParts.length);
}
}
if (!this.target().value && !this.preferredDayPartsEdited)
this.preferredDayPartsValue = Math.min(2, this.dayParts.length);
}
removePart(index: number): void {
this.dayParts.splice(index, 1);
if (!this.countsInitialized && !this.dayParts.length) this.appearances = {};
}
setSingleCounts(): void {
for (const option of this.types) this.appearances[option.value] = 1;
}
validCounts(): boolean {
return this.types.every(
(o) =>
Number.isInteger(this.appearances[o.value]) &&
this.appearances[o.value]! >= 1 &&
this.appearances[o.value]! <= this.dayParts.length,
return (
Number.isInteger(this.preferredDayParts) &&
this.preferredDayParts >= 1 &&
this.preferredDayParts <= 6
);
}
toggleStyle(style: GroupStyle, checked: boolean): void {
@@ -300,7 +277,7 @@ export class EntityEditor implements OnInit {
current.value &&
(JSON.stringify(current.value.dayParts.map((p) => ({ date: p.date, type: p.type }))) !==
JSON.stringify(this.dayParts.map((p) => ({ date: p.date, type: p.type }))) ||
JSON.stringify(current.value.appearances) !== JSON.stringify(this.appearances))
current.value.preferredDayParts !== this.preferredDayParts)
)
affected = [current.value];
const names = affected
@@ -349,7 +326,7 @@ export class EntityEditor implements OnInit {
{
name: this.name.trim(),
dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })),
appearances: this.appearances,
preferredDayParts: this.preferredDayParts,
},
id,
);

View File

@@ -0,0 +1,88 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';
import { Show, DanceGroup } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { ScheduleEditor } from './schedule-editor';
const groups: DanceGroup[] = [1, 2, 3].map((id) => ({
id,
category: `Group ${id}`,
type: 'REGULAR',
startAge: 0,
endAge: 99,
styles: [],
dancerIds: [id],
}));
const show: Show = {
id: 4,
name: 'Show',
preferredDayParts: 1,
finalized: true,
violations: [],
appearances: {},
dayParts: [
{ date: '2027-01-01', type: 'MORNING', danceGroupIds: [1, 2] },
{ date: '2027-01-02', type: 'MORNING', danceGroupIds: [3] },
],
};
describe('ScheduleEditor', () => {
const api = { saveAssignments: vi.fn(), finalize: vi.fn() };
beforeEach(() => {
vi.resetAllMocks();
TestBed.configureTestingModule({ providers: [{ provide: SchoolApi, useValue: api }] });
});
function editor() {
const fixture = TestBed.createComponent(ScheduleEditor);
fixture.componentRef.setInput('show', show);
fixture.componentRef.setInput('groups', groups);
fixture.componentRef.setInput('schoolId', 7);
fixture.componentInstance.ngOnInit();
return fixture.componentInstance;
}
it('edits order and dayparts without changing the saved input', () => {
const component = editor();
component.reorder(0, 1, -1);
component.move(0, 0, 1);
component.add(0, 3);
component.remove(1, 0);
expect(component.assignments).toEqual([[1, 3], [2]]);
expect(show.dayParts[0].danceGroupIds).toEqual([1, 2]);
expect(component.dirty()).toBe(true);
});
it('saves invalid drafts and displays server violations without finalizing', async () => {
const component = editor();
component.remove(0, 0);
const draft = { ...show, finalized: false, violations: ['Group 1 requires 1 dayparts.'] };
api.saveAssignments.mockReturnValue(of(draft));
await component.save();
expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]]);
expect(component.current()).toEqual(draft);
expect(component.dirty()).toBe(false);
expect(api.finalize).not.toHaveBeenCalled();
});
it('keeps unsaved edits when saving fails', async () => {
const component = editor();
component.remove(0, 0);
api.saveAssignments.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
await component.save();
expect(component.assignments).toEqual([[2], [3]]);
expect(component.dirty()).toBe(true);
expect(component.error()).toContain('niet bereikbaar');
expect(component.busy()).toBe(false);
});
it('finalizes through the server and reports a stale validation failure', async () => {
const component = editor();
api.finalize.mockReturnValue(of(show));
await component.finalize();
expect(component.current()?.finalized).toBe(true);
api.finalize.mockReturnValue(
throwError(
() => new HttpErrorResponse({ status: 400, error: { message: 'Changing gap violated' } }),
),
);
await component.finalize();
expect(component.error()).toContain('Changing gap violated');
});
});

View File

@@ -0,0 +1,178 @@
import { Component, inject, input, OnInit, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { DanceGroup, Show } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { apiError } from '../core/api-error';
import { DialogFrame } from './dialog-frame';
@Component({
selector: 'app-schedule-editor',
imports: [FormsModule, DialogFrame],
template: `
<app-dialog-frame
[title]="show().name + ' — indeling'"
[busy]="busy()"
(dismissed)="closed.emit()"
>
<p>
{{ !dirty() && current()?.finalized ? 'Definitief' : 'Concept' }} · preferredDayParts =
{{ show().preferredDayParts }}
</p>
<p>
Tussen twee dansen van dezelfde danser zijn twee andere optredens nodig. Je kunt een
onvolledige indeling als concept opslaan.
</p>
@if (error()) {
<p role="alert" class="alert error">{{ error() }}</p>
}
@if (dirty()) {
<p role="status">
Niet-opgeslagen wijzigingen. Sla het concept op om alle regels te controleren.
</p>
}
@for (violation of current()?.violations ?? []; track $index) {
<p class="alert error">{{ violation }}</p>
}
<fieldset [disabled]="busy()">
@for (part of show().dayParts; track $index; let p = $index) {
<fieldset>
<legend>
{{ part.date }} · {{ part.type }} ({{ assignments[p].length }} optredens)
</legend>
<ol>
@for (id of assignments[p]; track $index; let i = $index) {
<li>
{{ name(id) }}
<button
type="button"
[disabled]="i === 0"
(click)="reorder(p, i, -1)"
[attr.aria-label]="name(id) + ' omhoog'"
>
↑
</button>
<button
type="button"
[disabled]="i === assignments[p].length - 1"
(click)="reorder(p, i, 1)"
[attr.aria-label]="name(id) + ' omlaag'"
>
↓
</button>
<label
>Verplaatsen naar
<select [ngModel]="p" (ngModelChange)="move(p, i, +$event)">
@for (target of show().dayParts; track $index; let t = $index) {
<option [ngValue]="t">{{ target.date }} · {{ target.type }}</option>
}
</select>
</label>
<button type="button" (click)="remove(p, i)">Optreden verwijderen</button>
</li>
}
</ol>
<label
>Groep toevoegen
<select [ngModel]="null" (ngModelChange)="add(p, +$event)">
<option [ngValue]="null">Kies een groep</option>
@for (group of groups(); track group.id) {
@if (group.dancerIds.length && !assignments[p].includes(group.id)) {
<option [ngValue]="group.id">{{ group.category }}</option>
}
}
</select>
</label>
</fieldset>
}
</fieldset>
<div class="dialog-actions">
<button type="button" class="button secondary" [disabled]="busy()" (click)="closed.emit()">
Gereed
</button>
<button
type="button"
class="button primary"
[disabled]="busy() || !dirty()"
(click)="save()"
>
Concept opslaan
</button>
<button
type="button"
class="button primary"
[disabled]="busy() || dirty() || current()?.finalized || !!current()?.violations?.length"
(click)="finalize()"
>
Definitief maken
</button>
</div>
</app-dialog-frame>
`,
})
export class ScheduleEditor implements OnInit {
readonly show = input.required<Show>();
readonly groups = input.required<DanceGroup[]>();
readonly schoolId = input.required<number>();
readonly closed = output<void>();
readonly saved = output<void>();
readonly busy = signal(false);
readonly dirty = signal(false);
readonly error = signal('');
readonly current = signal<Show | null>(null);
private readonly api = inject(SchoolApi);
assignments: number[][] = [];
ngOnInit(): void {
this.current.set(this.show());
this.assignments = this.show().dayParts.map((p) => [...p.danceGroupIds]);
}
name(id: number): string {
return this.groups().find((g) => g.id === id)?.category ?? `Groep ${id}`;
}
reorder(p: number, i: number, delta: number): void {
const list = this.assignments[p];
[list[i], list[i + delta]] = [list[i + delta], list[i]];
this.dirty.set(true);
}
move(p: number, i: number, target: number): void {
if (target === p || this.assignments[target].includes(this.assignments[p][i])) return;
this.assignments[target].push(this.assignments[p].splice(i, 1)[0]);
this.dirty.set(true);
}
add(p: number, id: number): void {
if (!id || this.assignments[p].includes(id)) return;
this.assignments[p].push(id);
this.dirty.set(true);
}
remove(p: number, i: number): void {
this.assignments[p].splice(i, 1);
this.dirty.set(true);
}
async save(): Promise<void> {
await this.perform(false);
}
async finalize(): Promise<void> {
await this.perform(true);
}
private async perform(finalize: boolean): Promise<void> {
if (this.busy()) return;
this.busy.set(true);
this.error.set('');
try {
const result = await firstValueFrom(
finalize
? this.api.finalize(this.schoolId(), this.show().id)
: this.api.saveAssignments(this.schoolId(), this.show().id, this.assignments),
);
this.current.set(result);
this.assignments = result.dayParts.map((p) => [...p.danceGroupIds]);
this.dirty.set(false);
this.saved.emit();
} catch (error: unknown) {
this.error.set(apiError(error));
} finally {
this.busy.set(false);
}
}
}

View File

@@ -37,6 +37,9 @@ public class Ci {
case "install-runner" ->
Setup.installRunner(root, env, commands, args.length == 2 && args[1].equals("--prepare-only"));
case "test" -> CiTests.run();
case "deploy", "rollback" -> Release.execute(root, env, args[0]);
case "release-test" -> ReleaseTests.run();
case "configure-deployment" -> ReleaseSetup.configure(root, env);
default -> throw new IllegalArgumentException(usage());
}
} catch (Commands.Failure e) {
@@ -54,6 +57,6 @@ public class Ci {
static String usage() {
return "Usage: java ci/Ci.java verify|publish|cleanup|attempt|test|configure-gitea|configure-docker"
+ " <path>|install-runner [--prepare-only]";
+ " <path>|install-runner [--prepare-only]|deploy|rollback|release-test|configure-deployment";
}
}

View File

@@ -101,6 +101,7 @@ class Pipeline {
run("node", "--version");
run("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify");
CiTests.run();
ReleaseTests.run();
Path frontend = root.resolve("agiledancers-fe");
verifyFrontend();
String platform = env.get("TARGET_PLATFORM");
@@ -148,6 +149,10 @@ class Pipeline {
"1g",
"--cpus",
"2",
"--volume",
root.resolve("agiledancers-be/target/coverage-tools/agent.jar") + ":/jacoco-agent.jar:ro",
"--env",
"JAVA_TOOL_OPTIONS=-javaagent:/jacoco-agent.jar=output=tcpserver,address=*,port=6300",
backendImage);
run(
"docker",
@@ -202,6 +207,37 @@ class Pipeline {
"npm",
"run",
"e2e");
run(
"docker",
"run",
"--rm",
"--network",
runName,
"--user",
user,
"--volume",
root.resolve("agiledancers-be/target") + ":/coverage",
"eclipse-temurin:25-jre",
"java",
"-jar",
"/coverage/coverage-tools/cli.jar",
"dump",
"--address",
"backend",
"--destfile",
"/coverage/playwright.exec");
check(
Files.size(root.resolve("agiledancers-be/target/playwright.exec")) > 0,
"Playwright backend coverage is missing");
run(
"mvn",
"-B",
"-ntp",
"-pl",
"agiledancers-be",
"jacoco:merge@combined",
"jacoco:report@combined-report",
"exec:exec@combined-coverage");
Files.writeString(artifacts.resolve("verified-commit"), commit + "\n");
} catch (Exception e) {
failure = e;
@@ -270,6 +306,10 @@ class Pipeline {
List<String> failures = new ArrayList<>();
for (String path : List.of(
"agiledancers-be/target/surefire-reports",
"agiledancers-be/target/standards",
"tooling/standards/target/surefire-reports",
"agiledancers-be/target/site/jacoco",
"agiledancers-be/target/site/jacoco-combined",
"agiledancers-be/target/failsafe-reports",
"agiledancers-fe/unit-results",
"agiledancers-fe/test-results",
@@ -282,6 +322,11 @@ class Pipeline {
}
}
}
for (String data : List.of("jacoco.exec", "playwright.exec", "jacoco-combined.exec")) {
final Path source = root.resolve("agiledancers-be/target").resolve(data);
if (Files.isRegularFile(source))
Files.copy(source, artifacts.resolve(data), StandardCopyOption.REPLACE_EXISTING);
}
if (result(false, "docker", "info").code() == 0) {
for (String role : List.of("backend", "frontend", "browser")) {
String container = runName + "-" + role;

234
ci/Release.java Normal file
View File

@@ -0,0 +1,234 @@
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** Manual release coordinator. All mutation decisions are made under the NAS lock. */
class Release {
final Path root, artifacts;
final Map<String, String> env;
final ReleaseApi api;
final ReleaseRemote remote;
final String mode, increment, retry, pinned, attempt;
ReleaseState.Deployment selected;
boolean locked, pending;
static void execute(Path root, Map<String, String> env, String operation) throws Exception {
validate(env, operation);
String attempt = "release-" + env.get("CI_RUN_ID") + "-" + UUID.randomUUID();
Path artifacts = root.resolve("ci/artifacts/deployment");
Files.createDirectories(artifacts);
var commands = new Commands(Duration.ofMinutes(15));
commands.log = artifacts.resolve(attempt + ".log");
Files.writeString(commands.log, "");
try (var remote = new ReleaseRemote(root, env, commands, attempt)) {
new Release(root, env, new ReleaseApi(env), remote, operation, attempt).run();
}
}
static void validate(Map<String, String> env, String operation) {
Pipeline.check("refs/heads/main".equals(env.get("CI_REF")), "Production workflows must run from main");
Pipeline.check(
"workflow_dispatch".equals(env.get("CI_EVENT")), "Production requires a manual workflow dispatch");
Pipeline.check(Setup.required(env, "CI_RUN_ID").matches("[0-9]+"), "Invalid Gitea run ID");
ReleaseApi.sha(env.get("CI_COMMIT"));
Pipeline.check(List.of("deploy", "rollback").contains(operation), "Unknown release operation");
if (operation.equals("rollback")) return;
String mode = Setup.required(env, "RELEASE_MODE");
String increment = env.getOrDefault("RELEASE_INCREMENT", "");
String retry = env.getOrDefault("RELEASE_VERSION", "");
if (mode.equals("new")) {
Pipeline.check(List.of("patch", "minor", "major").contains(increment), "Choose patch, minor or major");
Pipeline.check(retry.isBlank(), "New release cannot specify an existing version");
} else {
Pipeline.check(mode.equals("retry"), "Choose new or retry release mode");
ReleaseVersion.parse(retry);
// Gitea shows all inputs; the new-release increment is ignored for retries.
}
}
Release(Path root, Map<String, String> env, ReleaseApi api, ReleaseRemote remote, String operation, String attempt)
throws Exception {
this.root = root;
this.env = env;
this.api = api;
this.remote = remote;
this.attempt = attempt;
artifacts = root.resolve("ci/artifacts/deployment");
Files.createDirectories(artifacts);
mode = operation.equals("rollback") ? "rollback" : env.get("RELEASE_MODE");
increment = env.getOrDefault("RELEASE_INCREMENT", "");
retry = env.getOrDefault("RELEASE_VERSION", "");
pinned = ReleaseApi.sha(env.get("CI_COMMIT"));
}
void run() throws Exception {
String outcome = "rejected";
try {
remote.acquire();
locked = true;
remote.call("preflight", null);
ReleaseState before = ReleaseState.parse(remote.call("state", null));
selected = select(before);
System.out.println("Selected " + selected.version() + " at " + selected.sha());
// Validate the recovery pair before any replacement too.
if (before.current() != null) ensure(before.current());
if (mode.equals("new")) {
api.create(selected.version(), selected.sha(), attempt);
Pipeline.check(
selected.sha().equals(api.head()),
"Main advanced; release tagged but production unchanged. Retry explicitly.");
}
var journal = new LinkedHashMap<String, Object>();
journal.put("attempt", attempt);
journal.put("mode", mode);
journal.put("candidate", selected.json());
journal.put("before", Json.parse(before.current() == null ? "{}" : before.json()));
remote.call("pending", Json.write(journal));
pending = true;
outcome = "replacement-started";
try {
remote.call("apply", null, selected.directory());
} catch (Exception failure) {
diagnose(selected);
outcome = recover(before);
throw new IllegalArgumentException(
"Deployment failed: " + outcome + ". See deployment diagnostics and recovery runbook.");
}
// A lost reply here is ambiguous: retain journal and lock, never guess
// whether a state commit happened and launch an overlapping recovery.
outcome = "healthy-state-commit-pending";
remote.call("commit", before.success(selected).json());
outcome = "success";
record(outcome);
remote.call("finish", null);
pending = false;
System.out.println("Production " + selected.version() + " is healthy (" + selected.sha() + ")");
} finally {
try {
record(outcome);
} finally {
if (locked && !pending) remote.release();
else if (locked)
System.err.println("NAS lock and pending journal retained. Follow docs/production-deployment.md for"
+ " recovery.");
}
}
}
ReleaseState.Deployment select(ReleaseState before) throws Exception {
if (mode.equals("rollback")) {
Pipeline.check(before.previous() != null, "No previous successful production release is available");
ensure(before.previous());
return before.previous();
}
List<ReleaseApi.Tag> tags = api.tags();
ReleaseVersion version;
String commit;
if (mode.equals("new")) {
Pipeline.check(
pinned.equals(api.head()), "Main advanced since this dispatch; start a new release workflow");
Pipeline.check(
tags.stream().noneMatch(t -> t.sha().equals(pinned)),
"This commit already has a release tag; use retry or rollback");
version = ReleaseVersion.next(
tags.stream().map(ReleaseApi.Tag::version).toList(), increment);
commit = pinned;
} else {
version = ReleaseVersion.parse(retry);
var tag = tags.stream()
.filter(t -> t.version().equals(version))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Retry release tag does not exist"));
api.annotated(tag);
commit = tag.sha();
}
Pipeline.check(
before.highest() == null || version.compareTo(before.highest()) >= (mode.equals("new") ? 1 : 0),
"A newer release has already succeeded; use explicit rollback for older versions");
String build = api.verified(commit);
remote.call("stage", Files.readString(root.resolve("deploy/compose.yaml")));
String prepared = remote.call("prepare", null, version.toString(), commit, prefix());
return new ReleaseState.Deployment(
version, commit, digest(prepared, "BACKEND"), digest(prepared, "FRONTEND"), attempt, build);
}
String prefix() {
var uri = URI.create(api.server);
String authority = uri.getRawAuthority();
String registry = env.getOrDefault("DEPLOY_REGISTRY", "").strip();
if (registry.isEmpty()) registry = authority;
int port = uri.getPort() >= 0 ? uri.getPort() : uri.getScheme().equals("https") ? 443 : 80;
Pipeline.check(
registry.equals(authority) || registry.equals("127.0.0.1:" + port),
"Deployment registry must be the existing Gitea registry or its NAS loopback alias");
String owner = Setup.required(env, "CI_REPOSITORY").split("/")[0].toLowerCase(java.util.Locale.ROOT);
return registry + "/" + owner + "/agiledancers-";
}
void ensure(ReleaseState.Deployment release) throws Exception {
remote.call(
"ensure",
null,
release.directory(),
release.version().toString(),
release.sha(),
release.backend(),
release.frontend());
}
String digest(String response, String role) {
String value = response.lines()
.filter(line -> line.startsWith("S6_" + role + "="))
.map(line -> line.substring(("S6_" + role + "=").length()))
.reduce((a, b) -> {
throw new IllegalArgumentException("Ambiguous prepared image digest");
})
.orElseThrow(() -> new IllegalArgumentException("Missing prepared image digest"));
Pipeline.check(
value.startsWith(prefix() + role.toLowerCase(java.util.Locale.ROOT) + "@sha256:"),
"Unexpected image repository");
return value;
}
String recover(ReleaseState before) throws Exception {
if (before.current() == null) return "first-release-failed-manual-recovery-required";
try {
remote.call("apply", null, before.current().directory());
} catch (Exception failure) {
diagnose(before.current());
return "recovery-failed-manual-recovery-required";
}
record("failed-previous-release-restored");
remote.call("finish", null);
pending = false;
return "previous-release-restored";
}
void diagnose(ReleaseState.Deployment release) {
try {
Files.writeString(
artifacts.resolve(attempt + "-" + release.directory() + "-containers.log"),
remote.call("diagnose", null, release.directory()));
} catch (Exception ignored) {
System.err.println("Container diagnostics unavailable; use the NAS recovery runbook");
}
}
void record(String outcome) throws Exception {
var data = new LinkedHashMap<String, Object>();
data.put("attempt", attempt);
data.put("mode", mode);
data.put("outcome", outcome);
data.put("time", Instant.now().toString());
data.put("release", selected == null ? null : selected.json());
String json = Json.write(data) + "\n";
Files.writeString(artifacts.resolve(attempt + ".json"), json);
if (locked) remote.call("event", json);
}
}

124
ci/ReleaseApi.java Normal file
View File

@@ -0,0 +1,124 @@
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** Gitea 1.25 API adapter. Never include response bodies or tokens in errors. */
class ReleaseApi {
record Tag(ReleaseVersion version, String sha, String object) {}
final String server, token, base;
ReleaseApi(Map<String, String> env) {
server = Setup.required(env, "GITEA_SERVER_URL");
var uri = URI.create(server);
Pipeline.check(
List.of("http", "https").contains(uri.getScheme())
&& uri.getUserInfo() == null
&& uri.getQuery() == null
&& uri.getFragment() == null
&& (uri.getPath().isEmpty() || uri.getPath().equals("/")),
"Gitea must use a server-root URL");
String repository = Setup.required(env, "CI_REPOSITORY");
Pipeline.check(repository.matches("[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+"), "Invalid repository name");
base = "/api/v1/repos/" + repository;
token = Setup.required(env, "RELEASE_TOKEN");
}
Object request(String method, String path, Object body) throws Exception {
return Setup.api(server, token, method, base + path, body);
}
String head() throws Exception {
return sha(
Json.object(Json.object(request("GET", "/branches/main", null)).get("commit"))
.get("id"));
}
static String sha(Object value) {
String result = Objects.toString(value, "");
Pipeline.check(result.matches("[0-9a-f]{40}"), "Invalid release commit SHA");
return result;
}
List<Tag> tags() throws Exception {
List<Tag> tags = new ArrayList<>();
var seen = new java.util.HashSet<String>();
for (int page = 1; ; page++) {
var entries = (List<?>) request("GET", "/tags?limit=50&page=" + page, null);
if (entries.isEmpty()) return tags;
for (Object entry : entries) {
var tag = Json.object(entry);
String name = Objects.toString(tag.get("name"), "");
Pipeline.check(
seen.add(name),
"Remote tag pagination repeated a tag; cannot establish a safe version baseline");
// Unknown version-looking tags require explicit reconciliation, not a second baseline.
if (!name.matches("v?[0-9].*")) continue;
ReleaseVersion version = ReleaseVersion.parse(name);
String commit = sha(Json.object(tag.get("commit")).get("sha"));
String object = sha(tag.get("id"));
tags.add(new Tag(version, commit, object));
}
}
}
void annotated(Tag tag) throws Exception {
var data = Json.object(request("GET", "/git/tags/" + tag.object(), null));
Pipeline.check(
tag.version().toString().equals(data.get("tag"))
&& tag.sha().equals(Json.object(data.get("object")).get("sha")),
"Release tag must be annotated and point directly to its recorded commit");
}
void create(ReleaseVersion version, String commit, String run) throws Exception {
request(
"POST",
"/tags",
Map.of(
"tag_name",
version.toString(),
"target",
commit,
"message",
"Agiledancers " + version + "\nCommit: " + commit + "\nDeployment attempt: " + run));
Tag tag = tags().stream()
.filter(t -> t.version().equals(version))
.findFirst()
.orElseThrow();
Pipeline.check(tag.sha().equals(commit), "Created release tag does not match selected commit");
annotated(tag);
}
String verified(String commit) throws Exception {
// A successful build.yaml main-push run includes verification AND successful image publication.
// Do not accept another workflow, a branch build, or merely an existing image/tag.
var seen = new java.util.HashSet<String>();
for (int page = 1; ; page++) {
var response = Json.object(request(
"GET",
"/actions/runs?head_sha=" + commit + "&event=push&branch=main&status=success&limit=50&page=" + page,
null));
var runs = (List<?>) response.get("workflow_runs");
if (runs.isEmpty()) break;
boolean fresh = false;
for (Object entry : runs) {
var run = Json.object(entry);
fresh |= seen.add(Objects.toString(run.get("id")));
if (commit.equals(run.get("head_sha"))
&& "push".equals(run.get("event"))
&& List.of("main", "refs/heads/main").contains(Objects.toString(run.get("head_branch")))
&& "completed".equals(run.get("status"))
&& "success".equals(run.get("conclusion"))
&& "build.yaml@refs/heads/main".equals(run.get("path"))) {
return Objects.toString(run.get("id"));
}
}
// Older Gitea installations may ignore some list filters/pagination.
// Validate each entry locally and stop if a page makes no progress.
if (!fresh) break;
}
throw new IllegalArgumentException("No successful build.yaml main-push run verifies and publishes this SHA");
}
}

119
ci/ReleaseRemote.java Normal file
View File

@@ -0,0 +1,119 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/** Argument-safe SSH adapter; server-side mkdir lock is shared by every runner. */
class ReleaseRemote implements AutoCloseable {
final Path root, credentials;
final Map<String, String> env;
final Commands commands;
final String owner, directory, host, user, port, script;
ReleaseRemote(Path root, Map<String, String> env, Commands commands, String owner) throws Exception {
this.root = root;
this.env = env;
this.commands = commands;
this.owner = owner;
directory = Setup.required(env, "DEPLOY_DIRECTORY");
host = Setup.required(env, "DEPLOY_HOST");
user = Setup.required(env, "DEPLOY_USER");
port = env.getOrDefault("DEPLOY_SSH_PORT", "22");
Pipeline.check(
directory.matches("/[a-zA-Z0-9_./-]+") && !directory.contains("..") && !directory.equals("/"),
"Use an absolute dedicated deployment directory without spaces or parent traversal");
Pipeline.check(
host.matches("[a-zA-Z0-9][a-zA-Z0-9.-]*")
&& user.matches("[a-zA-Z0-9_][a-zA-Z0-9_-]*")
&& port.matches("[0-9]{1,5}")
&& Integer.parseInt(port) > 0
&& Integer.parseInt(port) <= 65535,
"Invalid deployment SSH host, user or port");
script = Files.readString(root.resolve("deploy/remote.sh"));
credentials = Files.createTempDirectory("agiledancers-deploy-ssh-");
try {
writePrivate("identity", Setup.required(env, "DEPLOY_SSH_KEY") + "\n");
writePrivate("known_hosts", Setup.required(env, "DEPLOY_KNOWN_HOSTS") + "\n");
} catch (Exception e) {
close();
throw e;
}
}
void writePrivate(String name, String value) throws Exception {
Path file = credentials.resolve(name);
Files.createFile(file, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Files.writeString(file, value);
}
static String quote(String value) {
return "'" + value.replace("'", "'\"'\"'") + "'";
}
String call(String action, String input, String... arguments) throws Exception {
int seconds = switch (action) {
case "apply" -> 300;
case "prepare", "ensure" -> 600;
default -> 60;
};
var parameters = new ArrayList<>(List.of(action, directory, owner));
parameters.addAll(List.of(arguments));
return executeScript(script, input, seconds, parameters);
}
String executeScript(String source, String input, int seconds, List<String> parameters) throws Exception {
String remote = "timeout -k 5 " + seconds + " sh -c " + quote(source) + " --";
for (String argument : parameters) remote += " " + quote(argument);
var args = new ArrayList<>(List.of(
"ssh",
"-F",
"/dev/null",
"-T",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=yes",
"-o",
"IdentitiesOnly=yes",
"-o",
"IdentityAgent=none",
"-o",
"ForwardAgent=no",
"-o",
"ConnectTimeout=10",
"-o",
"ServerAliveInterval=10",
"-o",
"ServerAliveCountMax=3",
"-o",
"LogLevel=ERROR",
"-o",
"UserKnownHostsFile=" + credentials.resolve("known_hosts"),
"-o",
"GlobalKnownHostsFile=/dev/null",
"-i",
credentials.resolve("identity").toString(),
"-p",
port,
user + "@" + host,
remote));
commands.renew(Duration.ofSeconds(seconds + 30));
return commands.execute(args, root, env, input, false).checked();
}
void acquire() throws Exception {
call("lock", null);
}
void release() throws Exception {
call("unlock", null);
}
@Override
public void close() throws Exception {
Pipeline.deleteTree(credentials);
}
}

107
ci/ReleaseSetup.java Normal file
View File

@@ -0,0 +1,107 @@
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** One-time provisioning; secrets stay in owner-only files or Gitea secret storage. */
class ReleaseSetup {
static void configure(Path root, Map<String, String> input) throws Exception {
var env = new HashMap<>(input);
env.putIfAbsent("GITEA_SERVER_URL", "http://192.168.2.28:3000");
env.putIfAbsent("CI_REPOSITORY", "wanjakrah/agiledancers");
env.putIfAbsent("DEPLOY_HOST", "192.168.2.28");
env.putIfAbsent("DEPLOY_USER", "agiledancers-deploy");
env.putIfAbsent("DEPLOY_SSH_PORT", "22");
env.putIfAbsent("DEPLOY_DIRECTORY", "/volume1/docker/agiledancers-production");
env.putIfAbsent("DEPLOY_BIND_IP", "192.168.2.28");
env.putIfAbsent("DEPLOY_FRONTEND_PORT", "8088");
env.putIfAbsent("DEPLOY_REGISTRY", "127.0.0.1:3000");
Path config = Setup.home().resolve(".config/agiledancers-runner");
env.putIfAbsent("GITEA_ADMIN_TOKEN_FILE", config.resolve("admin-token").toString());
env.putIfAbsent("RELEASE_TOKEN_FILE", config.resolve("s6-release-token").toString());
env.putIfAbsent("PULL_TOKEN_FILE", config.resolve("s6-pull-token").toString());
env.putIfAbsent("DEPLOY_SSH_KEY_FILE", config.resolve("s6-deploy-key").toString());
String admin = Setup.secret(env, "GITEA_ADMIN_TOKEN_FILE");
String pull = Setup.secret(env, "PULL_TOKEN_FILE");
env.put("RELEASE_TOKEN", Setup.secret(env, "RELEASE_TOKEN_FILE"));
env.put("DEPLOY_SSH_KEY", Setup.secret(env, "DEPLOY_SSH_KEY_FILE"));
String bind = env.get("DEPLOY_BIND_IP"), port = env.get("DEPLOY_FRONTEND_PORT");
Pipeline.check(
bind.matches("[0-9.]+")
&& port.matches("[0-9]{4,5}")
&& Integer.parseInt(port) >= 1024
&& Integer.parseInt(port) <= 65535,
"Invalid private bind address or port");
var commands = new Commands(Duration.ofMinutes(15));
Path diagnostics = root.resolve("ci/artifacts/deployment");
Files.createDirectories(diagnostics);
commands.log = diagnostics.resolve("setup.log");
Files.writeString(commands.log, "");
String host = env.get("DEPLOY_HOST");
String lookup = env.get("DEPLOY_SSH_PORT").equals("22") ? host : "[" + host + "]:" + env.get("DEPLOY_SSH_PORT");
env.put(
"DEPLOY_KNOWN_HOSTS",
commands.execute(
List.of(
"ssh-keygen",
"-F",
lookup,
"-f",
Setup.home().resolve(".ssh/known_hosts").toString()),
root,
env,
null,
false)
.checked());
Pipeline.check(!env.get("DEPLOY_KNOWN_HOSTS").isBlank(), "A previously trusted NAS hostkey is required");
var api = new ReleaseApi(env);
api.verified(api.head());
var uri = URI.create(api.server);
String registry = env.get("DEPLOY_REGISTRY");
int registryPort = uri.getPort() >= 0 ? uri.getPort() : uri.getScheme().equals("https") ? 443 : 80;
Pipeline.check(
registry.equals(uri.getRawAuthority()) || registry.equals("127.0.0.1:" + registryPort),
"Deployment registry must be the Gitea registry or its NAS loopback alias");
String registryUser =
env.getOrDefault("PULL_USER", env.get("CI_REPOSITORY").split("/")[0]);
Pipeline.check(registryUser.matches("[a-zA-Z0-9_.-]+"), "Invalid registry user");
try (var remote = new ReleaseRemote(root, env, commands, "setup")) {
System.out.println(remote.executeScript(
Files.readString(root.resolve("deploy/provision.sh")),
pull + "\n",
300,
List.of(remote.directory, registryUser, registry, bind, port)));
remote.acquire();
try {
remote.call("preflight", null);
} finally {
remote.release();
}
}
// Provision credentials only after successful NAS login and release-read verification.
for (String secret : List.of("RELEASE_TOKEN", "DEPLOY_SSH_KEY", "DEPLOY_KNOWN_HOSTS"))
Setup.api(
api.server, admin, "PUT", api.base + "/actions/secrets/" + secret, Map.of("data", env.get(secret)));
for (String variable :
List.of("DEPLOY_HOST", "DEPLOY_USER", "DEPLOY_SSH_PORT", "DEPLOY_DIRECTORY", "DEPLOY_REGISTRY"))
variable(api, admin, variable, env.get(variable));
System.out.println(
"Production configuration and Gitea deployment secrets/variables installed; no release was created.");
}
static void variable(ReleaseApi api, String admin, String name, String value) throws Exception {
String path = api.base + "/actions/variables/" + name;
boolean exists;
try {
Setup.api(api.server, admin, "GET", path, null);
exists = true;
} catch (Setup.ApiFailure error) {
if (error.status != 404) throw error;
exists = false;
}
Setup.api(api.server, admin, exists ? "PUT" : "POST", path, Map.of("value", value));
}
}

197
ci/ReleaseShellTests.java Normal file
View File

@@ -0,0 +1,197 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Runs the actual NAS script with disposable command adapters, no Docker daemon. */
class ReleaseShellTests {
static void run() throws Exception {
ReleaseTests.test("actual NAS script prepares digest images and checks the published endpoint", () -> {
try (var f = new Fixture()) {
f.prepare();
f.call("pending", "{}", "one").checked();
CiTests.equal("S6_HEALTHY", f.call("apply", null, "one", "one").checked());
String calls = Files.readString(f.directory.resolve("calls"));
CiTests.equal(true, calls.contains("http://backend:8080/q/health/ready"));
CiTests.equal(true, calls.contains("http://127.0.0.1:8088/api/dance-schools"));
CiTests.equal(false, calls.contains(" down"));
CiTests.equal(false, calls.contains("prune"));
}
});
ReleaseTests.test("actual NAS script rejects wrong image revision before replacement", () -> {
try (var f = new Fixture()) {
f.env.put("WRONG_REVISION", "1");
CiTests.failure(f::prepare);
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
}
});
ReleaseTests.test("actual NAS script rejects unavailable image before replacement", () -> {
try (var f = new Fixture()) {
f.env.put("FAIL_PULL", "1");
CiTests.failure(f::prepare);
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
}
});
ReleaseTests.test("actual NAS script rejects a changed retained release record", () -> {
try (var f = new Fixture()) {
f.prepare();
CiTests.failure(() -> f.call(
"ensure", null, "one", "one", "v9.0.0", ReleaseTests.SHA, f.backend(), f.frontend())
.checked());
f.call("ensure", null, "one", "one", "v1.0.0", ReleaseTests.SHA, f.backend(), f.frontend())
.checked();
}
});
ReleaseTests.test("actual NAS replacement timeout is bounded and leaves recovery journal", () -> {
try (var f = new Fixture()) {
f.prepare();
f.call("pending", "{}", "one").checked();
f.env.put("FAIL_HEALTH", "1");
CiTests.equal(1, f.call("apply", null, "one", "one", "1").code());
CiTests.equal(true, Files.exists(f.directory.resolve("pending.json")));
// Termination releases only the operation fence, not the deployment lock.
CiTests.equal(false, Files.exists(f.directory.resolve("deploy.lock/operation")));
CiTests.equal(true, Files.exists(f.directory.resolve("deploy.lock/owner")));
f.env.remove("FAIL_HEALTH");
f.call("apply", null, "one", "one").checked();
}
});
ReleaseTests.test("actual NAS script fences overlapping remote replacements", () -> {
try (var f = new Fixture()) {
f.prepare();
Files.createDirectory(f.directory.resolve("deploy.lock/operation"));
CiTests.failure(() -> f.call("apply", null, "one", "one").checked());
CiTests.equal(false, Files.exists(f.directory.resolve("live.env")));
}
});
ReleaseTests.test("actual NAS script stores state atomically and archives resolved journal", () -> {
try (var f = new Fixture()) {
f.call("lock", null, "one").checked();
f.call("pending", "{\"attempt\":\"one\"}", "one").checked();
f.call("commit", "{\"current\":\"one\"}", "one").checked();
CiTests.equal(
"{\"current\":\"one\"}", f.call("state", null, "one").checked());
f.call("event", "{\"outcome\":\"success\"}", "one").checked();
f.call("finish", null, "one").checked();
f.call("unlock", null, "one").checked();
CiTests.equal(true, Files.exists(f.directory.resolve("history/one.json")));
CiTests.equal(false, Files.exists(f.directory.resolve("pending.json")));
}
});
}
static class Fixture implements AutoCloseable {
final Path directory = Files.createTempDirectory("nas-script-test-");
final Map<String, String> env = new HashMap<>(System.getenv());
final String script = Files.readString(Path.of("deploy/remote.sh"));
final Commands commands = new Commands(Duration.ofMinutes(1));
Fixture() throws Exception {
Files.createDirectory(directory.resolve("bin"));
executable("docker", DOCKER);
executable(
"curl",
"#!/bin/sh\nprintf 'curl %s\\n' \"$*\" >> \"$FIXTURE/calls\"\n[ \"${FAIL_HEALTH:-0}\" = 0 ]\n");
env.put("PATH", directory.resolve("bin") + ":" + env.get("PATH"));
env.put("FIXTURE", directory.toString());
Files.createDirectory(directory.resolve("docker-auth"));
Files.writeString(directory.resolve("docker-auth/config.json"), "{}");
Files.writeString(
directory.resolve("production.env"),
"BIND_IP=127.0.0.1\nFRONTEND_PORT=8088\nDOCKER_SUDO=0\nDOCKER_BIN="
+ directory.resolve("bin/docker") + "\nDOCKER_CONFIG=" + directory.resolve("docker-auth")
+ "\n");
}
void executable(String name, String body) throws Exception {
Path path = directory.resolve("bin").resolve(name);
Files.writeString(path, body);
Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rwx------"));
}
Commands.Result call(String action, String input, String owner, String... args) throws Exception {
var command = new ArrayList<>(
List.of("timeout", "-k", "1", "10", "sh", "-c", script, "--", action, directory.toString(), owner));
command.addAll(List.of(args));
commands.renew(Duration.ofSeconds(15));
return commands.execute(command, directory, env, input, false);
}
String backend() {
return "nas:3000/owner/agiledancers-backend@sha256:" + "1".repeat(64);
}
String frontend() {
return "nas:3000/owner/agiledancers-frontend@sha256:" + "2".repeat(64);
}
void prepare() throws Exception {
call("lock", null, "one").checked();
call("preflight", null, "one").checked();
call("stage", Files.readString(Path.of("deploy/compose.yaml")), "one")
.checked();
String result = call("prepare", null, "one", "v1.0.0", ReleaseTests.SHA, "nas:3000/owner/agiledancers-")
.checked();
CiTests.equal(true, result.contains("S6_BACKEND=" + backend()));
CiTests.equal(true, result.contains("S6_FRONTEND=" + frontend()));
}
@Override
public void close() throws Exception {
Pipeline.deleteTree(directory);
}
}
static final String DOCKER = """
#!/bin/sh
set -eu
printf 'docker %s\n' "$*" >> "$FIXTURE/calls"
[ "$1" != --config ] || shift 2
case "$1" in
info) echo linux/x86_64;;
pull) [ "${FAIL_PULL:-0}" = 0 ];;
image)
format=$4; image=$5
case "$image" in *backend*) role=backend; digit=1;; *) role=frontend; digit=2;; esac
case "$format" in
*revision*)
if [ "${WRONG_REVISION:-0}" = 1 ]; then echo wrong; else printf '%040d\n' 0 | tr 0 a; fi;;
*Architecture*) echo linux/amd64;;
*RepoDigests*)
printf '%s@sha256:' "${image%:*}"; printf '%064d\n' 0 | tr 0 "$digit";;
*Id*) echo "sha256:$role";;
*) exit 1;;
esac;;
compose)
shift
[ "$1" != version ] || exit 0
shift 4
[ "$1" = --env-file ] || exit 1
file=$2
shift 4
case "$1" in
config) test -f "$file";;
up) cp "$file" "$FIXTURE/live.env";;
ps) eval 'role=${'"$#"'}'; echo "$role";;
logs) echo 'sample container diagnostics';;
*) exit 1;;
esac;;
inspect)
. "$FIXTURE/live.env"
format=$3; id=$4
case "$format" in
*State.Running*) echo true;;
*Image*) echo "sha256:$id";;
*revision*) echo "$RELEASE_SHA";;
*release*) echo "$RELEASE_VERSION";;
*) exit 1;;
esac;;
exec) [ "${FAIL_HEALTH:-0}" = 0 ];;
*) exit 1;;
esac
""";
}

80
ci/ReleaseState.java Normal file
View File

@@ -0,0 +1,80 @@
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/** Successful deployment state is separate from immutable allocated version tags. */
record ReleaseState(ReleaseState.Deployment current, ReleaseState.Deployment previous, ReleaseVersion highest) {
record Deployment(
ReleaseVersion version, String sha, String backend, String frontend, String directory, String build) {
Deployment {
ReleaseApi.sha(sha);
Pipeline.check(directory.matches("[a-zA-Z0-9-]+"), "Invalid retained release directory");
for (String image : new String[] {backend, frontend})
Pipeline.check(image.matches("[a-zA-Z0-9.:/_-]+@sha256:[0-9a-f]{64}"), "Invalid retained image digest");
}
Map<String, Object> json() {
return Map.of(
"version",
version.toString(),
"sha",
sha,
"backend",
backend,
"frontend",
frontend,
"directory",
directory,
"build",
build);
}
static Deployment parse(Object value) {
if (value == null) return null;
var data = Json.object(value);
return new Deployment(
ReleaseVersion.parse(text(data, "version")),
text(data, "sha"),
text(data, "backend"),
text(data, "frontend"),
text(data, "directory"),
text(data, "build"));
}
}
static String text(Map<String, Object> data, String key) {
return Objects.toString(data.get(key), "");
}
static ReleaseState parse(String value) {
var data = Json.object(Json.parse(value));
if (data.isEmpty()) return new ReleaseState(null, null, null);
Pipeline.check("1".equals(text(data, "schema")), "Unsupported deployment state schema");
Deployment current = Deployment.parse(data.get("current"));
Deployment previous = Deployment.parse(data.get("previous"));
ReleaseVersion highest = ReleaseVersion.parse(text(data, "highest"));
Pipeline.check(
current != null
&& highest.compareTo(current.version()) >= 0
&& (previous == null || highest.compareTo(previous.version()) >= 0),
"Inconsistent successful release state");
return new ReleaseState(current, previous, highest);
}
String json() {
var data = new LinkedHashMap<String, Object>();
data.put("schema", "1");
data.put("current", current == null ? null : current.json());
data.put("previous", previous == null ? null : previous.json());
data.put("highest", highest == null ? null : highest.toString());
return Json.write(data) + "\n";
}
ReleaseState success(Deployment selected) {
ReleaseVersion watermark =
highest == null || selected.version().compareTo(highest) > 0 ? selected.version() : highest;
// A redeploy of the same release must not discard the distinct rollback target.
Deployment fallback = current != null && current.version().equals(selected.version()) ? previous : current;
return new ReleaseState(selected, fallback, watermark);
}
}

549
ci/ReleaseTests.java Normal file
View File

@@ -0,0 +1,549 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Release state-machine and real POSIX remote-script tests, without server writes. */
class ReleaseTests {
static final String SHA = "a".repeat(40), OLD = "b".repeat(40);
static int count;
static void test(String name, CiTests.Test test) throws Exception {
test.run();
count++;
System.out.println("PASS release: " + name);
}
static Map<String, String> environment() {
var env = new HashMap<>(System.getenv());
env.putAll(Map.of(
"CI_REF",
"refs/heads/main",
"CI_EVENT",
"workflow_dispatch",
"CI_RUN_ID",
"123",
"CI_COMMIT",
SHA,
"RELEASE_MODE",
"new",
"RELEASE_INCREMENT",
"patch",
"RELEASE_VERSION",
"",
"GITEA_SERVER_URL",
"http://nas:3000",
"CI_REPOSITORY",
"owner/agiledancers",
"RELEASE_TOKEN",
"test"));
env.putAll(Map.of(
"DEPLOY_HOST",
"nas",
"DEPLOY_USER",
"deploy",
"DEPLOY_DIRECTORY",
"/tmp/production",
"DEPLOY_SSH_KEY",
"test-private-key",
"DEPLOY_KNOWN_HOSTS",
"test-pinned-host"));
return env;
}
static ReleaseState.Deployment deployment(String version, String sha, String directory) {
return new ReleaseState.Deployment(
ReleaseVersion.parse(version),
sha,
"nas:3000/owner/agiledancers-backend@sha256:" + "1".repeat(64),
"nas:3000/owner/agiledancers-frontend@sha256:" + "2".repeat(64),
directory,
"10");
}
static ReleaseState prior() {
return new ReleaseState(
deployment("v1.0.1", OLD, "previous-current"),
deployment("v1.0.0", OLD, "previous-old"),
ReleaseVersion.parse("v1.0.1"));
}
static void run() throws Exception {
count = 0;
test("first release and all increments reset the correct components", () -> {
CiTests.equal("v1.0.0", ReleaseVersion.next(List.of(), "major").toString());
var versions = List.of(ReleaseVersion.parse("v1.2.3"), ReleaseVersion.parse("v1.1.9"));
CiTests.equal("v1.2.4", ReleaseVersion.next(versions, "patch").toString());
CiTests.equal("v1.3.0", ReleaseVersion.next(versions, "minor").toString());
CiTests.equal("v2.0.0", ReleaseVersion.next(versions, "major").toString());
CiTests.equal(
"v1.0.100000000000000000000",
ReleaseVersion.next(List.of(ReleaseVersion.parse("v1.0.99999999999999999999")), "patch")
.toString());
for (String invalid : List.of("1.0.0", "v01.0.0", "v1.0.0-rc1", "v1.0.0+build", "v1.0", "v-1.0.0"))
CiTests.failure(() -> ReleaseVersion.parse(invalid));
});
test("branch, nonmanual, malformed input and conflicting new-release inputs are rejected", () -> {
var env = environment();
Release.validate(env, "deploy");
env.put("CI_REF", "refs/heads/feature/S6");
CiTests.failure(() -> Release.validate(env, "deploy"));
env.put("CI_REF", "refs/heads/main");
env.put("CI_EVENT", "push");
CiTests.failure(() -> Release.validate(env, "rollback"));
env.put("CI_EVENT", "workflow_dispatch");
env.put("RELEASE_VERSION", "v1.0.0");
CiTests.failure(() -> Release.validate(env, "deploy"));
env.put("RELEASE_MODE", "retry");
Release.validate(env, "deploy");
env.put("RELEASE_VERSION", "bad");
CiTests.failure(() -> Release.validate(env, "deploy"));
});
test("successful release stages images before tag, checks main again and commits state", () -> {
try (var f = new Fixture()) {
f.release.run();
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
CiTests.equal(false, f.remote.locked);
CiTests.equal(false, f.remote.pending);
CiTests.equal(true, f.events.indexOf("prepare") < f.events.indexOf("tag"));
CiTests.equal(true, f.events.indexOf("tag") < f.events.lastIndexOf("head"));
CiTests.equal(true, f.events.lastIndexOf("head") < f.events.indexOf("apply:attempt"));
}
});
test("unverified commit never creates a tag or replaces production", () -> {
try (var f = new Fixture()) {
f.api.verified = false;
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("tag"));
CiTests.equal(false, f.events.contains("apply:attempt"));
CiTests.equal(false, f.remote.locked);
}
});
test("missing images prevent tag allocation and replacement", () -> {
try (var f = new Fixture()) {
f.remote.failAction = "prepare";
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("tag"));
CiTests.equal(false, f.remote.pending);
}
});
test("a tagged commit cannot receive another new version", () -> {
try (var f = new Fixture()) {
f.api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD));
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("prepare"));
}
});
test("stale dispatch is rejected before pulling or tagging", () -> {
try (var f = new Fixture()) {
f.api.head = OLD;
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("prepare"));
}
});
test("main advancing after tag creation preserves tag without changing production", () -> {
try (var f = new Fixture()) {
f.api.advanceAfterTag = true;
CiTests.failure(f.release::run);
CiTests.equal(1, f.api.tags.size());
CiTests.equal(false, f.remote.pending);
CiTests.equal(false, f.events.contains("apply:attempt"));
}
});
test("unavailable recovery images block replacement", () -> {
try (var f = new Fixture()) {
f.withPrior();
f.remote.failAction = "ensure";
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("tag"));
CiTests.equal(false, f.remote.pending);
}
});
test("failed candidate restores prior version and still fails the workflow", () -> {
try (var f = new Fixture()) {
f.withPrior();
f.remote.failedDirectories.add("attempt");
CiTests.failure(f.release::run);
CiTests.equal(
List.of("apply:attempt", "apply:previous-current"),
f.events.stream().filter(e -> e.startsWith("apply:")).toList());
CiTests.equal(prior(), f.remote.state);
CiTests.equal(false, f.remote.pending);
CiTests.equal(false, f.remote.locked);
CiTests.equal(2, f.api.tags.size());
}
});
test("failed first release retains journal and lock for manual recovery", () -> {
try (var f = new Fixture()) {
f.remote.failedDirectories.add("attempt");
CiTests.failure(f.release::run);
CiTests.equal(true, f.remote.pending);
CiTests.equal(true, f.remote.locked);
CiTests.equal(null, f.remote.state.current());
}
});
test("failed automatic recovery retains journal and lock without retry loop", () -> {
try (var f = new Fixture()) {
f.withPrior();
f.remote.failedDirectories.addAll(List.of("attempt", "previous-current"));
CiTests.failure(f.release::run);
CiTests.equal(
2L,
f.events.stream().filter(e -> e.startsWith("apply:")).count());
CiTests.equal(true, f.remote.pending);
CiTests.equal(true, f.remote.locked);
}
});
test("ambiguous state commit never launches speculative rollback", () -> {
try (var f = new Fixture()) {
f.withPrior();
f.remote.failAction = "commit";
CiTests.failure(f.release::run);
CiTests.equal(
1L,
f.events.stream().filter(e -> e.startsWith("apply:")).count());
CiTests.equal(true, f.remote.pending);
CiTests.equal(true, f.remote.locked);
}
});
test("retry keeps existing version and SHA after main advances", () -> {
try (var f = new Fixture("retry")) {
f.api.head = OLD;
f.release.run();
CiTests.equal(SHA, f.remote.state.current().sha());
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
CiTests.equal(false, f.events.contains("tag"));
CiTests.equal(true, f.events.contains("annotated"));
}
});
test("retry cannot cross the successful-version high watermark after rollback", () -> {
try (var f = new Fixture("retry")) {
f.remote.state = prior().success(prior().previous());
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("apply:attempt"));
}
});
test("manual rollback preserves version baseline and creates no tag", () -> {
try (var f = new Fixture("rollback")) {
f.remote.state = prior();
f.release.run();
CiTests.equal("v1.0.0", f.remote.state.current().version().toString());
CiTests.equal("v1.0.1", f.remote.state.previous().version().toString());
CiTests.equal("v1.0.1", f.remote.state.highest().toString());
CiTests.equal(false, f.events.contains("tag"));
}
});
test("redeploying current version preserves a distinct rollback target", () -> {
var state = prior();
CiTests.equal(state.previous(), state.success(state.current()).previous());
CiTests.equal(state, ReleaseState.parse(state.json()));
});
test("NAS lock contention cannot allocate tags or replace production", () -> {
try (var f = new Fixture()) {
f.remote.locked = true;
CiTests.failure(f.release::run);
CiTests.equal(false, f.events.contains("tag"));
CiTests.equal(true, f.remote.locked);
}
});
apiTests();
shellTests();
ReleaseShellTests.run();
System.out.println(count + " release behavior tests passed");
}
static class FakeApi extends ReleaseApi {
final List<String> events;
final List<Tag> tags = new ArrayList<>();
String head = SHA;
boolean verified = true, advanceAfterTag;
FakeApi(Map<String, String> env, List<String> events) {
super(env);
this.events = events;
}
@Override
String head() {
events.add("head");
return head;
}
@Override
List<Tag> tags() {
return List.copyOf(tags);
}
@Override
String verified(String sha) {
Pipeline.check(verified, "Unverified test commit");
events.add("verified");
return "10";
}
@Override
void annotated(Tag tag) {
events.add("annotated");
}
@Override
void create(ReleaseVersion version, String sha, String attempt) {
events.add("tag");
tags.add(new Tag(version, sha, OLD));
if (advanceAfterTag) head = OLD;
}
}
static class FakeRemote extends ReleaseRemote {
final List<String> events;
final List<String> failedDirectories = new ArrayList<>();
ReleaseState state = new ReleaseState(null, null, null);
boolean locked, pending;
String failAction = "";
FakeRemote(Path root, Map<String, String> env, List<String> events) throws Exception {
super(root, env, new Commands(Duration.ofMinutes(1)), "attempt");
this.events = events;
}
@Override
String call(String action, String input, String... args) {
events.add(action.equals("apply") ? action + ":" + args[0] : action);
Pipeline.check(!action.equals(failAction), "Injected remote failure");
switch (action) {
case "lock" -> {
Pipeline.check(!locked && !pending, "Locked");
locked = true;
}
case "unlock" -> {
Pipeline.check(!pending, "Pending");
locked = false;
}
case "state" -> {
return state.current() == null ? "{}" : state.json();
}
case "pending" -> pending = true;
case "finish" -> pending = false;
case "commit" -> state = ReleaseState.parse(input);
case "prepare" -> {
return "S6_BACKEND=" + deployment("v1.0.0", SHA, "attempt").backend() + "\nS6_FRONTEND="
+ deployment("v1.0.0", SHA, "attempt").frontend();
}
case "apply" -> Pipeline.check(!failedDirectories.contains(args[0]), "Injected unhealthy release");
default -> {}
}
return "";
}
}
static class Fixture implements AutoCloseable {
final Path root = Files.createTempDirectory("release-test-");
final Map<String, String> env = environment();
final List<String> events = new ArrayList<>();
final FakeApi api;
final FakeRemote remote;
final Release release;
Fixture() throws Exception {
this("new");
}
Fixture(String mode) throws Exception {
Files.createDirectories(root.resolve("deploy"));
Files.writeString(root.resolve("deploy/remote.sh"), "");
Files.writeString(root.resolve("deploy/compose.yaml"), "services: {}\n");
env.put("RELEASE_MODE", mode);
if (mode.equals("retry")) env.put("RELEASE_VERSION", "v1.0.0");
api = new FakeApi(env, events);
if (mode.equals("retry")) api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD));
remote = new FakeRemote(root, env, events);
release = new Release(root, env, api, remote, mode.equals("rollback") ? "rollback" : "deploy", "attempt");
}
void withPrior() {
remote.state = prior();
api.tags.add(new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.1"), OLD, OLD));
}
@Override
public void close() throws Exception {
remote.close();
Pipeline.deleteTree(root);
}
}
static void apiTests() throws Exception {
test("NAS registry alias keeps the same repository and rejects unrelated registries", () -> {
try (var f = new Fixture()) {
f.env.put("DEPLOY_REGISTRY", "127.0.0.1:3000");
CiTests.equal("127.0.0.1:3000/owner/agiledancers-", f.release.prefix());
f.env.put("DEPLOY_REGISTRY", "other-registry:3000");
CiTests.failure(f.release::prefix);
}
});
test("repeating remote tag pages fail closed instead of allocating from incomplete history", () -> {
var api = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return List.of(Map.of("name", "v1.0.0", "id", OLD, "commit", Map.of("sha", SHA)));
}
};
CiTests.failure(api::tags);
});
test("annotated release tag must identify the recorded version and exact SHA", () -> {
var tag = new ReleaseApi.Tag(ReleaseVersion.parse("v1.0.0"), SHA, OLD);
var api = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return Map.of("tag", "v1.0.0", "object", Map.of("sha", SHA));
}
};
api.annotated(tag);
var wrong = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return Map.of("tag", "v1.0.0", "object", Map.of("sha", OLD));
}
};
CiTests.failure(() -> wrong.annotated(tag));
});
test("Gitea API eligibility validates SHA, event, branch, workflow path and successful conclusion", () -> {
var valid = Map.<String, Object>of(
"id",
1,
"head_sha",
SHA,
"event",
"push",
"head_branch",
"main",
"path",
"build.yaml@refs/heads/main",
"status",
"completed",
"conclusion",
"success");
for (String field : List.of("head_sha", "event", "head_branch", "path", "status", "conclusion")) {
var invalid = new HashMap<>(valid);
invalid.put(field, "wrong");
var api = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return Map.of("workflow_runs", List.of(invalid));
}
};
CiTests.failure(() -> api.verified(SHA));
}
var api = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return Map.of("workflow_runs", List.of(valid));
}
};
CiTests.equal("1", api.verified(SHA));
});
test("remote tag pagination includes failed-release baseline and rejects ambiguous tag formats", () -> {
var api = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return path.endsWith("page=1")
? List.of(Map.of("name", "v1.2.3", "id", OLD, "commit", Map.of("sha", SHA)))
: List.of();
}
};
CiTests.equal(
"v1.2.4",
ReleaseVersion.next(
api.tags().stream()
.map(ReleaseApi.Tag::version)
.toList(),
"patch")
.toString());
var invalid = new ReleaseApi(environment()) {
@Override
Object request(String method, String path, Object body) {
return List.of(Map.of("name", "1.0", "id", OLD, "commit", Map.of("sha", SHA)));
}
};
CiTests.failure(invalid::tags);
});
}
static void shellTests() throws Exception {
test("real remote shell atomically excludes a second owner and retains unresolved journal", () -> {
Path directory = Files.createTempDirectory("release-shell-test-");
try {
String script = Files.readString(Path.of("deploy/remote.sh"));
var commands = new Commands(Duration.ofSeconds(30));
var env = environment();
var first = commands.execute(
List.of("sh", "-c", script, "--", "lock", directory.toString(), "one"),
directory,
env,
null,
false);
first.checked();
CiTests.equal(
1,
commands.execute(
List.of("sh", "-c", script, "--", "lock", directory.toString(), "two"),
directory,
env,
null,
false)
.code());
commands.execute(
List.of("sh", "-c", script, "--", "pending", directory.toString(), "one"),
directory,
env,
"{\"candidate\":\"test\"}",
false)
.checked();
CiTests.equal(
1,
commands.execute(
List.of("sh", "-c", script, "--", "unlock", directory.toString(), "one"),
directory,
env,
null,
false)
.code());
CiTests.equal(true, Files.exists(directory.resolve("pending.json")));
commands.execute(
List.of("sh", "-c", script, "--", "finish", directory.toString(), "one"),
directory,
env,
null,
false)
.checked();
commands.execute(
List.of("sh", "-c", script, "--", "unlock", directory.toString(), "one"),
directory,
env,
null,
false)
.checked();
CiTests.equal(false, Files.exists(directory.resolve("deploy.lock")));
CiTests.equal(true, Files.exists(directory.resolve("history/one.json")));
} finally {
Pipeline.deleteTree(directory);
}
});
test("SSH shell quoting preserves metacharacters as data", () -> {
String value = "quote' dollar$ backtick` newline\n";
var commands = new Commands(Duration.ofSeconds(5));
CiTests.equal(
value,
commands.execute(
List.of("sh", "-c", "printf '%s' " + ReleaseRemote.quote(value)),
Path.of("."),
environment(),
null,
false)
.output());
});
}
}

39
ci/ReleaseVersion.java Normal file
View File

@@ -0,0 +1,39 @@
import java.math.BigInteger;
import java.util.List;
import java.util.regex.Pattern;
/** Strict stable release versions; arbitrary-size integers avoid overflow/reuse. */
record ReleaseVersion(BigInteger major, BigInteger minor, BigInteger patch) implements Comparable<ReleaseVersion> {
private static final Pattern FORMAT = Pattern.compile("v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)");
static ReleaseVersion parse(String value) {
var match = FORMAT.matcher(value);
Pipeline.check(match.matches(), "Expected release version vMAJOR.MINOR.PATCH");
return new ReleaseVersion(
new BigInteger(match.group(1)), new BigInteger(match.group(2)), new BigInteger(match.group(3)));
}
static ReleaseVersion next(List<ReleaseVersion> versions, String increment) {
Pipeline.check(List.of("patch", "minor", "major").contains(increment), "Choose patch, minor or major");
var highest = versions.stream().max(ReleaseVersion::compareTo);
if (highest.isEmpty()) return parse("v1.0.0");
var current = highest.get();
return switch (increment) {
case "major" -> new ReleaseVersion(current.major.add(BigInteger.ONE), BigInteger.ZERO, BigInteger.ZERO);
case "minor" -> new ReleaseVersion(current.major, current.minor.add(BigInteger.ONE), BigInteger.ZERO);
default -> new ReleaseVersion(current.major, current.minor, current.patch.add(BigInteger.ONE));
};
}
@Override
public int compareTo(ReleaseVersion other) {
int value = major.compareTo(other.major);
if (value == 0) value = minor.compareTo(other.minor);
return value == 0 ? patch.compareTo(other.patch) : value;
}
@Override
public String toString() {
return "v" + major + "." + minor + "." + patch;
}
}

34
deploy/compose.yaml Normal file
View File

@@ -0,0 +1,34 @@
# Images are resolved to registry digests before replacement. Never use :main here.
services:
backend:
image: ${BACKEND_IMAGE:?Required verified backend digest}
platform: linux/amd64
restart: unless-stopped
stop_grace_period: 20s
labels:
nl.agilecc.release: ${RELEASE_VERSION:?Required release version}
nl.agilecc.revision: ${RELEASE_SHA:?Required full commit SHA}
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'
frontend:
image: ${FRONTEND_IMAGE:?Required verified frontend digest}
platform: linux/amd64
restart: unless-stopped
stop_grace_period: 20s
depends_on:
- backend
environment:
BACKEND_UPSTREAM: backend:8080
ports:
- '${BIND_IP:?Required private NAS IP}:${FRONTEND_PORT:?Required port}:8080'
labels:
nl.agilecc.release: ${RELEASE_VERSION:?Required release version}
nl.agilecc.revision: ${RELEASE_SHA:?Required full commit SHA}
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'

28
deploy/enable-docker.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/bin/sh
# One-time DSM administrator action, run with sudo; does not restart any service.
set -eu
test "$(id -u)" = 0 || { echo 'Run this setup with sudo' >&2; exit 1; }
account=agiledancers-deploy
docker=/usr/local/bin/docker
target=/etc/sudoers.d/agiledancers-deploy
rule="$account ALL=(root) NOPASSWD: $docker"
test -x "$docker" && id "$account" >/dev/null
test -d /etc/sudoers.d || { echo 'Missing sudoers.d; no changes made' >&2; exit 1; }
if [ -e "$target" ]; then
test "$(cat "$target")" = "$rule" || { echo 'Existing sudoers entry differs; no changes made' >&2; exit 1; }
else
umask 077
temporary=$(mktemp /etc/sudoers.d/agiledancers-deploy.XXXXXX)
trap 'rm -f "$temporary"' EXIT
printf '%s\n' "$rule" > "$temporary"
chmod 440 "$temporary"
if command -v visudo >/dev/null 2>&1; then visudo -cf "$temporary"; fi
# A dot in the temporary name keeps sudo from loading the uncommitted file.
mv "$temporary" "$target"
if ! sudo -n -u "$account" sudo -n "$docker" info --format '{{.OSType}}/{{.Architecture}}'; then
rm "$target"
echo 'Docker permission check failed; new sudoers entry removed' >&2
exit 1
fi
fi
echo 'Dedicated Docker sudo permission is configured; no services were restarted.'

View File

@@ -0,0 +1,9 @@
# Copy to <DEPLOY_DIRECTORY>/production.env on the NAS, mode 600.
# This is shell syntax: do not add spaces around '=' or put secrets here.
BIND_IP=192.168.2.28
FRONTEND_PORT=8088
DOCKER_BIN=/usr/local/bin/docker
# 1 uses sudo -n for Docker only; configure the documented sudoers entry first.
DOCKER_SUDO=1
# The pull-only registry login is provisioned separately using docker login.
DOCKER_CONFIG=/volume1/docker/agiledancers-production/docker-auth

38
deploy/provision.sh Normal file
View File

@@ -0,0 +1,38 @@
#!/bin/sh
# Account-level provisioning. Docker sudo must already be enabled separately.
set -eu
umask 077
root=$1
registry_user=$2
registry=$3
bind=$4
port=$5
docker=/usr/local/bin/docker
test -x "$docker"
sudo -n "$docker" compose version
sudo -n "$docker" info --format '{{.OSType}}/{{.Architecture}}'
test ! -L "$root"
mkdir -p "$root" "$root/docker-auth"
chmod 700 "$root" "$root/docker-auth"
if [ ! -f "$root/production.env" ]; then
# First provisioning must not take a port already occupied by another service.
command -v netstat >/dev/null
if netstat -lnt | awk -v port=":$port" '$4 ~ (port "$") { found=1 } END { exit !found }'; then
echo 'Frontend port is already occupied; production configuration unchanged' >&2
exit 1
fi
printf 'BIND_IP=%s\nFRONTEND_PORT=%s\nDOCKER_BIN=%s\nDOCKER_SUDO=1\nDOCKER_CONFIG=%s/docker-auth\n' \
"$bind" "$port" "$docker" "$root" > "$root/production.env"
chmod 600 "$root/production.env"
else
# Preserve existing config; refuse to silently rebind a configured deployment.
. "$root/production.env"
test "$BIND_IP" = "$bind" && test "$FRONTEND_PORT" = "$port" \
&& test "$DOCKER_CONFIG" = "$root/docker-auth" || {
echo 'Existing production.env differs; review it before provisioning' >&2
exit 1
}
fi
# Credential enters only via stdin. No password, environment or auth config is logged.
sudo -n "$docker" --config "$root/docker-auth" login "$registry" --username "$registry_user" --password-stdin
echo 'Production directory and dedicated registry login prepared; no containers were changed.'

186
deploy/remote.sh Normal file
View File

@@ -0,0 +1,186 @@
#!/bin/sh
# Invoked over a pinned SSH connection by ReleaseRemote. No secrets in arguments.
# mkdir is the NAS-wide lock; a disconnected runner leaves it for explicit recovery.
set -eu
umask 077
action=$1
root=$2
owner=$3
shift 3
die() { echo "S6: $*" >&2; exit 1; }
case "$root" in /*) ;; *) die 'Deployment directory must be absolute';; esac
case "$owner" in ''|*[!a-zA-Z0-9-]*) die 'Invalid lock owner';; esac
test -d "$root" || die 'Provision the deployment directory first'
cd "$root"
lock="$root/deploy.lock"
release="$root/releases/$owner"
if [ "$action" = lock ]; then
test ! -e pending.json || die 'Unresolved deployment: follow manual recovery runbook'
mkdir "$lock" 2>/dev/null || die 'Deployment locked: another run or manual recovery owns the NAS'
printf '%s\n' "$owner" > "$lock/owner"
# Check again after acquiring, covering races with another lock holder.
if [ -e pending.json ]; then
rm "$lock/owner"
rmdir "$lock"
die 'Unresolved deployment: follow manual recovery runbook'
fi
exit 0
fi
test -f "$lock/owner" && [ "$(cat "$lock/owner")" = "$owner" ] || die 'Deployment lock ownership mismatch'
case "$action" in
unlock)
test ! -e pending.json || die 'Pending deployment retained; manual recovery required'
rm "$lock/owner"
rmdir "$lock"
exit 0;;
state)
if [ -f state.json ]; then cat state.json; else printf '{}\n'; fi
exit 0;;
pending)
test ! -e pending.json || die 'Deployment already pending'
cat > pending.json.tmp
mv pending.json.tmp pending.json
exit 0;;
commit)
test -f pending.json || die 'No pending deployment'
cat > state.json.tmp
mv state.json.tmp state.json
exit 0;;
finish)
test -f pending.json || die 'No pending deployment'
mkdir -p history
mv pending.json "history/$owner.json"
exit 0;;
event)
mkdir -p history
cat > "history/$owner-outcome.json.tmp"
mv "history/$owner-outcome.json.tmp" "history/$owner-outcome.json"
exit 0;;
esac
test -f production.env || die 'Missing production.env; follow provisioning runbook'
# This account-owned file is trusted shell configuration, never a workflow input.
. "$root/production.env"
: "${BIND_IP:?}" "${FRONTEND_PORT:?}" "${DOCKER_BIN:?}" "${DOCKER_CONFIG:?}"
export DOCKER_CONFIG
docker_cmd() {
if [ "${DOCKER_SUDO:-1}" = 1 ]; then
sudo -n "$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@"
else
"$DOCKER_BIN" --config "$DOCKER_CONFIG" "$@"
fi
}
compose() {
docker_cmd compose --project-name agiledancers-production --project-directory "$root" \
--env-file "$release/release.env" -f "$release/compose.yaml" "$@"
}
identity() {
image=$1
sha=$2
test "$(docker_cmd image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" = "$sha" \
|| die 'Image revision does not match selected SHA'
test "$(docker_cmd image inspect --format '{{.Os}}/{{.Architecture}}' "$image")" = linux/amd64 \
|| die 'Image platform must be linux/amd64'
}
use_release() {
case "$1" in ''|*[!a-zA-Z0-9-]*) die 'Invalid release directory';; esac
release="$root/releases/$1"
test -f "$release/compose.yaml" && test -f "$release/release.env" || die 'Missing retained release configuration'
}
health() {
backend=$(compose ps -q backend)
frontend=$(compose ps -q frontend)
test -n "$backend" && test -n "$frontend" || return 1
# Compare image IDs, revision labels and the container release labels.
for role in backend frontend; do
id=$(compose ps -q "$role")
if [ "$role" = backend ]; then expected=$BACKEND_IMAGE; else expected=$FRONTEND_IMAGE; fi
test "$(docker_cmd inspect --format '{{.State.Running}}' "$id")" = true || return 1
test "$(docker_cmd inspect --format '{{.Image}}' "$id")" = "$(docker_cmd image inspect --format '{{.Id}}' "$expected")" || return 1
test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.revision"}}' "$id")" = "$RELEASE_SHA" || return 1
test "$(docker_cmd inspect --format '{{index .Config.Labels "nl.agilecc.release"}}' "$id")" = "$RELEASE_VERSION" || return 1
done
docker_cmd exec "$frontend" wget -q -T 5 -O /dev/null http://backend:8080/q/health/ready || return 1
curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/" -o /dev/null || return 1
curl --noproxy '*' --fail --silent --show-error --max-time 5 "http://$BIND_IP:$FRONTEND_PORT/api/dance-schools" -o /dev/null
}
case "$action" in
preflight)
command -v timeout >/dev/null || die 'timeout is required for bounded remote operations'
command -v curl >/dev/null || die 'curl is required'
test -x "$DOCKER_BIN" || die 'Docker executable missing'
docker_cmd compose version >/dev/null
test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/x86_64 \
|| test "$(docker_cmd info --format '{{.OSType}}/{{.Architecture}}')" = linux/amd64 \
|| die 'Synology Docker platform is not linux/amd64'
test -d "$DOCKER_CONFIG" && test -f "$DOCKER_CONFIG/config.json" || die 'Provision registry pull login first'
# Explicit private IPv4 binding; prevent accidental 0.0.0.0 publication.
case "$BIND_IP" in
10.*|192.168.*|172.16.*|172.17.*|172.18.*|172.19.*|172.2[0-9].*|172.30.*|172.31.*|127.*) ;;
*) die 'Bind frontend to a private IPv4 address';;
esac
case "$FRONTEND_PORT" in ''|*[!0-9]*) die 'Invalid frontend port';; esac
test "$FRONTEND_PORT" -ge 1024 && test "$FRONTEND_PORT" -le 65535 || die 'Use port 1024..65535'
;;
stage)
mkdir -p "$release"
test ! -e "$release/compose.yaml" || die 'Attempt configuration already exists'
cat > "$release/compose.yaml"
;;
prepare)
version=$1; sha=$2; prefix=$3
backend=''; frontend=''
for role in backend frontend; do
repository="$prefix$role"
image="$repository:$sha"
docker_cmd pull --platform linux/amd64 "$image" >&2
identity "$image" "$sha"
digest=$(docker_cmd image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image" \
| awk -v prefix="$repository@sha256:" 'index($0,prefix)==1 { print; exit }')
test -n "$digest" || die 'Missing registry digest'
if [ "$role" = backend ]; then backend=$digest; else frontend=$digest; fi
done
printf 'RELEASE_VERSION=%s\nRELEASE_SHA=%s\nBACKEND_IMAGE=%s\nFRONTEND_IMAGE=%s\nBIND_IP=%s\nFRONTEND_PORT=%s\n' \
"$version" "$sha" "$backend" "$frontend" "$BIND_IP" "$FRONTEND_PORT" > "$release/release.env"
compose config --quiet
printf 'S6_BACKEND=%s\nS6_FRONTEND=%s\n' "$backend" "$frontend"
;;
ensure)
use_release "$1"
. "$release/release.env"
test "$RELEASE_VERSION" = "$2" && test "$RELEASE_SHA" = "$3" \
&& test "$BACKEND_IMAGE" = "$4" && test "$FRONTEND_IMAGE" = "$5" \
|| die 'Retained configuration differs from recorded successful release'
for image in "$BACKEND_IMAGE" "$FRONTEND_IMAGE"; do
docker_cmd pull --platform linux/amd64 "$image" >&2
identity "$image" "$RELEASE_SHA"
done
compose config --quiet
;;
apply)
mkdir "$lock/operation" 2>/dev/null || die 'A remote replacement is still active; manual recovery required'
trap 'rmdir "$lock/operation"' EXIT
use_release "$1"
. "$release/release.env"
health_seconds=${2:-280}
case "$health_seconds" in ''|*[!0-9]*) die 'Invalid readiness window';; esac
test "$health_seconds" -ge 1 && test "$health_seconds" -le 280 || die 'Invalid readiness window'
end=$(( $(date +%s) + health_seconds ))
# Always recreate the frontend too: nginx must resolve the new backend IP.
compose up -d --no-build --pull never --force-recreate --timeout 20
while ! health; do
test "$(date +%s)" -lt "$end" || die 'Readiness checks timed out'
sleep 1
done
echo 'S6_HEALTHY'
;;
diagnose)
use_release "$1"
compose ps
compose logs --no-color --tail 200
;;
*) die 'Unknown remote deployment operation';;
esac

16
docs/backend-review.md Normal file
View File

@@ -0,0 +1,16 @@
# Backend review companion
Use alongside `skills/code-review/SKILL.md` and `CODING_STANDARDS.md`.
Review changed code for intent-revealing comments, missing public-contract Javadoc,
appropriate record use, defensive copying of mutable record components, and
compatibility with APIs, JSON and persistence. Assess ambiguous `var` initializers
and framework/lifecycle exceptions to field finality. Confirm architecture and
injection decisions make sense for production and tests.
Read standards reports even when verification passes. Assess every review signal
in changed code, including fan-out, public methods and record component counts.
Record a rationale for retained signals in the S7 evidence or change description.
Check narrowly scoped waivers against recorded maintainer agreement and reason.
Never introduce blanket exclusions or weaken thresholds to pass a build.
Do not repeat mechanical checks already enforced by tooling; verify their result.

Some files were not shown because too many files have changed in this diff Show More