21 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
9a69981025 Merge pull request 'refinement of S7 - coding standards for the backend' (#8) from coding-standards-be into main
Some checks failed
Build, test and publish / verify (push) Failing after 2m30s
Reviewed-on: #8
2026-10-30 17:18:29 +01:00
35123e51f6 refinement of S7 - coding standards for the backend
All checks were successful
Build, test and publish / verify (push) Successful in 1m45s
2026-10-30 17:18:13 +01:00
de42bbbbf0 Merge pull request 'S8-palentir' (#7) from S8-palentir into main
All checks were successful
Build, test and publish / verify (push) Successful in 1m57s
Reviewed-on: #7
2026-10-30 16:58:49 +01:00
5ec2dd83f3 review complete
All checks were successful
Build, test and publish / verify (push) Successful in 1m31s
2026-10-30 16:58:17 +01:00
7e4fd2dab7 spec-file updates
All checks were successful
Build, test and publish / verify (push) Successful in 1m41s
2026-10-30 16:54:05 +01:00
7e8b3a2cc7 S8 apply Palantir formatting to maintained Java sources
All checks were successful
Build, test and publish / verify (push) Successful in 1m39s
2026-10-30 15:50:51 +01:00
327a365550 S8 integrate pinned Palantir formatting through OpenRewrite 2026-10-30 15:50:51 +01:00
149 changed files with 9953 additions and 1286 deletions

View File

@@ -1,7 +1,6 @@
**/target **/target
.git .git
.idea .idea
.mvn
*.iml *.iml
**/node_modules **/node_modules
@@ -16,3 +15,9 @@ agiledancers-be/target/*
!agiledancers-be/target/quarkus-app !agiledancers-be/target/quarkus-app
**/unit-results **/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. `grilling` automatically to ordinary development requests.
- Keep the upstream skill files unchanged. Source revision and license are - Keep the upstream skill files unchanged. Source revision and license are
recorded in `skills/upstream.json` and `skills/LICENSE.mattpocock`. 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 FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /workspace WORKDIR /workspace
COPY pom.xml ./ COPY pom.xml ./
COPY .mvn .mvn
COPY tooling tooling
COPY ci ci
COPY agiledancers-be/pom.xml agiledancers-be/pom.xml COPY agiledancers-be/pom.xml agiledancers-be/pom.xml
COPY agiledancers-be/src agiledancers-be/src COPY agiledancers-be/src agiledancers-be/src
RUN mvn -B -ntp clean verify RUN mvn -B -ntp clean verify

111
README.md
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`), Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
with unique date/type combinations. The displayed date range comes from these with unique date/type combinations. The displayed date range comes from these
dayparts. Each show stores `appearances` for every group type. Omitting counts dayparts. Each show stores `preferredDayParts`, defaulting to two or one for
initializes defaults on creation or the initial daypart configuration; later one daypart. Ordinary groups, including PRE_EDUCATION, perform in exactly preferredDayParts
edits preserve saved counts and reject counts outside 1..N. With no dayparts, dayparts; preschool groups once; showcases every daypart. See
shows remain unscheduled and saved counts are retained. [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 `POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` generates an ordered
assign every current school group to its required number of distinct dayparts, draft using OR-Tools with a total thirty-minute time limit. A feasible result is
minimizing the busiest/quietest appearance gap. It replaces assignments only on accepted without claiming optimality. Concurrent runs and stale results return
a valid, current result. The response distinguishes optimal balance from a valid `409`; failures retain existing assignments. Development and production proxies
result whose optimality is unproven. Concurrent runs return `409`; changed inputs allow 31 minutes.
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.
School JSON exports use format version **2**, including dated dayparts, assignments, `PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
and all appearance counts. Version 1 files are intentionally unsupported. 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: Run the HTTP integration tests with:
@@ -253,3 +265,72 @@ Only successful `main` runs publish images to the Synology registry. See
[the CI management guide](docs/gitea-ci.md) for runner setup, registry secrets, [the CI management guide](docs/gitea-ci.md) for runner setup, registry secrets,
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md) image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
for the current operational status. for the current operational status.
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
Palantir Java Format **2.101.0**, Palantir style, through a custom OpenRewrite
**8.63.4** text recipe in `tooling/format/Format.java`. The pinned
`exec-maven-plugin` **3.6.3** launches that helper with JDK 25. Native import
ordering and unused-import removal are enabled; extra Javadoc formatting is off.
No OpenRewrite AutoFormat or additional refactoring recipes run.
From the repository root:
```sh
mvn -N exec:exec@format # apply, without application compilation
mvn -N exec:exec@format -Dformat.mode=check # check, without application compilation
mvn compile # applies during validate, before compilation
mvn -Dformat.mode=check clean verify # CI: fails before compilation on differences
```
Root `compile`, `test`, `verify` and `install` may rewrite maintained Java files
in the validate phase. CI's Java orchestration passes `-Dformat.mode=check` and
never repairs source files. On failure, the output lists paths and the apply
command. Apply locally, inspect and commit the result, then repeat the check.
Run root Maven commands; selecting only the backend with `-pl agiledancers-be`
bypasses the root formatting execution.
The scope is every `.java` file recursively under `agiledancers-be/src/main/java`,
`agiledancers-be/src/test/java`, `ci` (including the runner helper), and the
formatter helper itself. Generated `target` files and frontend files are excluded.
The adapter keeps paths and UTF-8 content, computes changes before writing, and
uses atomic per-file replacements. Unsupported syntax fails with its path/cause.
The source launcher compiles the adapter directly in memory from a clean checkout;
no custom recipe jar needs compiling or installing beforehand. Maven downloads
only pinned external tooling dependencies on first use.
Successfully checked/formatted content hashes are cached in
`target/palantir-cache.properties`; new or changed files are always processed.
The root POM, adapter source, dependency classpath and JDK version invalidate the cache. `clean` removes
it. Check and apply share the engine, options, scope and successful-content cache;
a failing check does not write sources or update the cache. For controlled timing
comparisons only, `-Dformat.skip=true` disables the execution.
Editors are optional. Palantir supplies an IntelliJ plugin (see the
[upstream instructions](https://github.com/palantir/palantir-java-format#intellij-plugin)).
Use it only with the same engine version/style and Javadoc setting; if the editor
cannot match them, use Maven to format instead. Editor import optimization or
format-on-save must not override Maven's result. Other editors can invoke the
standalone Maven apply command as an external tool.
Verification and measurements are recorded in
[the S8 evidence](specs/S8-formatting-evidence.md). Reproduce the isolated failure,
UTF-8, import, cache and idempotence checks with
`python3 tooling/format/test.py`, and the sequential end-to-end build measurements
with `python3 tooling/format/benchmark.py` (requires the ordinary backend test
prerequisites). Results are written under `target/`.
## 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> </dependency>
<!-- test dependencies --> <!-- test dependencies -->
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-jacoco</artifactId><scope>test</scope></dependency>
<dependency> <dependency>
<groupId>com.tngtech.archunit</groupId> <groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId> <artifactId>archunit-junit5</artifactId>
@@ -62,6 +63,60 @@
<build> <build>
<plugins> <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> <plugin>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId> <artifactId>quarkus-maven-plugin</artifactId>

View File

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

View File

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

View File

@@ -1,12 +1,16 @@
package nl.agilecc.agiledancers.model; package nl.agilecc.agiledancers.model;
import lombok.*; import java.util.LinkedHashSet;
import java.util.Set;
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.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType; import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import java.util.Set;
import java.util.LinkedHashSet;
@Getter @Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder @Builder
@@ -15,21 +19,27 @@ public class DanceGroup extends AbstractEntity {
@ToString.Include @ToString.Include
@Setter @Setter
private String category; private String category;
@ToString.Include @ToString.Include
@Setter @Setter
private DanceGroupType type; private DanceGroupType type;
@ToString.Include @ToString.Include
@Setter @Setter
private int startAge; private int startAge;
@ToString.Include @ToString.Include
@Setter @Setter
private int endAge; private int endAge;
@ToString.Include @ToString.Include
@Builder.Default @Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>(); private final Set<Dancer> dancers = new LinkedHashSet<>();
@ToString.Include @ToString.Include
@Builder.Default @Builder.Default
private final Set<DanceGroupStyle> styles = new LinkedHashSet<>(); private final Set<DanceGroupStyle> styles = new LinkedHashSet<>();
@ToString.Include @ToString.Include
private Coach coach; private Coach coach;
@@ -37,7 +47,7 @@ public class DanceGroup extends AbstractEntity {
return super.unmodifiableNullSafe(dancers); return super.unmodifiableNullSafe(dancers);
} }
public void addDancer(Dancer dancer) { public void addDancer(final Dancer dancer) {
super.addNullSafe(dancers, dancer); super.addNullSafe(dancers, dancer);
} }
@@ -45,15 +55,15 @@ public class DanceGroup extends AbstractEntity {
return super.unmodifiableNullSafe(styles); return super.unmodifiableNullSafe(styles);
} }
public void addStyle(DanceGroupStyle style) { public void addStyle(final DanceGroupStyle style) {
super.addNullSafe(styles, style); super.addNullSafe(styles, style);
} }
public void removeDancer(Dancer dancer) { public void removeDancer(final Dancer dancer) {
dancers.remove(dancer); dancers.remove(dancer);
} }
public void replaceStyles(Set<DanceGroupStyle> styles) { public void replaceStyles(final Set<DanceGroupStyle> styles) {
this.styles.clear(); this.styles.clear();
this.styles.addAll(styles); this.styles.addAll(styles);
} }

View File

@@ -1,9 +1,13 @@
package nl.agilecc.agiledancers.model; package nl.agilecc.agiledancers.model;
import lombok.*;
import java.util.Set;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.Set;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter @Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -12,14 +16,19 @@ import java.util.LinkedHashSet;
public class DanceSchool extends AbstractEntity { public class DanceSchool extends AbstractEntity {
@Builder.Default @Builder.Default
private final java.util.UUID key = java.util.UUID.randomUUID(); private final java.util.UUID key = java.util.UUID.randomUUID();
@Setter @Setter
private String name; private String name;
@Builder.Default @Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>(); private final Set<Dancer> dancers = new LinkedHashSet<>();
@Builder.Default @Builder.Default
private final Set<DanceGroup> danceGroups = new LinkedHashSet<>(); private final Set<DanceGroup> danceGroups = new LinkedHashSet<>();
@Builder.Default @Builder.Default
private final Set<Show> shows = new LinkedHashSet<>(); private final Set<Show> shows = new LinkedHashSet<>();
@Builder.Default @Builder.Default
private final Set<Coach> coaches = new LinkedHashSet<>(); private final Set<Coach> coaches = new LinkedHashSet<>();
@@ -39,31 +48,31 @@ public class DanceSchool extends AbstractEntity {
return super.unmodifiableNullSafe(coaches); return super.unmodifiableNullSafe(coaches);
} }
public void addDancer(Dancer dancer) { public void addDancer(final Dancer dancer) {
super.addNullSafe(dancers, dancer); super.addNullSafe(dancers, dancer);
} }
public void addDanceGroup(DanceGroup danceGroup) { public void addDanceGroup(final DanceGroup danceGroup) {
super.addNullSafe(danceGroups, danceGroup); super.addNullSafe(danceGroups, danceGroup);
} }
public void addShow(Show show) { public void addShow(final Show show) {
super.addNullSafe(shows, show); super.addNullSafe(shows, show);
} }
public void addCoach(Coach coach) { public void addCoach(final Coach coach) {
super.addNullSafe(coaches, coach); super.addNullSafe(coaches, coach);
} }
public void removeDancer(Dancer entity) { public void removeDancer(final Dancer entity) {
dancers.remove(entity); dancers.remove(entity);
} }
public void removeDanceGroup(DanceGroup entity) { public void removeDanceGroup(final DanceGroup entity) {
danceGroups.remove(entity); danceGroups.remove(entity);
} }
public void removeShow(Show entity) { public void removeShow(final Show entity) {
shows.remove(entity); shows.remove(entity);
} }
} }

View File

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

View File

@@ -1,10 +1,14 @@
package nl.agilecc.agiledancers.model; package nl.agilecc.agiledancers.model;
import lombok.*;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import java.util.List;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
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 @Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -13,12 +17,16 @@ import java.time.LocalDate;
public class DayPart extends AbstractEntity { public class DayPart extends AbstractEntity {
@ToString.Include @ToString.Include
private final DayPartType type; private final DayPartType type;
private final LocalDate date; private final LocalDate date;
@ToString.Include @ToString.Include
@Setter @Setter
private List<DanceGroup> danceGroups; private List<DanceGroup> danceGroups;
public void removeDanceGroup(DanceGroup group) { public void removeDanceGroup(final DanceGroup group) {
danceGroups = danceGroups.stream().filter(value -> value != group).toList(); danceGroups = danceGroups.stream()
.filter((final DanceGroup value) -> value != group)
.toList();
} }
} }

View File

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

View File

@@ -1,3 +1,21 @@
package nl.agilecc.agiledancers.model.scheduling; 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; package nl.agilecc.agiledancers.model.scheduling;
import java.util.List; 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 { public SchedulingInput {
groups = List.copyOf(groups); 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; package nl.agilecc.agiledancers.model.scheduling;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List; 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,5 +1,10 @@
package nl.agilecc.agiledancers.model.specifications; package nl.agilecc.agiledancers.model.specifications;
public enum DanceGroupStyle { public enum DanceGroupStyle {
HIPHOP, JAZZ, MODERN, STREETDANCE, CLASSIC, BREAKDANCE HIPHOP,
JAZZ,
MODERN,
STREETDANCE,
CLASSIC,
BREAKDANCE
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,24 +3,31 @@ package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.core.*; import jakarta.ws.rs.DELETE;
import nl.agilecc.agiledancers.service.SchoolManagementService; 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 java.util.List;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest; import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
import nl.agilecc.agiledancers.rest.apimodel.DancerView; import nl.agilecc.agiledancers.rest.apimodel.DancerView;
import nl.agilecc.agiledancers.service.SchoolManagementService;
@Path("/api/dance-schools/{schoolId}/dancers") @Path("/api/dance-schools/{schoolId}/dancers")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
public class DancerResource { public class DancerResource {
@Inject private @Inject SchoolManagementService service;
SchoolManagementService service;
@PathParam("schoolId") private @PathParam("schoolId") long schoolId;
long schoolId;
@GET @GET
public List<DancerView> list() { public List<DancerView> list() {
@@ -29,26 +36,29 @@ public class DancerResource {
@GET @GET
@Path("/{id}") @Path("/{id}")
public DancerView get(@PathParam("id") long id) { public DancerView get(final @PathParam("id") long id) {
return DancerView.of(service.dancer(schoolId, id)); return DancerView.of(service.dancer(schoolId, id));
} }
@POST @POST
public Response create(@NotNull @Valid DancerRequest request, @Context UriInfo uriInfo) { public Response create(final @NotNull @Valid DancerRequest request, final @Context UriInfo uriInfo) {
var entity = service.createDancer(schoolId, request.name(), request.birthDate()); final Dancer entity = service.createDancer(schoolId, request.name(), request.birthDate());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build()) return Response.created(uriInfo.getAbsolutePathBuilder()
.entity(DancerView.of(entity)).build(); .path(entity.getId().toString())
.build())
.entity(DancerView.of(entity))
.build();
} }
@PUT @PUT
@Path("/{id}") @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())); return DancerView.of(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
} }
@DELETE @DELETE
@Path("/{id}") @Path("/{id}")
public Response delete(@PathParam("id") long id) { public Response delete(final @PathParam("id") long id) {
service.deleteDancer(schoolId, id); service.deleteDancer(schoolId, id);
return Response.noContent().build(); return Response.noContent().build();
} }

View File

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

View File

@@ -4,19 +4,21 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper; import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.rest.apimodel.ErrorView; import nl.agilecc.agiledancers.rest.apimodel.ErrorView;
import nl.agilecc.agiledancers.service.ManagementException;
@Provider @Provider
public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> { public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> {
@Override @Override
public Response toResponse(ManagementException exception) { public Response toResponse(final ManagementException exception) {
int status = switch (exception.getKind()) { final int status = switch (exception.getKind()) {
case NOT_FOUND -> 404; case NOT_FOUND -> 404;
case INVALID_INPUT -> 400; case INVALID_INPUT -> 400;
case CONFLICT -> 409; case CONFLICT -> 409;
}; };
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE) return Response.status(status)
.entity(new ErrorView(exception.getMessage())).build(); .type(MediaType.APPLICATION_JSON_TYPE)
.entity(new ErrorView(exception.getMessage()))
.build();
} }
} }

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,2 +1,3 @@
package nl.agilecc.agiledancers.rest.apimodel; package nl.agilecc.agiledancers.rest.apimodel;
public record SchedulingView(boolean optimal, String message) {} public record SchedulingView(boolean optimal, String message) {}

View File

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

View File

@@ -1,7 +1,22 @@
package nl.agilecc.agiledancers.rest.apimodel; package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.NotBlank;
import java.util.*; 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; import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowRequest(@NotBlank String name, @NotNull List<@NotNull @Valid DayPartRequest> dayParts,
Map<DanceGroupType, Integer> appearances) {} public record ShowRequest(
@NotBlank String name,
@NotNull List<@NotNull @Valid DayPartRequest> dayParts,
Map<DanceGroupType, Integer> appearances,
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,10 +1,48 @@
package nl.agilecc.agiledancers.rest.apimodel; package nl.agilecc.agiledancers.rest.apimodel;
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; import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import java.util.*;
public record ShowView(long id, String name, List<DayPartView> dayParts, Map<DanceGroupType, Integer> appearances) { public record ShowView(
public static ShowView of(Show show) { long id,
return new ShowView(show.getId(), show.getName(), show.getDayParts().stream() String name,
.map(p -> new DayPartView(p.getDate(), p.getType(), p.getDanceGroups().stream().map(DanceGroup::getId).toList())).toList(), show.getAppearances()); 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((final DayPart p) -> new DayPartView(
p.getDate(),
p.getType(),
p.getDanceGroups().stream()
.map(DanceGroup::getId)
.toList()))
.toList(),
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; import nl.agilecc.agiledancers.repository.CoachRepository;
public class CoachService { 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()); return coachRepository.add(Coach.builder().name(name).build());
} }
} }

View File

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

View File

@@ -1,29 +1,33 @@
package nl.agilecc.agiledancers.service; 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; import nl.agilecc.agiledancers.repository.DanceSchoolRepository;
public class DanceSchoolService { public class DanceSchoolService {
private final DanceSchoolRepository danceSchoolRepository = new DanceSchoolRepository(); private final DanceSchoolRepository danceSchoolRepository = new DanceSchoolRepository();
public DanceSchool add(String name) { public DanceSchool add(final String name) {
DanceSchool danceSchool = DanceSchool.builder().name(name).build(); final DanceSchool danceSchool = DanceSchool.builder().name(name).build();
return danceSchoolRepository.add(danceSchool); 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); 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); 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); 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); danceSchoolRepository.findById(danceSchoolId).addShow(show);
} }
} }

View File

@@ -1,15 +1,14 @@
package nl.agilecc.agiledancers.service; package nl.agilecc.agiledancers.service;
import java.time.LocalDate;
import nl.agilecc.agiledancers.model.Dancer; import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.repository.DancerRepository; import nl.agilecc.agiledancers.repository.DancerRepository;
import java.time.LocalDate;
public class DancerService { public class DancerService {
private final DancerRepository dancerRepository = new DancerRepository(); private final DancerRepository dancerRepository = new DancerRepository();
public Dancer addDancer(String name, LocalDate birthDate) { public Dancer addDancer(final String name, final LocalDate birthDate) {
Dancer dancer = Dancer.builder().name(name).birthDate(birthDate).build(); final Dancer dancer = Dancer.builder().name(name).birthDate(birthDate).build();
return dancerRepository.add(dancer); return dancerRepository.add(dancer);
} }
} }

View File

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

View File

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

View File

@@ -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

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,7 @@
package nl.agilecc.agiledancers.service.model; 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; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
public interface SchedulingEngine { 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,55 +1,78 @@
package nl.agilecc.agiledancers.solver; package nl.agilecc.agiledancers.solver;
import com.google.ortools.Loader;
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.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput; import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult; import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.port.SchedulingEngine; import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import com.google.ortools.Loader;
import com.google.ortools.sat.*;
import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.service.*;
import java.util.*;
@Singleton @Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine { 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 @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(); Loader.loadNativeLibraries();
CpModel model = new CpModel(); final CpModel model = new CpModel();
int n = input.dayParts(), groups = input.groups().size(); final OrderedAssignments vars = OrderedAssignments.create(model, input);
BoolVar[][] assigned = new BoolVar[groups][n]; final LinearExpr attendance = SchedulingObjectives.attendance(model, input, vars);
for (int g = 0; g < groups; g++) { final LinearExpr variety = SchedulingObjectives.variety(model, input, vars);
for (int p = 0; p < n; p++) assigned[g][p] = model.newBoolVar("g" + g + "p" + p); final CpSolver solver = new CpSolver();
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.
solver.getParameters().setCatchSigintSignal(false); solver.getParameters().setCatchSigintSignal(false);
// Single-worker search with a fixed seed makes identical inputs repeatable.
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0); solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
CpSolverStatus status = solver.solve(model); SchedulingResult best = null;
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) {
throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, switch (status) { final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved."; if (remaining <= 0) {
case UNKNOWN -> "The 30-minute limit was reached without a valid result. Existing assignments were preserved."; if (best != null) {
default -> "Solver failed (" + status + "). Existing assignments were preserved."; return new SchedulingResult(false, best.assignments());
}); }
List<List<Long>> parts = new ArrayList<>(); requireSolution(CpSolverStatus.UNKNOWN);
for (int p = 0; p < n; p++) { }
List<Long> ids = new ArrayList<>(); solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
for (int g = 0; g < groups; g++) if (solver.booleanValue(assigned[g][p])) ids.add(input.groups().get(g).groupId()); model.minimize(objective);
parts.add(List.copyOf(ids)); 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) {
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
case UNKNOWN ->
"The 30-minute limit was reached without a valid result. Existing assignments were"
+ " preserved.";
default -> "Solver failed (" + status + "). Existing assignments were preserved.";
});
} }
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. # Scheduling is synchronous with a 30-minute solver limit.
quarkus.http.idle-timeout=31M quarkus.http.idle-timeout=31M
quarkus.jackson.fail-on-unknown-properties=true 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

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

View File

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

View File

@@ -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,54 +1,100 @@
package nl.agilecc.agiledancers; package nl.agilecc.agiledancers;
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.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.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.model.*; import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.service.port.SchedulingEngine; import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.model.*; import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.model.specifications.*; import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.*; 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 nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class SchedulingTest { class SchedulingTest {
private final SchoolManagementService service = new SchoolManagementService(); private final SchoolManagementService service = new SchoolManagementService();
private final long school = service.createSchool("Studio").getId(); private final long school = service.createSchool("Studio").getId();
private List<DayPart> parts(int n) {
return java.util.stream.IntStream.range(0, n).mapToObj(i -> DayPart.builder() private List<DayPart> parts(final int n) {
.date(LocalDate.of(2027, 1, 1).plusDays(i)).type(DayPartType.AFTERNOON).danceGroups(List.of()).build()).toList(); return java.util.stream.IntStream.range(0, n)
.mapToObj((final int i) -> DayPart.builder()
.date(LocalDate.of(2027, 1, 1).plusDays(i))
.type(DayPartType.AFTERNOON)
.danceGroups(List.of())
.build())
.toList();
} }
private DanceGroup group(DanceGroupType type) {
return service.createGroup(school, "Group", type, 0, 99, Set.of()); private 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) {
try { service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input)); } final SchedulingInput input = service.beginScheduling(school, show.getId());
finally { service.endScheduling(show.getId()); } try {
service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input));
} finally {
service.endScheduling(show.getId());
}
} }
@Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
public @Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
group(DanceGroupType.PRE_SCHOOLER); 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); run(show);
var loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics(); IntSummaryStatistics loads = show.getDayParts().stream()
assertEquals(1, loads.getSum()); assertEquals(0, loads.getMin()); assertEquals(1, loads.getMax()); .mapToInt((final DayPart p) -> p.getDanceGroups().size())
for (var type : DanceGroupType.values()) group(type); .summaryStatistics();
assertEquals(1, loads.getSum());
assertEquals(0, loads.getMin());
assertEquals(1, loads.getMax());
for (final DanceGroupType type : DanceGroupType.values()) {
group(type);
}
run(show); run(show);
for (var group : service.groups(school)) assertEquals(show.getAppearances().get(group.getType()).longValue(), for (final DanceGroup group : service.groups(school)) {
show.getDayParts().stream().filter(p -> p.getDanceGroups().contains(group)).count()); assertEquals(
loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics(); show.getAppearances().get(group.getType()).longValue(),
show.getDayParts().stream()
.filter((final DayPart p) -> p.getDanceGroups().contains(group))
.count());
}
loads = show.getDayParts().stream()
.mapToInt((final DayPart p) -> p.getDanceGroups().size())
.summaryStatistics();
assertTrue(loads.getMax() - loads.getMin() <= 1); assertTrue(loads.getMax() - loads.getMin() <= 1);
var snapshot = service.exportSchool(school); final SchoolSnapshot snapshot = service.exportSchool(school);
assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore())); 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()); assertTrue(show.getAppearances().isEmpty());
service.updateShow(school, show.getId(), "Show", parts(3), null); service.updateShow(school, show.getId(), "Show", parts(3), null);
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION)); assertEquals(2, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER)); assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
var counts = new EnumMap<>(show.getAppearances()); counts.put(DanceGroupType.PRE_SCHOOLER, 2); final var counts = new EnumMap<>(show.getAppearances());
counts.put(DanceGroupType.PRE_SCHOOLER, 2);
service.updateShow(school, show.getId(), "Show", parts(4), counts); service.updateShow(school, show.getId(), "Show", parts(4), counts);
assertEquals(counts, show.getAppearances()); assertEquals(counts, show.getAppearances());
assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null)); assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null));
@@ -58,55 +104,67 @@ class SchedulingTest {
service.updateShow(school, show.getId(), "Show", parts(4), null); service.updateShow(school, show.getId(), "Show", parts(4), null);
assertEquals(counts, show.getAppearances()); assertEquals(counts, show.getAppearances());
} }
@Test void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
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", duplicate, null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null)); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of())); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of()));
var counts = ShowConfiguration.defaults(2); counts.put(DanceGroupType.REGULAR, 3); final Map<DanceGroupType, Integer> counts = ShowConfiguration.defaults(2);
counts.put(DanceGroupType.REGULAR, 3);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts)); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
counts.put(DanceGroupType.REGULAR, 0); counts.put(DanceGroupType.REGULAR, 0);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts)); assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
} }
@Test void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
var group = group(DanceGroupType.REGULAR); public @Test void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
var show = service.createShow(school, "Show", parts(2), null); run(show); final DanceGroup group = group(DanceGroupType.REGULAR);
var before = show.getDayParts().stream().map(DayPart::getDanceGroups).toList(); final Show show = service.createShow(school, "Show", parts(2), null);
var input = service.beginScheduling(school, show.getId()); run(show);
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())); assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
var result = new OrToolsSchedulingEngine().solve(input); final SchedulingResult result = new OrToolsSchedulingEngine().solve(input);
service.updateGroup(school, group.getId(), "Renamed", group.getType(), 0, 99, Set.of()); service.updateGroup(school, group.getId(), new GroupDetails("Renamed", group.getType(), 0, 99, Set.of()));
assertDoesNotThrow(() -> service.finishScheduling(school, input, result)); assertDoesNotThrow(() -> service.finishScheduling(school, input, result));
group(DanceGroupType.PRE_SCHOOLER); group(DanceGroupType.PRE_SCHOOLER);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result)); assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList()); assertEquals(
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
service.endScheduling(show.getId()); service.endScheduling(show.getId());
var fresh = service.beginScheduling(school, show.getId()); final SchedulingInput fresh = service.beginScheduling(school, show.getId());
assertThrows(ManagementException.class, () -> service.finishScheduling(school, fresh, assertThrows(
new SchedulingResult(false, List.of(List.of(), List.of())))); ManagementException.class,
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList()); () -> service.finishScheduling(
school, fresh, new SchedulingResult(false, List.of(List.of(), List.of()))));
assertEquals(
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
// Feasible results are accepted without requiring proof of optimality. // Feasible results are accepted without requiring proof of optimality.
var valid = new OrToolsSchedulingEngine().solve(fresh); final SchedulingResult valid = new OrToolsSchedulingEngine().solve(fresh);
assertDoesNotThrow(() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments()))); assertDoesNotThrow(
() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments())));
service.endScheduling(show.getId()); service.endScheduling(show.getId());
} }
@Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {
public @Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {
group(DanceGroupType.REGULAR); group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null); final Show show = service.createShow(school, "Show", parts(2), null);
var input = service.beginScheduling(school, show.getId()); final SchedulingInput input = service.beginScheduling(school, show.getId());
var result = new OrToolsSchedulingEngine().solve(input); final SchedulingResult result = new OrToolsSchedulingEngine().solve(input);
service.updateShow(school, show.getId(), "Show", parts(3), null); service.updateShow(school, show.getId(), "Show", parts(3), null);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result)); assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.deleteShow(school, show.getId()); service.deleteShow(school, show.getId());
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result)); assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.endScheduling(show.getId()); service.endScheduling(show.getId());
} }
@Test void needsGroupsAndDayparts() {
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())); assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
group(DanceGroupType.REGULAR); 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())); 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,77 +1,78 @@
package nl.agilecc.agiledancers; package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
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.quarkus.test.junit.QuarkusTest;
import io.restassured.response.ValidatableResponse; import io.restassured.response.ValidatableResponse;
import org.junit.jupiter.api.Test;
import java.util.Map; import java.util.Map;
import static io.restassured.RestAssured.given; import org.junit.jupiter.api.Test;
import static org.hamcrest.Matchers.*;
@QuarkusTest @QuarkusTest
class SchoolManagementResourceTest { class SchoolManagementResourceTest {
private static final String SCHOOLS = "/api/dance-schools"; private static final String SCHOOLS = "/api/dance-schools";
private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12"); private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
private static final Map<String, Object> GROUP = Map.of("category", "Teens", "type", "REGULAR", private static final Map<String, Object> GROUP = Map.of(
"startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"}); "category", "Teens", "type", "REGULAR", "startAge", 12, "endAge", 18, "styles", new String[] {"HIPHOP"});
private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "dayParts", new Object[]{Map.of("date", "2027-06-12", "type", "AFTERNOON")}); private static final Map<String, Object> SHOW =
Map.of("name", "Annual show", "dayParts", new Object[] {Map.of("date", "2027-06-12", "type", "AFTERNOON")});
@Test public @Test void managesSchoolAndItsResources() {
void managesSchoolAndItsResources() { final long schoolId = create(SCHOOLS, Map.of("name", "Dance Studio"));
long schoolId = create(SCHOOLS, Map.of("name", "Dance Studio")); final String school = SCHOOLS + "/" + schoolId;
String school = SCHOOLS + "/" + schoolId;
try { try {
given().get(school).then().statusCode(200).body("name", equalTo("Dance Studio")); given().get(school).then().statusCode(200).body("name", equalTo("Dance Studio"));
given().get(SCHOOLS).then().statusCode(200).body("id", hasItem((int) schoolId)); 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")); put(school, Map.of("name", "Renamed studio")).statusCode(200).body("name", equalTo("Renamed studio"));
long dancerId = create(school + "/dancers", DANCER); final long dancerId = create(school + "/dancers", DANCER);
String dancer = school + "/dancers/" + dancerId; final String dancer = school + "/dancers/" + dancerId;
put(dancer, Map.of("name", "Sam updated", "birthDate", "2011-06-13")) put(dancer, Map.of("name", "Sam updated", "birthDate", "2011-06-13"))
.statusCode(200).body("birthDate", equalTo("2011-06-13")); .statusCode(200)
.body("birthDate", equalTo("2011-06-13"));
given().get(dancer).then().statusCode(200).body("name", equalTo("Sam updated")); given().get(dancer).then().statusCode(200).body("name", equalTo("Sam updated"));
given().get(school + "/dancers").then().statusCode(200).body("id", contains((int) dancerId)); given().get(school + "/dancers").then().statusCode(200).body("id", contains((int) dancerId));
long groupId = create(school + "/dance-groups", GROUP); final String group = manageGroup(school, dancerId);
String group = school + "/dance-groups/" + groupId; final long groupId = Long.parseLong(group.substring(group.lastIndexOf('/') + 1));
given().put(group + "/dancers/" + dancerId).then().statusCode(204); final String show = manageShow(school);
put(group, Map.of("category", "Selection", "type", "SELECTION", "startAge", 10, final long showId = Long.parseLong(show.substring(show.lastIndexOf('/') + 1));
"endAge", 20, "styles", new String[]{"JAZZ", "MODERN"})) given().get(school)
.statusCode(200).body("category", equalTo("Selection")) .then()
.body("dancerIds", contains((int) dancerId)).body("styles", containsInAnyOrder("JAZZ", "MODERN")); .statusCode(200)
given().get(group).then().statusCode(200).body("type", equalTo("SELECTION")); .body("dancerIds", contains((int) dancerId))
given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId)); .body("danceGroupIds", contains((int) groupId))
.body("showIds", contains((int) showId));
long showId = create(school + "/shows", SHOW); for (final String path : new String[] {dancer, group, 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));
given().get(school).then().statusCode(200).body("dancerIds", contains((int) dancerId))
.body("danceGroupIds", contains((int) groupId)).body("showIds", contains((int) showId));
for (String path : new String[]{dancer, group, show}) {
given().delete(path).then().statusCode(204); given().delete(path).then().statusCode(204);
given().get(path).then().statusCode(404); given().get(path).then().statusCode(404);
given().delete(path).then().statusCode(404); given().delete(path).then().statusCode(404);
} }
given().get(school).then().body("dancerIds", empty()).body("danceGroupIds", empty()).body("showIds", empty()); given().get(school)
.then()
.body("dancerIds", empty())
.body("danceGroupIds", empty())
.body("showIds", empty());
} finally { } finally {
given().delete(school).then().statusCode(204); given().delete(school).then().statusCode(204);
} }
given().get(school).then().statusCode(404); given().get(school).then().statusCode(404);
} }
@Test public @Test void dancerCanBelongToMultipleGroupsAndDeletingDancerRemovesMemberships() {
void dancerCanBelongToMultipleGroupsAndDeletingDancerRemovesMemberships() { final String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Membership school"));
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Membership school"));
try { try {
long dancerId = create(school + "/dancers", DANCER); final long dancerId = create(school + "/dancers", DANCER);
String first = school + "/dance-groups/" + create(school + "/dance-groups", GROUP); final String first = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
String second = school + "/dance-groups/" + create(school + "/dance-groups", GROUP); final String second = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
for (String group : new String[]{first, second}) { 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().put(group + "/dancers/" + dancerId).then().statusCode(204); given().put(group + "/dancers/" + dancerId).then().statusCode(204);
given().get(group).then().body("dancerIds", contains((int) dancerId)); given().get(group).then().body("dancerIds", contains((int) dancerId));
@@ -89,23 +90,24 @@ class SchoolManagementResourceTest {
} }
} }
@Test public @Test void isolatesSchoolsAndCascadesSchoolDeletion() {
void isolatesSchoolsAndCascadesSchoolDeletion() { final String first = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "First"));
String first = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "First")); final String second = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Second"));
String second = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Second"));
try { try {
long dancer = create(first + "/dancers", DANCER); final long dancer = create(first + "/dancers", DANCER);
long group = create(first + "/dance-groups", GROUP); final long group = create(first + "/dance-groups", GROUP);
long show = create(first + "/shows", SHOW); final long show = create(first + "/shows", SHOW);
long otherGroup = create(second + "/dance-groups", GROUP); final long otherGroup = create(second + "/dance-groups", GROUP);
for (String path : new String[]{"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) { for (final String path : new String[] {"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) {
given().get(second + path).then().statusCode(404); given().get(second + path).then().statusCode(404);
given().delete(second + path).then().statusCode(404); given().delete(second + path).then().statusCode(404);
} }
put(second + "/dancers/" + dancer, DANCER).statusCode(404); put(second + "/dancers/" + dancer, DANCER).statusCode(404);
put(second + "/dance-groups/" + group, GROUP).statusCode(404); put(second + "/dance-groups/" + group, GROUP).statusCode(404);
put(second + "/shows/" + show, SHOW).statusCode(404); put(second + "/shows/" + show, SHOW).statusCode(404);
given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer).then().statusCode(404); given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer)
.then()
.statusCode(404);
given().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty()); given().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty());
given().delete(first).then().statusCode(204); given().delete(first).then().statusCode(204);
given().get(first + "/dancers").then().statusCode(404); given().get(first + "/dancers").then().statusCode(404);
@@ -118,68 +120,175 @@ class SchoolManagementResourceTest {
} }
} }
@Test public @Test void rejectsInvalidInputWithoutChangingStoredData() {
void rejectsInvalidInputWithoutChangingStoredData() {
given().contentType("application/json").body("{}").post(SCHOOLS).then().statusCode(400); given().contentType("application/json").body("{}").post(SCHOOLS).then().statusCode(400);
given().contentType("application/json").body("null").post(SCHOOLS).then().statusCode(400); given().contentType("application/json")
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Validation school")); .body("null")
.post(SCHOOLS)
.then()
.statusCode(400);
final String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Validation school"));
try { try {
put(school, Map.of("name", " ")).statusCode(400); put(school, Map.of("name", " ")).statusCode(400);
given().get(school).then().body("name", equalTo("Validation school")); given().get(school).then().body("name", equalTo("Validation school"));
for (Object body : new Object[]{Map.of("name", "Sam"), Map.of("name", "Sam", "birthDate", "2999-01-01"), rejectsInvalidDancersAndGroups(school);
Map.of("name", "Sam", "birthDate", "not-a-date")}) { final long group = create(school + "/dance-groups", GROUP);
given().contentType("application/json").body(body).post(school + "/dancers").then().statusCode(400); put(
} school + "/dance-groups/" + group,
for (String body : new String[]{"{}", "{\"category\":\"Teens\",\"type\":\"INVALID\"}", Map.of(
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}", "category",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"}) { "Invalid",
given().contentType("application/json").body(body).post(school + "/dance-groups").then().statusCode(400); "type",
} "REGULAR",
long group = create(school + "/dance-groups", GROUP); "startAge",
put(school + "/dance-groups/" + group, Map.of("category", "Invalid", "type", "REGULAR", 20,
"startAge", 20, "endAge", 10, "styles", new String[]{})).statusCode(400); "endAge",
10,
"styles",
new String[] {}))
.statusCode(400);
given().get(school + "/dance-groups/" + group).then().body("category", equalTo("Teens")); given().get(school + "/dance-groups/" + group).then().body("category", equalTo("Teens"));
given().contentType("application/json").body(Map.of("name", "Show")) given().contentType("application/json")
.post(school + "/shows").then().statusCode(400); .body(Map.of("name", "Show"))
.post(school + "/shows")
.then()
.statusCode(400);
given().get(school + "/dancers").then().body("$", empty()); given().get(school + "/dancers").then().body("$", empty());
given().get(school + "/shows").then().body("$", empty()); given().get(school + "/shows").then().body("$", empty());
given().contentType("application/json").body(DANCER) given().contentType("application/json")
.post(SCHOOLS + "/9223372036854775807/dancers").then().statusCode(404); .body(DANCER)
.post(SCHOOLS + "/9223372036854775807/dancers")
.then()
.statusCode(404);
} finally { } finally {
given().delete(school).then().statusCode(204); given().delete(school).then().statusCode(204);
} }
} }
@Test public @Test void schedulesAndValidatesTheNewShowConfiguration() {
void schedulesAndValidatesTheNewShowConfiguration() { final String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Scheduling school"));
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Scheduling school"));
try { 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); 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().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group)); given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
String before = given().get(show).asString(); final String before = given().get(show).asString();
String counts = "{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}"; for (final String count : new String[] {"0", "2", "1.5", "7"}) {
for (String count : new String[]{"0", "2", "1.5", "null"}) { final String body =
String body = "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":" + counts.formatted(count) + "}"; "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"preferredDayParts\":"
+ count + "}";
put(show, body).statusCode(400); put(show, body).statusCode(400);
given().get(show).then().body(equalTo(before)); given().get(show).then().body(equalTo(before));
} }
String exported = given().get(school + "/export").then().statusCode(200).extract().asString(); final String exported = given().get(school + "/export")
given().contentType("application/json").body(exported).post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1)) .then()
.then().statusCode(200); .statusCode(200)
given().get(school + "/export").then().body("$", equalTo(io.restassured.path.json.JsonPath.from(exported).getMap("$"))); .extract()
} finally { given().delete(school); } .asString();
given().contentType("application/json")
.body(exported)
.post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
.then()
.statusCode(200);
given().get(school + "/export")
.then()
.body(
"$",
equalTo(io.restassured.path.json.JsonPath.from(exported)
.getMap("$")));
} finally {
given().delete(school);
}
} }
private static long create(String path, Object body) { private static String manageGroup(final String school, final long dancerId) {
return given().contentType("application/json").body(body).post(path).then() final long groupId = create(school + "/dance-groups", GROUP);
.statusCode(201).header("Location", matchesPattern(".*/[0-9]+$")) final String group = school + "/dance-groups/" + groupId;
.extract().jsonPath().getLong("id"); 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 ValidatableResponse put(String path, Object body) { 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)
.then()
.statusCode(201)
.header("Location", matchesPattern(".*/[0-9]+$"))
.extract()
.jsonPath()
.getLong("id");
}
private static ValidatableResponse put(final String path, final Object body) {
return given().contentType("application/json").body(body).put(path).then(); return given().contentType("application/json").body(body).put(path).then();
} }
} }

View File

@@ -1,32 +1,74 @@
package nl.agilecc.agiledancers; package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
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 io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
@QuarkusTest @QuarkusTest
class SchoolSnapshotResourceTest { class SchoolSnapshotResourceTest {
private static final String BASE = "/api/dance-schools"; private static final String BASE = "/api/dance-schools";
@Test void downloadPreviewRestoreAndRejectInvalidFiles() {
long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE) public @Test void downloadPreviewRestoreAndRejectInvalidFiles() {
.then().statusCode(201).extract().jsonPath().getLong("id"); long id = given().contentType("application/json")
String json = given().get(BASE + "/" + id + "/export").then().statusCode(200) .body("{\"name\":\"JSON school\"}")
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(2)).extract().asString(); .post(BASE)
.then()
.statusCode(201)
.extract()
.jsonPath()
.getLong("id");
final String json = given().get(BASE + "/" + id + "/export")
.then()
.statusCode(200)
.header("Content-Disposition", containsString(".json"))
.body("formatVersion", equalTo(3))
.extract()
.asString();
try { try {
given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200) given().contentType("application/json")
.body("existingId", equalTo((int) id)).body("dancers", equalTo(0)); .body(json)
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"), .post(BASE + "/import/preview")
json.replace("\"name\" :", "\"unknown\" :"), json + " {}", .then()
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) { .statusCode(200)
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id) .body("existingId", equalTo((int) id))
.then().statusCode(400).body("message", not(emptyOrNullString())); .body("dancers", equalTo(0));
} rejectInvalidFiles(id, json);
given().get(BASE + "/" + id + "/export").then().body(equalTo(json)); given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
given().delete(BASE + "/" + id).then().statusCode(204); given().delete(BASE + "/" + id).then().statusCode(204);
id = given().contentType("application/json").body(json).post(BASE + "/import") id = given().contentType("application/json")
.then().statusCode(200).extract().jsonPath().getLong("id"); .body(json)
.post(BASE + "/import")
.then()
.statusCode(200)
.extract()
.jsonPath()
.getLong("id");
given().get(BASE + "/" + id + "/export").then().body(equalTo(json)); given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
} finally { given().delete(BASE + "/" + id); } } finally {
given().delete(BASE + "/" + id);
}
}
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,71 +1,130 @@
package nl.agilecc.agiledancers; package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.service.model.*; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import nl.agilecc.agiledancers.service.*; import static org.junit.jupiter.api.Assertions.assertEquals;
import nl.agilecc.agiledancers.model.specifications.*; import static org.junit.jupiter.api.Assertions.assertNull;
import org.junit.jupiter.api.Test; 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.time.LocalDate;
import java.util.*; import java.util.List;
import static org.junit.jupiter.api.Assertions.*; 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 { class SchoolSnapshotTest {
private SchoolSnapshot complete() { private SchoolSnapshot complete() {
return new SchoolSnapshot(2, UUID.randomUUID(), "Studio", List.of(new Person("a", "Sam", LocalDate.of(2010,1,1))), return new SchoolSnapshot(
2,
UUID.randomUUID(),
"Studio",
List.of(new Person("a", "Sam", LocalDate.of(2010, 1, 1))),
List.of(new Trainer("c", "Alex")), List.of(new Trainer("c", "Alex")),
List.of(new Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"), List.of(
new Group(
"g",
"Hiphop",
DanceGroupType.REGULAR,
0,
99,
List.of(DanceGroupStyle.HIPHOP),
List.of("a"),
"c"),
new Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")), new Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
List.of(new Performance("s", "Show", List.of(new Performance(
List.of(new Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1)))); "s",
"Show",
List.of(new Part(LocalDate.of(2027, 1, 1), DayPartType.MORNING, List.of("g", "h"))),
ShowConfiguration.defaults(1))));
} }
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
var snapshot = complete(); public @Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
var service = new SchoolManagementService(); final SchoolSnapshot snapshot = complete();
var existing = service.createSchool("Other"); final var service = new SchoolManagementService();
service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000,1,1)); final DanceSchool existing = service.createSchool("Other");
var imported = service.importSchool(snapshot, null); service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000, 1, 1));
final DanceSchool imported = service.importSchool(snapshot, null);
assertEquals(2, imported.getDanceGroups().size()); 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())); imported.getDanceGroups()
.forEach((final DanceGroup g) ->
assertSame(dancer, g.getDancers().iterator().next()));
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(imported.getId())); assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(imported.getId()));
var restarted = new SchoolManagementService(); final var restarted = new SchoolManagementService();
var restored = restarted.importSchool(service.exportSchool(imported.getId()), null); final DanceSchool restored = restarted.importSchool(service.exportSchool(imported.getId()), null);
assertEquals(SchoolSnapshot.of(snapshot.restore()), restarted.exportSchool(restored.getId())); assertEquals(SchoolSnapshot.of(snapshot.restore()), restarted.exportSchool(restored.getId()));
// Membership removal must work after remapping hash-based sets. // Membership removal must work after remapping hash-based sets.
service.deleteDancer(imported.getId(), dancer.getId()); 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()); assertEquals(1, existing.getDancers().size());
var group = imported.getDanceGroups().iterator().next(); final DanceGroup group = imported.getDanceGroups().iterator().next();
service.deleteGroup(imported.getId(), group.getId()); service.deleteGroup(imported.getId(), group.getId());
assertEquals(1, imported.getShows().iterator().next().getDayParts().getFirst().getDanceGroups().size()); assertEquals(
1,
imported.getShows()
.iterator()
.next()
.getDayParts()
.getFirst()
.getDanceGroups()
.size());
assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore()); assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore());
} }
@Test void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
var service = new SchoolManagementService(); var snapshot = complete(); public @Test void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
assertNull(service.preview(snapshot).existingId()); assertTrue(service.schools().isEmpty()); final var service = new SchoolManagementService();
var original = service.importSchool(snapshot, null); final SchoolSnapshot snapshot = complete();
service.createDancer(original.getId(), "Extra", LocalDate.of(2000,1,1)); assertNull(service.preview(snapshot).existingId());
var other = service.createSchool(snapshot.name()); assertTrue(service.schools().isEmpty());
final DanceSchool original = service.importSchool(snapshot, null);
service.createDancer(original.getId(), "Extra", LocalDate.of(2000, 1, 1));
final DanceSchool other = service.createSchool(snapshot.name());
assertEquals(original.getId(), service.preview(snapshot).existingId()); assertEquals(original.getId(), service.preview(snapshot).existingId());
assertThrows(ManagementException.class, () -> service.importSchool(snapshot, null)); assertThrows(ManagementException.class, () -> service.importSchool(snapshot, null));
assertEquals(2, original.getDancers().size()); 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())); assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(replaced.getId()));
assertSame(other, service.school(other.getId())); assertSame(other, service.school(other.getId()));
service.deleteSchool(replaced.getId()); service.deleteSchool(replaced.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId())); assertEquals(
SchoolSnapshot.of(snapshot.restore()),
service.exportSchool(service.importSchool(snapshot, null).getId()));
} }
@Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var service = new SchoolManagementService(); var valid = complete(); public @Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var original = service.importSchool(valid, null); final var service = new SchoolManagementService();
var bad = new SchoolSnapshot(2, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows()); 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.preview(bad));
assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId())); assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId()));
assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId())); assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId()));
} }
@Test void emptySchoolRoundTrips() {
var service = new SchoolManagementService(); public @Test void emptySchoolRoundTrips() {
var empty = service.createSchool("Empty"); var snapshot = service.exportSchool(empty.getId()); final var service = new SchoolManagementService();
final DanceSchool empty = service.createSchool("Empty");
final SchoolSnapshot snapshot = service.exportSchool(empty.getId());
service.deleteSchool(empty.getId()); service.deleteSchool(empty.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId())); assertEquals(
SchoolSnapshot.of(snapshot.restore()),
service.exportSchool(service.importSchool(snapshot, null).getId()));
} }
} }

View File

@@ -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,68 +1,126 @@
package nl.agilecc.agiledancers.service; package nl.agilecc.agiledancers.service;
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.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.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine; import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
class SchedulingServiceTest { class SchedulingServiceTest {
@Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() { public @Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
var schools = new SchoolManagementService(); final var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId(); final long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of()); populatedGroup(schools, school);
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1)) final Show show = schools.createShow(
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null); school,
var scheduling = new SchedulingService(); "Show",
scheduling.schools = schools; List.of(DayPart.builder()
scheduling.engine = new OrToolsSchedulingEngine(); .date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build()),
null);
final var scheduling = new SchedulingService(schools, new OrToolsSchedulingEngine());
scheduling.schedule(school, show.getId()); scheduling.schedule(school, show.getId());
var before = schools.exportSchool(school); final SchoolSnapshot before = schools.exportSchool(school);
for (SchedulingEngine failure : List.<SchedulingEngine>of( for (final SchedulingEngine failure : List.<SchedulingEngine>of(
input -> { throw new IllegalStateException("Native failure"); }, (final SchedulingInput input) -> {
input -> { throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit"); }, throw new IllegalStateException("Native failure");
input -> null)) { },
scheduling.engine = failure; (final SchedulingInput input) -> {
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId())); throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit");
},
(final SchedulingInput input) -> null)) {
final var failedScheduling = new SchedulingService(schools, failure);
assertThrows(ManagementException.class, () -> failedScheduling.schedule(school, show.getId()));
assertEquals(before, schools.exportSchool(school)); assertEquals(before, schools.exportSchool(school));
var next = schools.beginScheduling(school, show.getId()); final SchedulingInput next = schools.beginScheduling(school, show.getId());
schools.endScheduling(next.showId()); schools.endScheduling(next.showId());
} }
} }
@Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception { public @Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
var schools = new SchoolManagementService(); final var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId(); final long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of()); populatedGroup(schools, school);
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1)) final Show show = schools.createShow(
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null); school,
var scheduling = new SchedulingService(); scheduling.schools = schools; "Show",
var entered = new CountDownLatch(1); var release = new CountDownLatch(1); List.of(DayPart.builder()
scheduling.engine = input -> { .date(LocalDate.of(2027, 1, 1))
entered.countDown(); .type(DayPartType.MORNING)
try { if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout"); } .danceGroups(List.of())
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } .build()),
return new OrToolsSchedulingEngine().solve(input); null);
}; final var entered = new CountDownLatch(1);
try (var executor = Executors.newSingleThreadExecutor()) { final var release = new CountDownLatch(1);
var first = executor.submit(() -> scheduling.schedule(school, show.getId())); 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 { try {
assertTrue(entered.await(10, TimeUnit.SECONDS)); assertTrue(entered.await(10, TimeUnit.SECONDS));
assertEquals(ManagementExceptionKind.CONFLICT, assertThrows(ManagementException.class, assertEquals(
() -> scheduling.schedule(school, show.getId())).getKind()); ManagementExceptionKind.CONFLICT,
schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of()); assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
} finally { release.countDown(); } .getKind());
var exception = assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS)); populatedGroup(schools, school);
} finally {
release.countDown();
}
final ExecutionException exception =
assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS));
assertInstanceOf(ManagementException.class, exception.getCause()); assertInstanceOf(ManagementException.class, exception.getCause());
assertTrue(exception.getCause().getMessage().contains("inputs changed")); assertTrue(exception.getCause().getMessage().contains("inputs changed"));
assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty()); assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty());
} }
scheduling.engine = new OrToolsSchedulingEngine(); new SchedulingService(schools, new OrToolsSchedulingEngine()).schedule(school, show.getId());
scheduling.schedule(school, show.getId());
assertEquals(2, show.getDayParts().getFirst().getDanceGroups().size()); 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,21 +6,18 @@ import nl.agilecc.agiledancers.service.DanceSchoolService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class DanceSchoolSit { class DanceSchoolSit {
Faker faker = new Faker(); private final Faker faker = new Faker();
int numberOfDanceGroups = 42; private final int numberOfDanceGroups = 42;
int aantalDansersTm5 = 10; private final int aantalDansersTm5 = 10;
int aantalDansers6tm10 = 30; private final int aantalDansers6tm10 = 30;
int aantalDansers11tot14 = 70; private final int aantalDansers11tot14 = 70;
int aantalDansers14tm17 = 140; private final int aantalDansers14tm17 = 140;
int aantalDansers18Plus = 100; private final int aantalDansers18Plus = 100;
private DanceSchoolService danceSchoolService = new DanceSchoolService();
@Test
void testDanceSchoolModel() {
DanceSchool danceSchool = danceSchoolService.add("24 Dance Studio");
private final DanceSchoolService danceSchoolService = new DanceSchoolService();
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 ({ test('manages a school, shared memberships and shows through the real REST API', async ({
page, page,
request,
}) => { }) => {
const schoolName = `E2E Studio ${Date.now()}`; const schoolName = `E2E Studio ${Date.now()}`;
const browserErrors: string[] = []; const browserErrors: string[] = [];
@@ -107,6 +108,26 @@ test('manages a school, shared memberships and shows through the real REST API',
).toBeVisible(); ).toBeVisible();
await page.screenshot({ path: 'test-results/groups-desktop.png', fullPage: true }); 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 tab(page, 'Shows');
await page.getByRole('button', { name: 'Show toevoegen' }).click(); await page.getByRole('button', { name: 'Show toevoegen' }).click();
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow'); 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'); await expect(page.locator('.date-tile')).toContainText('jul 2027');
page.once('dialog', (dialog) => dialog.accept()); page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: /Schedule groups/ }).click(); await page.getByRole('button', { name: /Schedule groups/ }).click();
await expect(page.getByText('Planning voldoet aan alle optredensaantallen.')).toBeVisible(); await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
await expect(page.getByRole('status').filter({ hasText: 'Optimale balans.' })).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: 'Zomershow bewerken' }).click();
await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click(); await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click();
await page.getByLabel('Datum', { exact: true }).nth(1).fill('2027-07-02'); 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 tab(page, 'Dansers');
await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click(); await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click();
await confirmDelete(page); 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 tab(page, 'Dansgroepen');
await expect(page.getByText(/0 dansers/)).toHaveCount(2); await expect(page.getByText(/0 dansers/)).toHaveCount(2);
await page.getByRole('button', { name: 'Hiphop Selectie verwijderen' }).click(); 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([]); 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 }) => { test('shows a useful connection error and recovers on retry', async ({ page }) => {
await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed')); await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed'));
await page.goto('/schools'); await page.goto('/schools');

View File

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

View File

@@ -24,7 +24,14 @@ describe('SchoolApi contract', () => {
http.expectOne('/api/dance-schools/7/dance-groups').flush([]); http.expectOne('/api/dance-schools/7/dance-groups').flush([]);
expect(received).not.toHaveBeenCalled(); expect(received).not.toHaveBeenCalled();
http.expectOne('/api/dance-schools/7/shows').flush([]); 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', () => { 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 { HttpClient } from '@angular/common/http';
import { forkJoin, Observable } from 'rxjs'; import { forkJoin, Observable } from 'rxjs';
import { import {
Coach,
DanceGroup, DanceGroup,
Dancer, Dancer,
DancerRequest, DancerRequest,
@@ -29,6 +30,7 @@ export class SchoolApi {
dancers: this.http.get<Dancer[]>(`${base}/dancers`), dancers: this.http.get<Dancer[]>(`${base}/dancers`),
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`), groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
shows: this.http.get<Show[]>(`${base}/shows`), shows: this.http.get<Show[]>(`${base}/shows`),
coaches: this.http.get<Coach[]>(`${base}/coaches`),
}); });
} }
saveSchool(body: SchoolRequest, id?: number): Observable<School> { saveSchool(body: SchoolRequest, id?: number): Observable<School> {
@@ -52,6 +54,20 @@ export class SchoolApi {
{ timeout: 1860000 }, { 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> { delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[ const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
kind kind

View File

@@ -151,6 +151,7 @@
</section> </section>
} }
@case ('dancers') { @case ('dancers') {
<app-coach-editor [data]="context" (changed)="reload()" />
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>Dansers</h2> <h2>Dansers</h2>
@@ -350,6 +351,16 @@
</div> </div>
<div class="show-details"> <div class="show-details">
<h3>{{ show.name }}</h3> <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> <p>{{ dateRange(show) }}</p>
@for (part of show.dayParts; track $index) { @for (part of show.dayParts; track $index) {
<p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</p> <p>{{ part.date }} · {{ part.type }}: {{ groupNamesFor(part.danceGroupIds) }}</p>
@@ -357,7 +368,7 @@
<p role="status"> <p role="status">
{{ {{
scheduleValid(show) scheduleValid(show)
? 'Planning voldoet aan alle optredensaantallen.' ? 'Planning voldoet aan alle harde regels.'
: 'Geen geldige planning. Plan de groepen opnieuw in.' : 'Geen geldige planning. Plan de groepen opnieuw in.'
}} }}
</p> </p>
@@ -365,7 +376,10 @@
type="button" type="button"
class="button primary" class="button primary"
[disabled]=" [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)" (click)="schedule(show)"
> >
@@ -426,6 +440,15 @@
(saved)="saved()" (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) { @if (members(); as group) {
<app-group-members <app-group-members
[group]="group" [group]="group"

View File

@@ -17,6 +17,8 @@ import { apiError } from '../core/api-error';
import { SchoolApi } from '../core/school-api'; import { SchoolApi } from '../core/school-api';
import { EntityEditor } from '../shared/entity-editor'; import { EntityEditor } from '../shared/entity-editor';
import { ConfirmDialog } from '../shared/confirm-dialog'; import { ConfirmDialog } from '../shared/confirm-dialog';
import { ScheduleEditor } from '../shared/schedule-editor';
import { CoachEditor } from '../shared/coach-editor';
import { GroupMembers } from '../shared/group-members'; import { GroupMembers } from '../shared/group-members';
interface DeleteTarget { interface DeleteTarget {
@@ -29,6 +31,8 @@ interface DeleteTarget {
@Component({ @Component({
selector: 'app-school-page', selector: 'app-school-page',
imports: [ imports: [
ScheduleEditor,
CoachEditor,
SchoolTransfer, SchoolTransfer,
DatePipe, DatePipe,
RouterLink, RouterLink,
@@ -50,6 +54,7 @@ export class SchoolPage implements OnInit {
readonly error = signal(''); readonly error = signal('');
readonly notice = signal(''); readonly notice = signal('');
readonly editor = signal<EditorTarget | null>(null); readonly editor = signal<EditorTarget | null>(null);
readonly scheduleEditing = signal<Show | null>(null);
readonly members = signal<DanceGroup | null>(null); readonly members = signal<DanceGroup | null>(null);
readonly deleting = signal<DeleteTarget | null>(null); readonly deleting = signal<DeleteTarget | null>(null);
readonly scheduling = signal<number[]>([]); readonly scheduling = signal<number[]>([]);
@@ -86,6 +91,7 @@ export class SchoolPage implements OnInit {
this.search.set(''); this.search.set('');
this.editor.set(null); this.editor.set(null);
this.members.set(null); this.members.set(null);
this.scheduleEditing.set(null);
this.deleting.set(null); this.deleting.set(null);
this.section.set(params.get('section') ?? 'overview'); this.section.set(params.get('section') ?? 'overview');
return this.api.schoolData(Number(params.get('schoolId'))).pipe( 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}`; return !first ? 'No dates configured' : first === last ? first : `${first} – ${last}`;
} }
scheduleValid(show: Show): boolean { scheduleValid(show: Show): boolean {
const groups = this.data()?.groups ?? []; return show.violations?.length === 0;
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)),
)
);
} }
groupNamesFor(ids: number[]): string { groupNamesFor(ids: number[]): string {
return ( return (
ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') || ids.map((id) => this.data()?.groups.find((g) => g.id === id)?.category ?? '?').join(', ') ||
@@ -163,7 +156,9 @@ export class SchoolPage implements OnInit {
try { try {
const result = await firstValueFrom(this.api.schedule(schoolId, show.id)); const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
this.notice.set( 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(); await this.reload();
} catch (error: unknown) { } catch (error: unknown) {
@@ -221,6 +216,7 @@ export class SchoolPage implements OnInit {
this.deleting.set(target); this.deleting.set(target);
} }
membershipChanged(ids: number[]): void { membershipChanged(ids: number[]): void {
void this.reload();
const groupId = this.members()?.id; const groupId = this.members()?.id;
this.data.update((data) => this.data.update((data) =>
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 = { const show: Show = {
id: 3, id: 3,
name: 'Summer', name: 'Summer',
preferredDayParts: 2,
finalized: false,
violations: [],
dayParts: [ dayParts: [
{ date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] }, { date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] },
{ date: '2027-06-02', 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 }, 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' }); const component = editor({ kind: 'show' });
component.name = 'Show'; component.name = 'Show';
expect(component.validDetails()).toBe(true); expect(component.validDetails()).toBe(true);
@@ -87,31 +90,45 @@ describe('EntityEditor', () => {
component.dayParts[1].date = '2027-06-02'; component.dayParts[1].date = '2027-06-02';
component.addPart(); component.addPart();
component.dayParts[2].date = '2027-06-03'; component.dayParts[2].date = '2027-06-03';
expect(component.appearances).toEqual({ expect(component.preferredDayParts).toBe(2);
REGULAR: 2,
SELECTION: 2,
PRE_SCHOOLER: 1,
SHOW_CASE: 3,
PRE_EDUCATION: 3,
});
expect(component.validDetails()).toBe(true); expect(component.validDetails()).toBe(true);
component.dayParts[2].date = '2027-06-02'; component.dayParts[2].date = '2027-06-02';
expect(component.validDetails()).toBe(false); expect(component.validDetails()).toBe(false);
component.dayParts[2].date = '2027-06-03'; component.dayParts[2].date = '2027-06-03';
component.appearances.PRE_SCHOOLER = 1.5; component.preferredDayParts = 1.5;
expect(component.validDetails()).toBe(false); 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 }); const component = editor({ kind: 'show', value: show });
component.addPart(); component.addPart();
component.dayParts[2].date = '2027-06-03'; component.dayParts[2].date = '2027-06-03';
expect(component.appearances.SHOW_CASE).toBe(2); expect(component.preferredDayParts).toBe(2);
component.removePart(2); component.removePart(2);
component.removePart(1); component.removePart(1);
expect(component.appearances.REGULAR).toBe(2); expect(component.preferredDayParts).toBe(2);
expect(component.validDetails()).toBe(false); expect(component.validDetails()).toBe(true);
component.setSingleCounts(); component.preferredDayParts = 1;
expect(component.validDetails()).toBe(true); expect(component.validDetails()).toBe(true);
}); });
@@ -120,11 +137,11 @@ describe('EntityEditor', () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
try { try {
const component = editor({ kind: 'show', value: show }); const component = editor({ kind: 'show', value: show });
component.appearances.REGULAR = 1; component.preferredDayParts = 1;
await component.save(); await component.save();
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer')); expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
expect(api.saveShow).not.toHaveBeenCalled(); expect(api.saveShow).not.toHaveBeenCalled();
component.appearances.REGULAR = 2; component.preferredDayParts = 2;
confirm.mockClear(); confirm.mockClear();
component.name = 'Renamed'; component.name = 'Renamed';
await component.save(); 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. // jsdom does not implement native dialog opening.
const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal'); const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal');
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', { Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
@@ -163,8 +180,8 @@ describe('EntityEditor', () => {
fixture.detectChanges(); fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
const inputs = Array.from(element.querySelectorAll<HTMLInputElement>('input[type="number"]')); const inputs = Array.from(element.querySelectorAll<HTMLInputElement>('input[type="number"]'));
expect(inputs).toHaveLength(5); expect(inputs).toHaveLength(1);
expect(inputs.every((input) => input.disabled && input.value === '1')).toBe(true); expect(inputs.every((input) => !input.disabled && input.value === '1')).toBe(true);
} finally { } finally {
if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor); if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor);
else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal'); else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal');

View File

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

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

View File

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

View File

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

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