Compare commits
28 Commits
09cb26107e
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b0d6592761 | |||
| 60496de8bb | |||
| 525e9f9335 | |||
| c9db528756 | |||
| b1544357bf | |||
| 3a5203eb47 | |||
| a7f7730ea0 | |||
| c965f9b41d | |||
| 8fc430537b | |||
| fc307de64e | |||
| f281200f47 | |||
| ff6a0ae0a9 | |||
| aae5a97200 | |||
| f910f1e535 | |||
| 9a69981025 | |||
| 35123e51f6 | |||
| de42bbbbf0 | |||
| 5ec2dd83f3 | |||
| 7e4fd2dab7 | |||
| 7e8b3a2cc7 | |||
| 327a365550 | |||
| 4cfd1d71de | |||
| 38247d0705 | |||
| 1b4c7f7f2f | |||
| f64aa9123d | |||
| 9171a2d932 | |||
| aa29283a94 | |||
| 8f3026e6cd |
@@ -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
|
||||
|
||||
68
.gitea/workflows/deploy.yaml
Normal file
68
.gitea/workflows/deploy.yaml
Normal 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
|
||||
42
.gitea/workflows/rollback.yaml
Normal file
42
.gitea/workflows/rollback.yaml
Normal 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
2
.husky/pre-commit
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["angular.ng-template", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"eslint.workingDirectories": ["./agiledancers-fe"]
|
||||
}
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -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
255
CODING_STANDARDS.md
Normal 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.
|
||||
@@ -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
166
README.md
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package nl.agilecc.agiledancers.model.specifications;
|
||||
|
||||
public enum DayPartType {
|
||||
MORNING, AFTERNOON, EVENING;
|
||||
MORNING,
|
||||
AFTERNOON,
|
||||
EVENING;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record CoachRequest(@NotNull Long dancerId) {}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
public record SchedulingView(boolean optimal, String message) {}
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
public enum ManagementExceptionKind {
|
||||
NOT_FOUND,
|
||||
INVALID_INPUT,
|
||||
CONFLICT
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package nl.agilecc.agiledancers.service.model;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record Person(String id, String name, LocalDate birthDate) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
14
agiledancers-fe/.prettierignore
Normal file
14
agiledancers-fe/.prettierignore
Normal 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/
|
||||
2
agiledancers-fe/.vscode/extensions.json
vendored
2
agiledancers-fe/.vscode/extensions.json
vendored
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
47
agiledancers-fe/eslint.config.mjs
Normal file
47
agiledancers-fe/eslint.config.mjs
Normal 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,
|
||||
);
|
||||
21
agiledancers-fe/lint-staged.config.mjs
Normal file
21
agiledancers-fe/lint-staged.config.mjs
Normal 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(' ')}`];
|
||||
},
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user