28 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
4cfd1d71de Merge pull request 'feature/S9_ortools-phase1' (#6) from feature/S9_ortools-phase1 into main
All checks were successful
Build, test and publish / verify (push) Successful in 2m44s
Reviewed-on: #6
2026-10-30 15:20:57 +01:00
38247d0705 ortools review on some design issues and fixed flaky test
All checks were successful
Build, test and publish / verify (push) Successful in 1m34s
2026-10-30 15:14:27 +01:00
1b4c7f7f2f S9 OR Tools basic scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s
2026-10-30 15:00:45 +01:00
f64aa9123d Merge pull request 'refinement of spec S9 and updated S2 as implemented' (#5) from refinement into main
All checks were successful
Build, test and publish / verify (push) Successful in 1m44s
Reviewed-on: #5
2026-10-30 11:50:36 +01:00
9171a2d932 refinement of spec S9 and updated S2 as implemented
All checks were successful
Build, test and publish / verify (push) Successful in 1m38s
2026-10-30 11:46:06 +01:00
aa29283a94 Merge pull request 'S2 EsLint implementation' (#4) from feature/S2_eslint into main
All checks were successful
Build, test and publish / verify (push) Successful in 1m50s
Reviewed-on: #4
2026-10-29 22:07:38 +01:00
8f3026e6cd S2 EsLint implementation
All checks were successful
Build, test and publish / verify (push) Successful in 1m40s
2026-10-29 22:05:11 +01:00
178 changed files with 13090 additions and 994 deletions

View File

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

View File

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

View File

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

2
.husky/pre-commit Normal file
View File

@@ -0,0 +1,2 @@
cd agiledancers-fe || exit 1
node node_modules/lint-staged/bin/lint-staged.js --concurrent false

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["angular.ng-template", "dbaeumer.vscode-eslint"]
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"eslint.workingDirectories": ["./agiledancers-fe"]
}

View File

@@ -1,6 +1,8 @@
# Project skills and specifications
- Project skills are stored in `skills/`; specifications belong in `specs/`.
- For code reviews, read and follow `skills/code-review/SKILL.md`. If no
dedicated Skill tool is available, load the file directly.
- Use `grill-me` only when the user explicitly requests it, for example
`Use grill-me to review this plan` or `Gebruik grill-me voor dit ontwerp`.
- Before using it, read `skills/grill-me/SKILL.md` and its dependency
@@ -10,3 +12,11 @@
`grilling` automatically to ordinary development requests.
- Keep the upstream skill files unchanged. Source revision and license are
recorded in `skills/upstream.json` and `skills/LICENSE.mattpocock`.
# Backend changes and reviews
- Before changing backend Java, read `CODING_STANDARDS.md`.
- For reviews, use `skills/code-review/SKILL.md` together with
`docs/backend-review.md`. Keep upstream skills unchanged.
- Obtain maintainer agreement before adding any standards waiver; record its
narrow scope and reason alongside the affected code or configuration.

255
CODING_STANDARDS.md Normal file
View File

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

View File

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

166
README.md
View File

@@ -76,6 +76,20 @@ implementations in an outer adapter. For future calculations, define a port in
`service.port` using domain types and implement it in `solver`; services must not
depend on solver implementations or expose OR-Tools types.
Handwritten production types each have their own file and must be top-level.
Service records live in `service.model`; the school JSON snapshot records and
import preview are service-owned data models. Scheduling records shared with
the solver live in `model.scheduling`. Each group requirement combines its group
ID and appearance count in one record.
`service.port.SchedulingEngine` is the application-owned interface for solving a
scheduling problem. `SchedulingService` calls this interface; dependency injection
supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain
types, so neither the caller nor the contract depends on OR-Tools or the concrete
school-management service. Architecture tests enforce the port's location and
dependencies, the location of service records, and the top-level type rule
(excluding generated Lombok builders and compiler helpers).
## School management REST API
@@ -117,7 +131,7 @@ Request bodies (dates use ISO `yyyy-MM-dd`):
```
```json
{"name": "Annual show", "date": "2027-06-12"}
{"name": "Annual show", "dayParts": [{"date": "2027-06-12", "type": "AFTERNOON"}]}
```
Names and categories must be nonblank. Birth dates cannot be in the future.
@@ -138,9 +152,37 @@ Both operations return `204` and are idempotent for existing groups and dancers.
Deleting a dancer removes all their memberships; deleting a group retains the
dancers; deleting a school also deletes its dancers, groups and shows.
Shows have a name, a date and three automatically created day parts: `MORNING`,
`AFTERNOON`, `EVENING`. Each day part has an empty `danceGroupIds` list; assigning
groups is not exposed yet. Multiple shows per year are allowed.
Shows have a name, zero to six dated dayparts (`MORNING`, `AFTERNOON`, `EVENING`),
with unique date/type combinations. The displayed date range comes from these
dayparts. Each show stores `preferredDayParts`, defaulting to two or one for
one daypart. Ordinary groups, including PRE_EDUCATION, perform in exactly preferredDayParts
dayparts; preschool groups once; showcases every daypart. See
[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints,
coach exceptions, ordering and optimization rules.
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` generates an ordered
draft using OR-Tools with a total thirty-minute time limit. A feasible result is
accepted without claiming optimality. Concurrent runs and stale results return
`409`; failures retain existing assignments. Development and production proxies
allow 31 minutes.
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;
unknown IDs and malformed lists are rejected. `POST .../shows/{showId}/finalize`
checks every hard rule before marking a schedule final. Show responses include
`preferredDayParts`, `finalized` and `violations`. Relevant edits invalidate final
status. The UI supports ordering and moving performances, and displays violations.
`GET .../coaches` lists coach registrations. `POST .../coaches` with
`{"dancerId":1}` registers a dancer as a coach. `PUT .../coaches/{coachId}` uses
the same body to link an existing coach. `DELETE .../coaches/{coachId}/dancer`
removes the identity link. The dancers screen supports maintaining these links.
School JSON exports use format version **3**, including preferredDayParts, schedule status and
coach-to-dancer links. Version 2 remains importable; its schedules migrate to
drafts with default preferredDayParts. Version 1 is unsupported. Legacy `appearances` fields are
retained for file compatibility; S18 scheduling uses preferredDayParts and fixed type exceptions.
The REST `appearances` input is deprecated; use `preferredDayParts`.
Run the HTTP integration tests with:
@@ -169,6 +211,53 @@ backend on port 8080. Frontend builds and tests run independently of Maven:
`npm run build` and `npm test`. See the frontend README for browser tests and
production hosting requirements.
## Frontend lint and formatting
Run these commands from `agiledancers-fe/`:
```sh
npm run lint
npm run lint:fix
```
`lint` checks maintained application code, inline and external Angular templates,
unit tests, Playwright tests and JavaScript/TypeScript configuration. It uses the
recommended JavaScript, Angular and type-aware TypeScript rules. It changes no
files; both errors and warnings cause failure. Dependencies, generated output
and `package-lock.json` are excluded. `lint:fix` applies ESLint's available fixes;
review the diff and resolve any remaining findings manually. Prettier retains
responsibility for formatting with the existing configuration.
Every branch pipeline runs `npm run lint` immediately after frontend `npm ci`,
before frontend tests/builds and container E2E. Failure prevents image publication;
findings are in the job log and retained `ci/artifacts/verify.log`. CI only checks
and never fixes or stages source files.
Local `npm ci` automatically installs Husky at the repository root. Its commit
hook runs **Prettier only**, through lint-staged with backup, rollback and hiding
of unstaged changes in partially staged files enabled. It formats applicable
staged frontend TypeScript, JavaScript, HTML, CSS and JSON files, then stages the
formatting. Unrelated unstaged edits and files remain outside the commit. A
formatter error or a conflict restoring unstaged edits aborts the commit and
restores the original staged and unstaged state. Generated files are excluded;
commits without applicable frontend changes skip these checks.
The complete Prettier-plus-ESLint hook exceeds the agreed three-second budget
when all maintained files are staged. The selected Prettier-only hook meets the
budget for single-file, mixed and all-file commits. ESLint is therefore run
explicitly during development and always by CI. See [S2 validation](docs/s2-validation.md)
for the measured environment, timings and preservation checks.
Hook installation is skipped when `CI` is set (including Docker builds) or
`HUSKY=0`. Use `HUSKY=0 npm ci` to skip local installation, `HUSKY=0 git commit`
to skip one hook execution, or `git config --local core.hooksPath /dev/null` to
disable hooks for this clone. A subsequent normal `npm ci` enables them again.
VS Code recommends the ESLint extension in both root and frontend workspaces.
With it installed, findings appear in the editor and Problems panel; the root
workspace selects `agiledancers-fe/` as ESLint's working directory. No automatic
fixing or formatting on save is required. `npm run lint` uses the same rules as CI.
## Gitea CI
Every branch push builds and tests both applications and their Docker images.
@@ -176,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,
image names, diagnostics and recovery, and [validation evidence](docs/s4-validation.md)
for the current operational status.
Production releases and rollback use separate manual Gitea workflows. See the
[deployment runbook](docs/production-deployment.md) for Synology provisioning,
semantic version tags, release/retry/rollback and the accepted data-loss limitation
until S16, and [S6 evidence](docs/s6-validation.md) for deployment readiness.
### Java formatting (S8)
Maven is the authoritative formatting path. The root build runs the actual
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>
<!-- test dependencies -->
<dependency><groupId>io.quarkus</groupId><artifactId>quarkus-jacoco</artifactId><scope>test</scope></dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
@@ -62,6 +63,60 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version>
<executions><execution><id>standards-classpath</id><phase>prepare-package</phase><goals><goal>build-classpath</goal></goals>
<configuration><outputFile>${project.build.directory}/standards-classpath.txt</outputFile></configuration>
</execution>
<execution><id>coverage-tools</id><phase>prepare-package</phase><goals><goal>copy</goal></goals>
<configuration><artifactItems>
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.agent</artifactId><version>0.8.14</version><classifier>runtime</classifier><destFileName>agent.jar</destFileName></artifactItem>
<artifactItem><groupId>org.jacoco</groupId><artifactId>org.jacoco.cli</artifactId><version>0.8.14</version><classifier>nodeps</classifier><destFileName>cli.jar</destFileName></artifactItem>
</artifactItems><outputDirectory>${project.build.directory}/coverage-tools</outputDirectory></configuration>
</execution></executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.6.3</version>
<executions><execution><id>standards</id><phase>verify</phase><goals><goal>exec</goal></goals>
<configuration>
<executable>${maven.home}/bin/mvn</executable>
<workingDirectory>${maven.multiModuleProjectDirectory}</workingDirectory>
<arguments><argument>-B</argument><argument>-ntp</argument><argument>-f</argument>
<argument>${maven.multiModuleProjectDirectory}/tooling/standards/pom.xml</argument>
<argument>test</argument><argument>exec:java</argument>
</arguments>
</configuration>
</execution>
<execution><id>coverage</id><phase>verify</phase><goals><goal>exec</goal></goals>
<configuration><executable>${java.home}/bin/java</executable><arguments>
<argument>${maven.multiModuleProjectDirectory}/tooling/coverage/Coverage.java</argument>
<argument>${project.build.directory}/site/jacoco/jacoco.xml</argument>
</arguments></configuration>
</execution>
<execution><id>combined-coverage</id><phase>none</phase><goals><goal>exec</goal></goals>
<configuration><executable>${java.home}/bin/java</executable><arguments>
<argument>${maven.multiModuleProjectDirectory}/tooling/coverage/Coverage.java</argument>
<argument>${project.build.directory}/site/jacoco-combined/jacoco.xml</argument>
</arguments></configuration>
</execution></executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.14</version>
<executions>
<execution><id>agent</id><goals><goal>prepare-agent</goal></goals><configuration>
<exclClassLoaders>*QuarkusClassLoader</exclClassLoaders><destFile>${project.build.directory}/jacoco.exec</destFile><append>true</append>
</configuration></execution>
<execution><id>report</id><phase>post-integration-test</phase><goals><goal>report</goal></goals></execution>
<execution><id>combined</id><phase>none</phase><goals><goal>merge</goal></goals><configuration>
<fileSets><fileSet><directory>${project.build.directory}</directory><includes><include>jacoco.exec</include><include>playwright.exec</include></includes></fileSet></fileSets>
<destFile>${project.build.directory}/jacoco-combined.exec</destFile>
</configuration></execution>
<execution><id>combined-report</id><phase>none</phase><goals><goal>report</goal></goals><configuration>
<dataFile>${project.build.directory}/jacoco-combined.exec</dataFile>
<outputDirectory>${project.build.directory}/site/jacoco-combined</outputDirectory>
</configuration></execution>
</executions>
</plugin>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
package nl.agilecc.agiledancers.model;
import lombok.*;
import java.util.List;
import java.time.LocalDate;
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;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -12,8 +17,17 @@ import java.time.LocalDate;
public class Show extends AbstractEntity {
@Setter
private String name;
@Setter
private LocalDate date;
private Integer preferredDayParts;
@Setter
private boolean finalized;
@Setter
private Map<DanceGroupType, Integer> appearances;
@ToString.Include
private final List<DayPart> dayParts;
@Setter
private List<DayPart> dayParts;
}

View File

@@ -0,0 +1,21 @@
package nl.agilecc.agiledancers.model.scheduling;
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

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

View File

@@ -0,0 +1,16 @@
package nl.agilecc.agiledancers.model.scheduling;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public record SchedulingResult(boolean optimal, List<List<Long>> assignments) {
public 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;
public enum DanceGroupStyle {
HIPHOP, JAZZ, MODERN, STREETDANCE, CLASSIC, BREAKDANCE
HIPHOP,
JAZZ,
MODERN,
STREETDANCE,
CLASSIC,
BREAKDANCE
}

View File

@@ -1,18 +1,9 @@
package nl.agilecc.agiledancers.model.specifications;
import lombok.Getter;
public enum DanceGroupType {
SHOW_CASE(3),
PRE_EDUCATION(1), // TODO check if this is one group mentioned with different docent each time, or three different groups - and thus if they are required 1 day or 3
SELECTION(2),
REGULAR(2),
PRE_SCHOOLER(1);
@Getter
private int dayPartsRequired;
DanceGroupType(int dayPartsRequired) {
this.dayPartsRequired = dayPartsRequired;
}
SHOW_CASE,
PRE_EDUCATION,
SELECTION,
REGULAR,
PRE_SCHOOLER
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
package nl.agilecc.agiledancers.rest;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.jackson.ObjectMapperCustomizer;
import jakarta.inject.Singleton;
@Singleton
public class JacksonConfiguration implements ObjectMapperCustomizer {
@Override
public void customize(final ObjectMapper mapper) {
mapper.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,7 +1,22 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowRequest(@NotBlank String name, @NotNull LocalDate date) {}
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,14 +1,48 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.DanceGroup;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record ShowView(long id, String name, LocalDate date, List<DayPartView> dayParts) {
public static ShowView of(Show show) {
return new ShowView(show.getId(), show.getName(), show.getDate(), show.getDayParts().stream()
.map(dayPart -> new DayPartView(dayPart.getType(), dayPart.getDanceGroups().stream()
.map(DanceGroup::getId).toList())).toList());
public record ShowView(
long id,
String name,
List<DayPartView> dayParts,
Map<DanceGroupType, Integer> appearances,
int preferredDayParts,
boolean finalized,
List<String> violations) {
public ShowView {
violations = List.copyOf(violations);
dayParts = dayParts == null ? null : Collections.unmodifiableList(new ArrayList<>(dayParts));
appearances = appearances == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(appearances));
}
public static ShowView of(final Show show) {
return of(show, List.of());
}
public static ShowView of(final Show show, final List<String> violations) {
return new ShowView(
show.getId(),
show.getName(),
show.getDayParts().stream()
.map((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;
public class CoachService {
private CoachRepository coachRepository = new CoachRepository();
private final CoachRepository coachRepository = new CoachRepository();
public Coach addCoach(String name, Coach coach) {
public Coach addCoach(final String name, final Coach coach) {
return coachRepository.add(Coach.builder().name(name).build());
}
}

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,15 @@
package nl.agilecc.agiledancers.service;
public class ManagementException extends RuntimeException {
public enum Kind { NOT_FOUND, INVALID_INPUT }
private final Kind kind;
private final ManagementExceptionKind kind;
public ManagementException(Kind kind, String message) {
public ManagementException(final ManagementExceptionKind kind, final String message) {
super(message);
this.kind = kind;
}
public Kind getKind() {
public ManagementExceptionKind getKind() {
return kind;
}
}

View File

@@ -0,0 +1,7 @@
package nl.agilecc.agiledancers.service;
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

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

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
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.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public final class ShowConfiguration {
private ShowConfiguration() {}
public static List<DayPart> retainAssignments(final Show show, final List<DayPart> parts) {
return parts.stream()
.map((final DayPart p) -> DayPart.builder()
.date(p.getDate())
.type(p.getType())
.danceGroups(show.getDayParts().stream()
.filter((final DayPart old) ->
old.getDate().equals(p.getDate()) && old.getType() == p.getType())
.findFirst()
.map(DayPart::getDanceGroups)
.orElse(List.of()))
.build())
.toList();
}
public static Map<DanceGroupType, Integer> defaults(final int n) {
final Map<DanceGroupType, Integer> counts = new EnumMap<>(DanceGroupType.class);
if (n > 0) {
for (final DanceGroupType type : DanceGroupType.values()) {
counts.put(
type,
switch (type) {
case PRE_SCHOOLER -> 1;
case SHOW_CASE -> n;
default -> Math.min(2, n);
});
}
}
return counts;
}
public static void validate(final List<DayPart> parts, final Map<DanceGroupType, Integer> counts) {
validateParts(parts);
if (counts == null) {
invalidCounts();
}
if (parts.isEmpty() && counts.isEmpty()) {
return;
}
if (counts.size() != DanceGroupType.values().length
|| Arrays.stream(DanceGroupType.values())
.anyMatch((final DanceGroupType t) -> counts.get(t) == null
|| counts.get(t) < 1
|| (!parts.isEmpty() && counts.get(t) > parts.size()))) {
invalidCounts();
}
}
public static void validateParts(final List<DayPart> parts) {
if (parts == null
|| parts.size() > 6
|| parts.stream().anyMatch((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() {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
"Every type needs an integer appearance count between 1 and the number of dayparts. Saved counts are"
+ " retained when dayparts are removed.");
}
}

View File

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

@@ -0,0 +1,23 @@
package nl.agilecc.agiledancers.service.model;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record Group(
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

@@ -0,0 +1,3 @@
package nl.agilecc.agiledancers.service.model;
public record ImportPreview(String name, int dancers, int groups, int shows, Long existingId, String existingName) {}

View File

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

View File

@@ -0,0 +1,29 @@
package nl.agilecc.agiledancers.service.model;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
public record Performance(
String id,
String name,
List<Part> dayParts,
Map<DanceGroupType, Integer> appearances,
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

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.service.model;
import java.time.LocalDate;
public record Person(String id, String name, LocalDate birthDate) {}

View File

@@ -0,0 +1,259 @@
package nl.agilecc.agiledancers.service.model;
import java.time.LocalDate;
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 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,
List<Trainer> coaches,
List<Group> groups,
List<Performance> shows) {
public SchoolSnapshot {
dancers = dancers == null ? null : Collections.unmodifiableList(new ArrayList<>(dancers));
coaches = coaches == null ? null : Collections.unmodifiableList(new ArrayList<>(coaches));
groups = groups == null ? null : Collections.unmodifiableList(new ArrayList<>(groups));
shows = shows == null ? null : Collections.unmodifiableList(new ArrayList<>(shows));
}
public static SchoolSnapshot of(final DanceSchool s) {
final Map<Dancer, String> dancers = ids(s.getDancers());
final Map<Coach, String> coaches = ids(s.getCoaches());
final Map<DanceGroup, String> groups = ids(s.getDanceGroups());
final Map<Show, String> shows = ids(s.getShows());
return new SchoolSnapshot(
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;
}
/** Validates all references and values, then rebuilds an independent graph with temporary IDs. */
public DanceSchool restore() {
check(
Objects.equals(formatVersion, 2) || Objects.equals(formatVersion, 3),
"Niet-ondersteunde formatVersion: " + formatVersion);
check(key != null, "key ontbreekt");
text(name, "schoolnaam");
final DanceSchool school = DanceSchool.builder().key(key).name(name).build();
final Map<String, Dancer> dancerMap = restoreDancers(school);
final Map<String, Coach> coachMap = restoreCoaches(school, dancerMap);
final Map<String, DanceGroup> groupMap = restoreGroups(school, dancerMap, coachMap);
restoreShows(school, groupMap);
return school;
}
private Map<String, Dancer> restoreDancers(final DanceSchool school) {
final Map<String, Dancer> dancerMap = new LinkedHashMap<>();
for (final Person d : list(dancers, "dancers")) {
check(d != null, "Lege danser");
text(d.name(), "dansernaam");
check(
d.birthDate() != null && !d.birthDate().isAfter(LocalDate.now()),
"Ongeldige geboortedatum voor " + d.name());
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);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
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.SchedulingResult;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine {
private final double timeLimitSeconds;
public OrToolsSchedulingEngine() {
this(1800);
}
/** Allows callers to impose a shorter run budget, for example in automated tests. */
public OrToolsSchedulingEngine(final double timeLimitSeconds) {
this.timeLimitSeconds = timeLimitSeconds;
}
@Override
public SchedulingResult solve(final SchedulingInput input) {
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
Loader.loadNativeLibraries();
final CpModel model = new CpModel();
final OrderedAssignments vars = OrderedAssignments.create(model, input);
final LinearExpr attendance = SchedulingObjectives.attendance(model, input, vars);
final LinearExpr variety = SchedulingObjectives.variety(model, input, vars);
final CpSolver solver = new CpSolver();
solver.getParameters().setCatchSigintSignal(false);
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
SchedulingResult best = null;
for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) {
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
if (remaining <= 0) {
if (best != null) {
return new SchedulingResult(false, best.assignments());
}
requireSolution(CpSolverStatus.UNKNOWN);
}
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
model.minimize(objective);
final CpSolverStatus status = solver.solve(model);
if (status == CpSolverStatus.UNKNOWN && best != null) {
return new SchedulingResult(false, best.assignments());
}
requireSolution(status);
best = new SchedulingResult(status == CpSolverStatus.OPTIMAL, vars.read(input, solver));
if (status != CpSolverStatus.OPTIMAL) {
return best;
}
model.addEquality(objective, Math.round(solver.objectiveValue()));
}
return best;
}
private static void requireSolution(final CpSolverStatus status) {
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
switch (status) {
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.";
});
}
}
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,170 @@
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.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowConfiguration;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.SchoolSnapshot;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingTest {
private final SchoolManagementService service = new SchoolManagementService();
private final long school = service.createSchool("Studio").getId();
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.AFTERNOON)
.danceGroups(List.of())
.build())
.toList();
}
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(final Show show) {
final SchedulingInput input = service.beginScheduling(school, show.getId());
try {
service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input));
} finally {
service.endScheduling(show.getId());
}
}
public @Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
group(DanceGroupType.PRE_SCHOOLER);
final Show show = service.createShow(school, "Show", parts(6), null);
run(show);
IntSummaryStatistics loads = show.getDayParts().stream()
.mapToInt((final DayPart p) -> p.getDanceGroups().size())
.summaryStatistics();
assertEquals(1, loads.getSum());
assertEquals(0, loads.getMin());
assertEquals(1, loads.getMax());
for (final DanceGroupType type : DanceGroupType.values()) {
group(type);
}
run(show);
for (final DanceGroup group : service.groups(school)) {
assertEquals(
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);
final SchoolSnapshot snapshot = service.exportSchool(school);
assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore()));
}
public @Test void defaultsInitialConfigurationAndPreservationOnEdits() {
final Show show = service.createShow(school, "Show", List.of(), null);
assertTrue(show.getAppearances().isEmpty());
service.updateShow(school, show.getId(), "Show", parts(3), null);
assertEquals(2, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
final var counts = new EnumMap<>(show.getAppearances());
counts.put(DanceGroupType.PRE_SCHOOLER, 2);
service.updateShow(school, show.getId(), "Show", parts(4), counts);
assertEquals(counts, show.getAppearances());
assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null));
assertEquals(4, show.getDayParts().size());
service.updateShow(school, show.getId(), "Show", List.of(), null);
assertEquals(counts, show.getAppearances());
service.updateShow(school, show.getId(), "Show", parts(4), null);
assertEquals(counts, show.getAppearances());
}
public @Test void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
final List<DayPart> duplicate = List.of(parts(1).getFirst(), parts(1).getFirst());
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", duplicate, null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of()));
final Map<DanceGroupType, Integer> counts = ShowConfiguration.defaults(2);
counts.put(DanceGroupType.REGULAR, 3);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
counts.put(DanceGroupType.REGULAR, 0);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
}
public @Test void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
final DanceGroup group = group(DanceGroupType.REGULAR);
final Show show = service.createShow(school, "Show", parts(2), null);
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()));
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input);
service.updateGroup(school, group.getId(), new GroupDetails("Renamed", group.getType(), 0, 99, Set.of()));
assertDoesNotThrow(() -> service.finishScheduling(school, input, result));
group(DanceGroupType.PRE_SCHOOLER);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
assertEquals(
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
service.endScheduling(show.getId());
final SchedulingInput fresh = service.beginScheduling(school, show.getId());
assertThrows(
ManagementException.class,
() -> service.finishScheduling(
school, fresh, new SchedulingResult(false, List.of(List.of(), List.of()))));
assertEquals(
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
// Feasible results are accepted without requiring proof of optimality.
final SchedulingResult valid = new OrToolsSchedulingEngine().solve(fresh);
assertDoesNotThrow(
() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments())));
service.endScheduling(show.getId());
}
public @Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {
group(DanceGroupType.REGULAR);
final Show show = service.createShow(school, "Show", parts(2), null);
final SchedulingInput input = service.beginScheduling(school, show.getId());
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input);
service.updateShow(school, show.getId(), "Show", parts(3), null);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.deleteShow(school, show.getId());
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.endScheduling(show.getId());
}
public @Test void needsGroupsAndDayparts() {
final Show show = service.createShow(school, "Show", parts(1), null);
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
group(DanceGroupType.REGULAR);
final Show empty = service.createShow(school, "Empty", List.of(), null);
assertThrows(ManagementException.class, () -> service.beginScheduling(school, empty.getId()));
assertTrue(show.getAppearances().values().stream().allMatch((final Integer n) -> n == 1));
}
}

View File

@@ -1,79 +1,78 @@
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.restassured.response.ValidatableResponse;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import org.junit.jupiter.api.Test;
@QuarkusTest
class SchoolManagementResourceTest {
private static final String SCHOOLS = "/api/dance-schools";
private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
private static final Map<String, Object> GROUP = Map.of("category", "Teens", "type", "REGULAR",
"startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"});
private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "date", "2027-06-12");
private static final Map<String, Object> GROUP = Map.of(
"category", "Teens", "type", "REGULAR", "startAge", 12, "endAge", 18, "styles", new String[] {"HIPHOP"});
private static final Map<String, Object> SHOW =
Map.of("name", "Annual show", "dayParts", new Object[] {Map.of("date", "2027-06-12", "type", "AFTERNOON")});
@Test
void managesSchoolAndItsResources() {
long schoolId = create(SCHOOLS, Map.of("name", "Dance Studio"));
String school = SCHOOLS + "/" + schoolId;
public @Test void managesSchoolAndItsResources() {
final long schoolId = create(SCHOOLS, Map.of("name", "Dance Studio"));
final String school = SCHOOLS + "/" + schoolId;
try {
given().get(school).then().statusCode(200).body("name", equalTo("Dance Studio"));
given().get(SCHOOLS).then().statusCode(200).body("id", hasItem((int) schoolId));
put(school, Map.of("name", "Renamed studio")).statusCode(200).body("name", equalTo("Renamed studio"));
long dancerId = create(school + "/dancers", DANCER);
String dancer = school + "/dancers/" + dancerId;
final long dancerId = create(school + "/dancers", DANCER);
final String dancer = school + "/dancers/" + dancerId;
put(dancer, Map.of("name", "Sam updated", "birthDate", "2011-06-13"))
.statusCode(200).body("birthDate", equalTo("2011-06-13"));
.statusCode(200)
.body("birthDate", equalTo("2011-06-13"));
given().get(dancer).then().statusCode(200).body("name", equalTo("Sam updated"));
given().get(school + "/dancers").then().statusCode(200).body("id", contains((int) dancerId));
long groupId = create(school + "/dance-groups", GROUP);
String group = school + "/dance-groups/" + groupId;
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
put(group, Map.of("category", "Selection", "type", "SELECTION", "startAge", 10,
"endAge", 20, "styles", new String[]{"JAZZ", "MODERN"}))
.statusCode(200).body("category", equalTo("Selection"))
.body("dancerIds", contains((int) dancerId)).body("styles", containsInAnyOrder("JAZZ", "MODERN"));
given().get(group).then().statusCode(200).body("type", equalTo("SELECTION"));
given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId));
final String group = manageGroup(school, dancerId);
final long groupId = Long.parseLong(group.substring(group.lastIndexOf('/') + 1));
final String show = manageShow(school);
final long showId = Long.parseLong(show.substring(show.lastIndexOf('/') + 1));
given().get(school)
.then()
.statusCode(200)
.body("dancerIds", contains((int) dancerId))
.body("danceGroupIds", contains((int) groupId))
.body("showIds", contains((int) showId));
long showId = create(school + "/shows", SHOW);
String show = school + "/shows/" + showId;
given().get(show).then().statusCode(200)
.body("dayParts.type", contains("MORNING", "AFTERNOON", "EVENING"))
.body("dayParts.danceGroupIds", everyItem(empty()));
put(show, Map.of("name", "Summer show", "date", "2027-07-01"))
.statusCode(200).body("date", equalTo("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}) {
for (final String path : new String[] {dancer, group, show}) {
given().delete(path).then().statusCode(204);
given().get(path).then().statusCode(404);
given().delete(path).then().statusCode(404);
}
given().get(school).then().body("dancerIds", empty()).body("danceGroupIds", empty()).body("showIds", empty());
given().get(school)
.then()
.body("dancerIds", empty())
.body("danceGroupIds", empty())
.body("showIds", empty());
} finally {
given().delete(school).then().statusCode(204);
}
given().get(school).then().statusCode(404);
}
@Test
void dancerCanBelongToMultipleGroupsAndDeletingDancerRemovesMemberships() {
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Membership school"));
public @Test void dancerCanBelongToMultipleGroupsAndDeletingDancerRemovesMemberships() {
final String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Membership school"));
try {
long dancerId = create(school + "/dancers", DANCER);
String first = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
String second = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
for (String group : new String[]{first, second}) {
final long dancerId = create(school + "/dancers", DANCER);
final String first = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
final String second = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
for (final String group : new String[] {first, second}) {
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
given().get(group).then().body("dancerIds", contains((int) dancerId));
@@ -91,23 +90,24 @@ class SchoolManagementResourceTest {
}
}
@Test
void isolatesSchoolsAndCascadesSchoolDeletion() {
String first = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "First"));
String second = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Second"));
public @Test void isolatesSchoolsAndCascadesSchoolDeletion() {
final String first = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "First"));
final String second = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Second"));
try {
long dancer = create(first + "/dancers", DANCER);
long group = create(first + "/dance-groups", GROUP);
long show = create(first + "/shows", SHOW);
long otherGroup = create(second + "/dance-groups", GROUP);
for (String path : new String[]{"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) {
final long dancer = create(first + "/dancers", DANCER);
final long group = create(first + "/dance-groups", GROUP);
final long show = create(first + "/shows", SHOW);
final long otherGroup = create(second + "/dance-groups", GROUP);
for (final String path : new String[] {"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) {
given().get(second + path).then().statusCode(404);
given().delete(second + path).then().statusCode(404);
}
put(second + "/dancers/" + dancer, DANCER).statusCode(404);
put(second + "/dance-groups/" + group, GROUP).statusCode(404);
put(second + "/shows/" + show, SHOW).statusCode(404);
given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer).then().statusCode(404);
given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer)
.then()
.statusCode(404);
given().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty());
given().delete(first).then().statusCode(204);
given().get(first + "/dancers").then().statusCode(404);
@@ -120,45 +120,175 @@ class SchoolManagementResourceTest {
}
}
@Test
void rejectsInvalidInputWithoutChangingStoredData() {
public @Test void rejectsInvalidInputWithoutChangingStoredData() {
given().contentType("application/json").body("{}").post(SCHOOLS).then().statusCode(400);
given().contentType("application/json").body("null").post(SCHOOLS).then().statusCode(400);
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Validation school"));
given().contentType("application/json")
.body("null")
.post(SCHOOLS)
.then()
.statusCode(400);
final String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Validation school"));
try {
put(school, Map.of("name", " ")).statusCode(400);
given().get(school).then().body("name", equalTo("Validation school"));
for (Object body : new Object[]{Map.of("name", "Sam"), Map.of("name", "Sam", "birthDate", "2999-01-01"),
Map.of("name", "Sam", "birthDate", "not-a-date")}) {
given().contentType("application/json").body(body).post(school + "/dancers").then().statusCode(400);
}
for (String body : new String[]{"{}", "{\"category\":\"Teens\",\"type\":\"INVALID\"}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"}) {
given().contentType("application/json").body(body).post(school + "/dance-groups").then().statusCode(400);
}
long group = create(school + "/dance-groups", GROUP);
put(school + "/dance-groups/" + group, Map.of("category", "Invalid", "type", "REGULAR",
"startAge", 20, "endAge", 10, "styles", new String[]{})).statusCode(400);
rejectsInvalidDancersAndGroups(school);
final long group = create(school + "/dance-groups", GROUP);
put(
school + "/dance-groups/" + group,
Map.of(
"category",
"Invalid",
"type",
"REGULAR",
"startAge",
20,
"endAge",
10,
"styles",
new String[] {}))
.statusCode(400);
given().get(school + "/dance-groups/" + group).then().body("category", equalTo("Teens"));
given().contentType("application/json").body(Map.of("name", "Show"))
.post(school + "/shows").then().statusCode(400);
given().contentType("application/json")
.body(Map.of("name", "Show"))
.post(school + "/shows")
.then()
.statusCode(400);
given().get(school + "/dancers").then().body("$", empty());
given().get(school + "/shows").then().body("$", empty());
given().contentType("application/json").body(DANCER)
.post(SCHOOLS + "/9223372036854775807/dancers").then().statusCode(404);
given().contentType("application/json")
.body(DANCER)
.post(SCHOOLS + "/9223372036854775807/dancers")
.then()
.statusCode(404);
} finally {
given().delete(school).then().statusCode(204);
}
}
private static long create(String path, Object body) {
return given().contentType("application/json").body(body).post(path).then()
.statusCode(201).header("Location", matchesPattern(".*/[0-9]+$"))
.extract().jsonPath().getLong("id");
public @Test void schedulesAndValidatesTheNewShowConfiguration() {
final String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Scheduling school"));
try {
final String show = school + "/shows/" + create(school + "/shows", SHOW);
given().post(show + "/schedule").then().statusCode(400);
final long group = create(school + "/dance-groups", GROUP);
final long dancer = create(school + "/dancers", DANCER);
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
.then()
.statusCode(204);
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
final String before = given().get(show).asString();
for (final String count : new String[] {"0", "2", "1.5", "7"}) {
final String body =
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"preferredDayParts\":"
+ count + "}";
put(show, body).statusCode(400);
given().get(show).then().body(equalTo(before));
}
final String exported = given().get(school + "/export")
.then()
.statusCode(200)
.extract()
.asString();
given().contentType("application/json")
.body(exported)
.post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
.then()
.statusCode(200);
given().get(school + "/export")
.then()
.body(
"$",
equalTo(io.restassured.path.json.JsonPath.from(exported)
.getMap("$")));
} finally {
given().delete(school);
}
}
private static ValidatableResponse put(String path, Object body) {
private static String manageGroup(final String school, final long dancerId) {
final long groupId = create(school + "/dance-groups", GROUP);
final String group = school + "/dance-groups/" + groupId;
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
put(
group,
Map.of(
"category",
"Selection",
"type",
"SELECTION",
"startAge",
10,
"endAge",
20,
"styles",
new String[] {"JAZZ", "MODERN"}))
.statusCode(200)
.body("category", equalTo("Selection"))
.body("dancerIds", contains((int) dancerId))
.body("styles", containsInAnyOrder("JAZZ", "MODERN"));
given().get(group).then().statusCode(200).body("type", equalTo("SELECTION"));
given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId));
return group;
}
private static String manageShow(final String school) {
final long showId = create(school + "/shows", SHOW);
final String show = school + "/shows/" + showId;
given().get(show)
.then()
.statusCode(200)
.body("dayParts.type", contains("AFTERNOON"))
.body("dayParts.danceGroupIds", everyItem(empty()));
put(show, Map.of("name", "Summer show", "dayParts", new Object[] {
Map.of("date", "2027-07-01", "type", "EVENING")
}))
.statusCode(200)
.body("dayParts.date", contains("2027-07-01"));
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
return show;
}
private static void rejectsInvalidDancersAndGroups(final String school) {
for (final Object body : new Object[] {
Map.of("name", "Sam"),
Map.of("name", "Sam", "birthDate", "2999-01-01"),
Map.of("name", "Sam", "birthDate", "not-a-date")
}) {
given().contentType("application/json")
.body(body)
.post(school + "/dancers")
.then()
.statusCode(400);
}
for (final String body : new String[] {
"{}",
"{\"category\":\"Teens\",\"type\":\"INVALID\"}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"
}) {
given().contentType("application/json")
.body(body)
.post(school + "/dance-groups")
.then()
.statusCode(400);
}
}
private static long create(final String path, final Object body) {
return given().contentType("application/json")
.body(body)
.post(path)
.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();
}
}

View File

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

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

View File

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

View File

@@ -0,0 +1,14 @@
node_modules/
dist/
.angular/
coverage/
test-results/
unit-results/
playwright-report/
package-lock.json
out-tsc/
bazel-out/
tmp/
typings/
__screenshots__/
.history/

View File

@@ -1,4 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
"recommendations": ["angular.ng-template", "dbaeumer.vscode-eslint"]
}

View File

@@ -1,9 +1,10 @@
FROM node:24.15.0-bookworm AS build
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN npm ci
COPY tools/install-hooks.mjs tools/install-hooks.mjs
RUN CI=true npm ci
COPY . .
RUN npm test && npm run build
RUN npm run lint && npm test && npm run build
FROM nginx:1.28-alpine AS runtime
ENV BACKEND_UPSTREAM=backend:8080

View File

@@ -84,3 +84,9 @@ PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome npm run e2e
Browser test screenshots and failure traces are written to the ignored
`test-results` directory.
## Lint and commit formatting
See the [root README](../README.md#frontend-lint-and-formatting) for `npm run lint`,
`npm run lint:fix`, editor support, CI enforcement and the measured Prettier-only
commit hook, including installation and local opt-out.

View File

@@ -1,4 +1,5 @@
import { test, expect, Page } from '@playwright/test';
import type { School } from '../src/app/core/api-models';
const backend = process.env['PLAYWRIGHT_BASE_URL'] || 'http://127.0.0.1:8080';
let schoolToDelete: number | undefined;
@@ -30,9 +31,9 @@ async function confirmDelete(page: Page) {
test('manages a school, shared memberships and shows through the real REST API', async ({
page,
request,
}) => {
const schoolName = `E2E Studio ${Date.now()}`;
let schoolId: number | undefined;
const browserErrors: string[] = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
await page.goto('/');
@@ -43,8 +44,9 @@ test('manages a school, shared memberships and shows through the real REST API',
response.url().endsWith('/api/dance-schools') && response.request().method() === 'POST',
);
await save(page);
schoolId = (await (await created).json()).id;
schoolToDelete = schoolId;
const school: unknown = await (await created).json();
expect(school).toEqual(expect.objectContaining({ id: expect.any(Number) }));
schoolToDelete = (school as School).id;
await page.getByRole('link', { name: new RegExp(schoolName) }).click();
await expect(page.getByRole('heading', { name: schoolName, exact: true })).toBeVisible();
@@ -106,18 +108,62 @@ test('manages a school, shared memberships and shows through the real REST API',
).toBeVisible();
await page.screenshot({ path: 'test-results/groups-desktop.png', fullPage: true });
// Two independent performances give Sam time to change between the shared groups.
for (const name of ['Modern adults', 'Streetdance adults']) {
const dancerResponse = await request.post(
`${backend}/api/dance-schools/${schoolToDelete}/dancers`,
{
data: { name, birthDate: '1990-01-01' },
},
);
const dancer = (await dancerResponse.json()) as { id: number };
const groupResponse = await request.post(
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups`,
{
data: { category: name, type: 'REGULAR', startAge: 0, endAge: 99, styles: ['MODERN'] },
},
);
const group = (await groupResponse.json()) as { id: number };
await request.put(
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups/${group.id}/dancers/${dancer.id}`,
);
}
await tab(page, 'Shows');
await page.getByRole('button', { name: 'Show toevoegen' }).click();
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click();
await page.getByLabel('Datum', { exact: true }).fill('2027-06-12');
await save(page);
await expect(page.getByText('Ochtend', { exact: true })).toBeVisible();
await expect(page.getByText('Middag', { exact: true })).toBeVisible();
await expect(page.getByText('Avond', { exact: true })).toBeVisible();
await expect(page.getByText('2027-06-12 · AFTERNOON:', { exact: false })).toBeVisible();
await page.getByRole('button', { name: 'Zomershow bewerken' }).click();
await page.getByLabel('Datum', { exact: true }).fill('2027-07-01');
await save(page);
await expect(page.locator('.date-tile')).toContainText('jul 2027');
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: /Schedule groups/ }).click();
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
await expect(
page.getByRole('status').filter({ hasText: 'Optimaal conceptrooster.' }),
).toBeVisible();
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
const scheduleDialog = page.getByRole('dialog');
await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click();
await expect(scheduleDialog.getByText('Definitief', { exact: false }).first()).toBeVisible();
await scheduleDialog.getByRole('button', { name: 'Optreden verwijderen' }).first().click();
await scheduleDialog.getByRole('button', { name: 'Concept opslaan' }).click();
await expect(scheduleDialog.locator('.alert.error').first()).toBeVisible();
await expect(scheduleDialog.getByRole('button', { name: 'Definitief maken' })).toBeDisabled();
await scheduleDialog.getByRole('button', { name: 'Gereed', exact: true }).click();
await page.getByRole('button', { name: 'Zomershow bewerken' }).click();
await page.getByRole('button', { name: 'Dagdeel toevoegen', exact: true }).click();
await page.getByLabel('Datum', { exact: true }).nth(1).fill('2027-07-02');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toContain('Zomershow');
await dialog.dismiss();
});
await page.getByRole('button', { name: 'Opslaan', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Annuleren', exact: true }).click();
await tab(page, 'Overzicht');
await page.setViewportSize({ width: 390, height: 844 });
@@ -134,7 +180,7 @@ test('manages a school, shared memberships and shows through the real REST API',
await tab(page, 'Dansers');
await page.getByRole('button', { name: 'Sam Testdanser verwijderen' }).click();
await confirmDelete(page);
await expect(page.getByText('De dansvloer is nog leeg')).toBeVisible();
await expect(page.getByRole('cell', { name: 'Sam Testdanser', exact: true })).toHaveCount(0);
await tab(page, 'Dansgroepen');
await expect(page.getByText(/0 dansers/)).toHaveCount(2);
await page.getByRole('button', { name: 'Hiphop Selectie verwijderen' }).click();
@@ -152,6 +198,66 @@ test('manages a school, shared memberships and shows through the real REST API',
expect(browserErrors).toEqual([]);
});
test('links a coach and finalizes performances spread across multiple dates', async ({
page,
request,
}) => {
const schoolResponse = await request.post(`${backend}/api/dance-schools`, {
data: { name: `Coach show ${Date.now()}` },
});
const school = (await schoolResponse.json()) as School;
schoolToDelete = school.id;
const base = `${backend}/api/dance-schools/${school.id}`;
const dancerResponse = await request.post(`${base}/dancers`, {
data: { name: 'Dancing coach', birthDate: '1990-01-01' },
});
const dancer = (await dancerResponse.json()) as { id: number };
const groupIds: number[] = [];
for (const name of ['Preschool', 'Adults']) {
const response = await request.post(`${base}/dance-groups`, {
data: {
category: name,
type: name === 'Preschool' ? 'PRE_SCHOOLER' : 'REGULAR',
startAge: 0,
endAge: 99,
styles: [],
},
});
const group = (await response.json()) as { id: number };
groupIds.push(group.id);
await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`);
}
const showResponse = await request.post(`${base}/shows`, {
data: {
name: 'Coach across dates',
preferredDayParts: 1,
dayParts: [
{ date: '2027-06-01', type: 'AFTERNOON' },
{ date: '2027-06-02', type: 'AFTERNOON' },
],
},
});
const show = (await showResponse.json()) as { id: number };
await request.put(`${base}/shows/${show.id}/assignments`, {
data: { assignments: groupIds.map((id) => [id]) },
});
await page.goto(`/schools/${school.id}/shows`);
await expect(page.getByText(/exceeds preferredDayParts=1/)).toBeVisible();
await tab(page, 'Dansers');
await page.getByText('Coaches koppelen aan dansers', { exact: true }).click();
await page.getByLabel('Danser als coach registreren').selectOption({ label: 'Dancing coach' });
await expect(page.getByRole('combobox', { name: 'Dancing coach', exact: true })).toBeVisible();
await tab(page, 'Shows');
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }).click();
await expect(
page.getByRole('dialog').getByRole('button', { name: 'Definitief maken' }),
).toBeDisabled();
const finalResponse = await request.get(`${base}/shows/${show.id}`);
expect(((await finalResponse.json()) as { finalized: boolean }).finalized).toBe(true);
});
test('shows a useful connection error and recovers on retry', async ({ page }) => {
await page.route('**/api/dance-schools', (route) => route.abort('connectionfailed'));
await page.goto('/schools');

View File

@@ -0,0 +1,47 @@
import js from '@eslint/js';
import { defineConfig, globalIgnores } from 'eslint/config';
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
export default defineConfig(
globalIgnores([
'**/node_modules/**',
'**/dist/**',
'**/out-tsc/**',
'**/bazel-out/**',
'**/tmp/**',
'**/typings/**',
'**/__screenshots__/**',
'**/.history/**',
'**/.angular/**',
'**/coverage/**',
'**/test-results/**',
'**/unit-results/**',
'**/playwright-report/**',
'package-lock.json',
]),
{
files: ['**/*.{js,mjs,cjs}'],
extends: [js.configs.recommended],
languageOptions: { globals: globals.node },
},
{
files: ['**/*.ts'],
extends: [
js.configs.recommended,
tseslint.configs.recommendedTypeChecked,
angular.configs.tsRecommended,
],
languageOptions: {
parserOptions: {
project: './tsconfig.eslint.json',
tsconfigRootDir: import.meta.dirname,
},
},
processor: angular.processInlineTemplates,
},
{ files: ['**/*.html'], extends: [angular.configs.templateRecommended] },
prettier,
);

View File

@@ -0,0 +1,21 @@
import path from 'node:path';
export default {
'*.{ts,js,mjs,cjs,html,css,json,prettierrc}': (files) => {
const maintained = files.filter((file) => {
const relative = path
.relative(import.meta.dirname, file)
.split(path.sep)
.join('/');
return (
!relative.startsWith('../') &&
!/^(node_modules|dist|out-tsc|bazel-out|tmp|typings|__screenshots__|\.history|\.angular|coverage|test-results|unit-results|playwright-report)\//.test(
relative,
) &&
relative !== 'package-lock.json'
);
});
if (!maintained.length) return [];
return [`prettier --write ${maintained.map((file) => JSON.stringify(file)).join(' ')}`];
},
};

View File

@@ -5,6 +5,8 @@ server {
index index.html;
location /api/ {
proxy_read_timeout 1860s;
proxy_send_timeout 1860s;
proxy_pass http://${BACKEND_UPSTREAM};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

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