46 Commits

Author SHA1 Message Date
b0d6592761 Merge pull request 'S6: manual versioned Synology deployment' (#12) from feature/S6_deployment into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m6s
Reviewed-on: #12
2026-11-03 14:51:13 +01:00
60496de8bb Record successful S6 feature build validation [skip ci] 2026-11-03 14:48:59 +01:00
525e9f9335 Implement manual versioned Synology deployments for S6
All checks were successful
Build, test and publish / verify (push) Successful in 6m34s
2026-11-03 14:16:52 +01:00
c9db528756 refinement of spec S6
All checks were successful
Build, test and publish / verify (push) Successful in 5m49s
2026-11-03 13:30:47 +01:00
b1544357bf Merge pull request 'ortools-documentation' (#11) from ortools-documentation into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m9s
Reviewed-on: #11
2026-11-03 13:09:09 +01:00
3a5203eb47 some renaming of variables
All checks were successful
Build, test and publish / verify (push) Successful in 5m55s
2026-11-03 12:58:15 +01:00
a7f7730ea0 S18 Added OR Tools rules for scheduling show
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s
2026-11-03 11:03:47 +01:00
c965f9b41d Merge pull request 'refinement2' (#10) from refinement2 into main
All checks were successful
Build, test and publish / verify (push) Successful in 4m45s
Reviewed-on: #10
2026-11-01 21:29:04 +01:00
8fc430537b refinement S16 - database storage
All checks were successful
Build, test and publish / verify (push) Successful in 5m26s
2026-11-01 21:28:42 +01:00
fc307de64e backlog management
All checks were successful
Build, test and publish / verify (push) Successful in 5m38s
2026-11-01 20:51:27 +01:00
f281200f47 Merge pull request 'feature/S7-backend-coding-standards' (#9) from feature/S7-backend-coding-standards into main
Some checks failed
Build, test and publish / verify (push) Failing after 5m15s
Reviewed-on: #9
2026-11-01 20:40:45 +01:00
ff6a0ae0a9 review finished
All checks were successful
Build, test and publish / verify (push) Successful in 5m40s
2026-11-01 17:08:46 +01:00
aae5a97200 S7 review findings
Some checks failed
Build, test and publish / verify (push) Failing after 36s
2026-10-30 23:39:14 +01:00
f910f1e535 updated coding standards
All checks were successful
Build, test and publish / verify (push) Successful in 4m43s
2026-10-30 19:58:03 +01:00
9a69981025 Merge pull request 'refinement of S7 - coding standards for the backend' (#8) from coding-standards-be into main
Some checks failed
Build, test and publish / verify (push) Failing after 2m30s
Reviewed-on: #8
2026-10-30 17:18:29 +01:00
35123e51f6 refinement of S7 - coding standards for the backend
All checks were successful
Build, test and publish / verify (push) Successful in 1m45s
2026-10-30 17:18:13 +01:00
de42bbbbf0 Merge pull request 'S8-palentir' (#7) from S8-palentir into main
All checks were successful
Build, test and publish / verify (push) Successful in 1m57s
Reviewed-on: #7
2026-10-30 16:58:49 +01:00
5ec2dd83f3 review complete
All checks were successful
Build, test and publish / verify (push) Successful in 1m31s
2026-10-30 16:58:17 +01:00
7e4fd2dab7 spec-file updates
All checks were successful
Build, test and publish / verify (push) Successful in 1m41s
2026-10-30 16:54:05 +01:00
7e8b3a2cc7 S8 apply Palantir formatting to maintained Java sources
All checks were successful
Build, test and publish / verify (push) Successful in 1m39s
2026-10-30 15:50:51 +01:00
327a365550 S8 integrate pinned Palantir formatting through OpenRewrite 2026-10-30 15:50:51 +01:00
4cfd1d71de Merge pull request 'feature/S9_ortools-phase1' (#6) from feature/S9_ortools-phase1 into main
All checks were successful
Build, test and publish / verify (push) Successful in 2m44s
Reviewed-on: #6
2026-10-30 15:20:57 +01:00
38247d0705 ortools review on some design issues and fixed flaky test
All checks were successful
Build, test and publish / verify (push) Successful in 1m34s
2026-10-30 15:14:27 +01:00
1b4c7f7f2f S9 OR Tools basic scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 1m48s
2026-10-30 15:00:45 +01:00
f64aa9123d Merge pull request 'refinement of spec S9 and updated S2 as implemented' (#5) from refinement into main
All checks were successful
Build, test and publish / verify (push) Successful in 1m44s
Reviewed-on: #5
2026-10-30 11:50:36 +01:00
9171a2d932 refinement of spec S9 and updated S2 as implemented
All checks were successful
Build, test and publish / verify (push) Successful in 1m38s
2026-10-30 11:46:06 +01:00
aa29283a94 Merge pull request 'S2 EsLint implementation' (#4) from feature/S2_eslint into main
All checks were successful
Build, test and publish / verify (push) Successful in 1m50s
Reviewed-on: #4
2026-10-29 22:07:38 +01:00
8f3026e6cd S2 EsLint implementation
All checks were successful
Build, test and publish / verify (push) Successful in 1m40s
2026-10-29 22:05:11 +01:00
09cb26107e Merge pull request 'config to start docker and runner at startup' (#3) from feature/s4-gitea-ci into main
All checks were successful
Build, test and publish / verify (push) Successful in 1m36s
Reviewed-on: #3
2026-10-29 21:17:41 +01:00
8ae2c16fee config to start docker and runner at startup
All checks were successful
Build, test and publish / verify (push) Successful in 1m15s
2026-10-29 21:16:31 +01:00
66d9844836 Merge pull request 'specs' (#2) from feature/s4-gitea-ci into main
All checks were successful
Build, test and publish / verify (push) Successful in 2m6s
Reviewed-on: #2
2026-10-29 21:10:39 +01:00
b0c2f28e13 specs
All checks were successful
Build, test and publish / verify (push) Successful in 1m21s
2026-10-29 19:35:10 +01:00
2356a31aa6 Merge pull request 'Bring the application and Gitea build pipeline to main' (#1) from feature/s4-gitea-ci into main
All checks were successful
Build, test and publish / verify (push) Successful in 2m9s
Reviewed-on: #1
2026-10-29 15:45:33 +01:00
aea792307c Probe the frontend on its IPv4 listener
All checks were successful
Build, test and publish / verify (push) Successful in 1m16s
2026-10-29 09:51:47 +01:00
19fffe2cd3 Report missing Docker clearly and preserve CI attempt setup
Some checks failed
Build, test and publish / verify (push) Failing after 3m9s
2026-10-29 09:23:24 +01:00
069b62ae92 Run CI orchestration and setup as Java 25 source programs
Some checks failed
Build, test and publish / verify (push) Failing after 11s
2026-10-29 09:07:41 +01:00
834584dae0 Give Gitea workflow reruns unique diagnostic names
All checks were successful
Build, test and publish / verify (push) Successful in 1m27s
2026-10-28 17:46:00 +01:00
74a5aa1237 Add Gitea build, container tests and registry publication
All checks were successful
Build, test and publish / verify (push) Successful in 1m41s
2026-10-28 17:07:23 +01:00
e3e362c2ac import export functionality added 2026-10-28 08:07:42 +01:00
bd497aefd5 Spec driven development setup 2026-10-28 07:26:47 +01:00
02337fafaa frontend-toegevoegd 2026-10-27 15:34:56 +01:00
777d9f199d Add school management REST API and architecture checks 2026-10-27 15:02:58 +01:00
2bb3beef60 turned into a quarkus application 2026-10-26 22:53:35 +01:00
c037abbde9 upgraded to Java 25 2026-10-26 22:45:14 +01:00
Wanja Krah
e617bc09c1 WIP: verder uitbreiden model 2025-05-22 18:42:39 +02:00
Wanja Krah
aa6f50e3c8 set basis and model 2025-05-15 18:38:36 +02:00
229 changed files with 27774 additions and 1 deletions

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
**/target
.git
.idea
*.iml
**/node_modules
**/.angular
**/dist
**/test-results
**/playwright-report
ci/artifacts
# Allow only the verified runtime output for the CI prebuilt target.
!agiledancers-be/target
agiledancers-be/target/*
!agiledancers-be/target/quarkus-app
**/unit-results
# CI sources are build inputs; runner credentials and local state are not.
ci/runner/.env
ci/runner/.runner
ci/runner/data
ci/runner/local

View File

@@ -0,0 +1,44 @@
name: Build, test and publish
on:
push:
branches: ['**']
jobs:
verify:
runs-on: agiledancers-mini-pc
env:
CI: 'true'
TARGET_PLATFORM: ${{ vars.TARGET_PLATFORM }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
CI_REPOSITORY: ${{ gitea.repository }}
CI_COMMIT: ${{ gitea.sha }}
CI_REF: ${{ gitea.ref }}
CI_RUN_ID: ${{ gitea.run_id }}
steps:
- name: Check out pushed commit
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
persist-credentials: false
- name: Identify workflow attempt
# Gitea 1.25 does not populate gitea.run_attempt. Use a unique value for
# container names and diagnostic artifacts, including reruns of one SHA.
run: java ci/Ci.java attempt
- name: Build and test both images
run: java ci/Ci.java verify
- name: Publish verified main images
if: success() && gitea.ref == 'refs/heads/main'
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: java ci/Ci.java publish
- name: Collect diagnostics and clean up test containers
if: always()
run: java ci/Ci.java cleanup
- name: Retain diagnostics for fourteen days
if: always()
uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5
with:
name: diagnostics-${{ gitea.run_id }}-${{ env.CI_RUN_ATTEMPT }}
path: ci/artifacts/
if-no-files-found: error
retention-days: 14

View File

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

View File

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

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
*.iml
.idea
target
# Local CI diagnostics and runner credentials/state
/ci/artifacts/
/ci/runner/.env
/ci/runner/.runner
/ci/runner/data/
/ci/runner/local/
**/unit-results/
**/__pycache__/

2
.husky/pre-commit Normal file
View File

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

2
.mvn/maven.config Normal file
View File

@@ -0,0 +1,2 @@
-s.mvn/settings-local.xml

265
.mvn/settings-local.xml Normal file
View File

@@ -0,0 +1,265 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!--
| This is the configuration file for Maven. It can be specified at two levels:
|
| 1. User Level. This settings.xml file provides configuration for a single user,
| and is normally provided in ${user.home}/.m2/settings.xml.
|
| NOTE: This location can be overridden with the CLI option:
|
| -s /path/to/user/settings.xml
|
| 2. Global Level. This settings.xml file provides configuration for all Maven
| users on a machine (assuming they're all using the same Maven
| installation). It's normally provided in
| ${maven.conf}/settings.xml.
|
| NOTE: This location can be overridden with the CLI option:
|
| -gs /path/to/global/settings.xml
|
| The sections in this sample file are intended to give you a running start at
| getting the most out of your Maven installation. Where appropriate, the default
| values (values used when the setting is not specified) are provided.
|
|-->
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<!-- localRepository
| The path to the local repository maven will use to store artifacts.
|
| Default: ${user.home}/.m2/repository
<localRepository>/path/to/local/repo</localRepository>
-->
<!-- interactiveMode
| This will determine whether maven prompts you when it needs input. If set to false,
| maven will use a sensible default value, perhaps based on some other setting, for
| the parameter in question.
|
| Default: true
<interactiveMode>true</interactiveMode>
-->
<!-- offline
| Determines whether maven should attempt to connect to the network when executing a build.
| This will have an effect on artifact downloads, artifact deployment, and others.
|
| Default: false
<offline>false</offline>
-->
<!-- pluginGroups
| This is a list of additional group identifiers that will be searched when resolving plugins by their prefix, i.e.
| when invoking a command line like "mvn prefix:goal". Maven will automatically add the group identifiers
| "org.apache.maven.plugins" and "org.codehaus.mojo" if these are not already contained in the list.
|-->
<pluginGroups>
<!-- pluginGroup
| Specifies a further group identifier to use for plugin lookup.
<pluginGroup>com.your.plugins</pluginGroup>
-->
</pluginGroups>
<!-- TODO Since when can proxies be selected as depicted? -->
<!-- proxies
| This is a list of proxies which can be used on this machine to connect to the network.
| Unless otherwise specified (by system property or command-line switch), the first proxy
| specification in this list marked as active will be used.
|-->
<proxies>
<!-- proxy
| Specification for one proxy, to be used in connecting to the network.
|
<proxy>
<id>optional</id>
<active>true</active>
<protocol>http</protocol>
<username>proxyuser</username>
<password>proxypass</password>
<host>proxy.host.net</host>
<port>80</port>
<nonProxyHosts>local.net|some.host.com</nonProxyHosts>
</proxy>
-->
</proxies>
<!-- servers
| This is a list of authentication profiles, keyed by the server-id used within the system.
| Authentication profiles can be used whenever maven must make a connection to a remote server.
|-->
<servers>
<!-- server
| Specifies the authentication information to use when connecting to a particular server, identified by
| a unique name within the system (referred to by the 'id' attribute below).
|
| NOTE: You should either specify username/password OR privateKey/passphrase, since these pairings are
| used together.
|
<server>
<id>deploymentRepo</id>
<username>repouser</username>
<password>repopwd</password>
</server>
-->
<!-- Another sample, using keys to authenticate.
<server>
<id>siteServer</id>
<privateKey>/path/to/private/key</privateKey>
<passphrase>optional; leave empty if not used.</passphrase>
</server>
-->
</servers>
<!-- mirrors
| This is a list of mirrors to be used in downloading artifacts from remote repositories.
|
| It works like this: a POM may declare a repository to use in resolving certain artifacts.
| However, this repository may have problems with heavy traffic at times, so people have mirrored
| it to several places.
|
| That repository definition will have a unique id, so we can create a mirror reference for that
| repository, to be used as an alternate download site. The mirror site will be the preferred
| server for that repository.
|-->
<mirrors>
<!-- mirror
| Specifies a repository mirror site to use instead of a given repository. The repository that
| this mirror serves has an ID that matches the mirrorOf element of this mirror. IDs are used
| for inheritance and direct lookup purposes, and must be unique across the set of mirrors.
|
<mirror>
<id>mirrorId</id>
<mirrorOf>repositoryId</mirrorOf>
<name>Human Readable Name for this Mirror.</name>
<url>http://my.repository.com/repo/path</url>
</mirror>
-->
<mirror>
<id>maven-default-http-blocker</id>
<mirrorOf>external:http:*</mirrorOf>
<name>Pseudo repository to mirror external repositories initially using HTTP.</name>
<url>http://0.0.0.0/</url>
<blocked>true</blocked>
</mirror>
</mirrors>
<!-- profiles
| This is a list of profiles which can be activated in a variety of ways, and which can modify
| the build process. Profiles provided in the settings.xml are intended to provide local machine-
| specific paths and repository locations which allow the build to work in the local environment.
|
| For example, if you have an integration testing plugin - like cactus - that needs to know where
| your Tomcat instance is installed, you can provide a variable here such that the variable is
| dereferenced during the build process to configure the cactus plugin.
|
| As noted above, profiles can be activated in a variety of ways. One way - the activeProfiles
| section of this document (settings.xml) - will be discussed later. Another way essentially
| relies on the detection of a property, either matching a particular value for the property,
| or merely testing its existence. Profiles can also be activated by JDK version prefix, where a
| value of '1.4' might activate a profile when the build is executed on a JDK version of '1.4.2_07'.
| Finally, the list of active profiles can be specified directly from the command line.
|
| NOTE: For profiles defined in the settings.xml, you are restricted to specifying only artifact
| repositories, plugin repositories, and free-form properties to be used as configuration
| variables for plugins in the POM.
|
|-->
<profiles>
<!-- profile
| Specifies a set of introductions to the build process, to be activated using one or more of the
| mechanisms described above. For inheritance purposes, and to activate profiles via <activatedProfiles/>
| or the command line, profiles have to have an ID that is unique.
|
| An encouraged best practice for profile identification is to use a consistent naming convention
| for profiles, such as 'env-dev', 'env-test', 'env-production', 'user-jdcasey', 'user-brett', etc.
| This will make it more intuitive to understand what the set of introduced profiles is attempting
| to accomplish, particularly when you only have a list of profile id's for debug.
|
| This profile example uses the JDK version to trigger activation, and provides a JDK-specific repo.
<profile>
<id>jdk-1.4</id>
<activation>
<jdk>1.4</jdk>
</activation>
<repositories>
<repository>
<id>jdk14</id>
<name>Repository for JDK 1.4 builds</name>
<url>http://www.myhost.com/maven/jdk14</url>
<layout>default</layout>
<snapshotPolicy>always</snapshotPolicy>
</repository>
</repositories>
</profile>
-->
<!--
| Here is another profile, activated by the property 'target-env' with a value of 'dev', which
| provides a specific path to the Tomcat instance. To use this, your plugin configuration might
| hypothetically look like:
|
| ...
| <plugin>
| <groupId>org.myco.myplugins</groupId>
| <artifactId>myplugin</artifactId>
|
| <configuration>
| <tomcatLocation>${tomcatPath}</tomcatLocation>
| </configuration>
| </plugin>
| ...
|
| NOTE: If you just wanted to inject this configuration whenever someone set 'target-env' to
| anything, you could just leave off the <value/> inside the activation-property.
|
<profile>
<id>env-dev</id>
<activation>
<property>
<name>target-env</name>
<value>dev</value>
</property>
</activation>
<properties>
<tomcatPath>/path/to/tomcat/instance</tomcatPath>
</properties>
</profile>
-->
</profiles>
<!-- activeProfiles
| List of profiles that are active for all builds.
|
<activeProfiles>
<activeProfile>alwaysActiveProfile</activeProfile>
<activeProfile>anotherAlwaysActiveProfile</activeProfile>
</activeProfiles>
-->
</settings>

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

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

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

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

22
AGENTS.md Normal file
View File

@@ -0,0 +1,22 @@
# 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
`skills/grilling/SKILL.md`. If no dedicated Skill tool is available, load these
files directly and follow their instructions within the available tools.
- Installing or mentioning a skill does not start an interview. Do not apply
`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.

33
CHANGELOG.md Normal file
View File

@@ -0,0 +1,33 @@
# Changelog
## [Unreleased]
### Added
- Angular 22 frontend in `agiledancers-fe` with Dutch-language school management screens.
- Forms for dancers, groups and shows, including multiple group memberships and deletion confirmation.
- Development proxy to the Quarkus REST API, frontend unit tests and end-to-end browser tests.
## [1.0] - 2026-10-27
### Added
- Quarkus backend running on Java 25, with health endpoints and Docker support.
- Domain model for dance schools, dancers, dance groups, coaches, shows and day parts.
- REST API to create, list, retrieve, update and delete dance schools, dancers, dance groups and shows.
- Membership management allowing a dancer to belong to multiple groups within a school.
- Shows with a name, date and three day parts: morning, afternoon and evening.
- Input validation, school-scoped resource access and cleanup of memberships when dancers are deleted.
- Shared in-memory storage for the REST API.
- Separate API model records in `rest.apimodel`.
- ArchUnit checks for package dependencies, domain independence, package cycles and API model conventions.
- HTTP integration tests for resource management, memberships, validation and school isolation.
### Fixed
- Adding entities to initially empty dance school and dance group collections now retains them.
### Limitations
- Data is lost when the application stops.
- Assigning dance groups to show day parts is not yet available.

255
CODING_STANDARDS.md Normal file
View File

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

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
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
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/quarkus-run.jar"]
# CI packages the exact output that already passed mvn clean verify.
FROM runtime AS prebuilt
COPY --chown=10001:10001 agiledancers-be/target/quarkus-app/ ./
# Default standalone build still compiles and tests from source.
FROM runtime AS standalone
COPY --from=build --chown=10001:10001 /workspace/agiledancers-be/target/quarkus-app/ ./

335
README.md
View File

@@ -1,3 +1,336 @@
# agiledancers
To map dancegroups to performancedays
To map dance groups to performance days. The backend runs on Quarkus with Java 25.
Requires JDK 25 and Maven 3.9.0 or newer. Set `JAVA_HOME` to your JDK 25 installation.
Build and run all tests (including `*Sit` tests) from the repository root:
```sh
mvn clean verify
```
Start development mode with live reload from the repository root:
```sh
mvn -pl agiledancers-be quarkus:dev
```
Run the packaged application after building:
```sh
java -jar agiledancers-be/target/quarkus-app/quarkus-run.jar
```
Build and run a Docker container from the repository root (only Docker is required):
```sh
docker build -t agiledancers .
docker run --rm --name agiledancers -p 8080:8080 agiledancers
```
The Docker build compiles the application and runs the tests in a Maven build stage,
then copies the Quarkus application into a Java 25 runtime image running as a non-root user.
Check readiness at <http://localhost:8080/q/health/ready> or overall health at
<http://localhost:8080/q/health>. For example:
```sh
curl --fail http://localhost:8080/q/health/ready
```
Quarkus configuration can be overridden with environment variables, for example
`docker run --rm -e QUARKUS_HTTP_PORT=9090 -p 9090:9090 agiledancers`.
The REST API uses shared in-memory storage; data is lost when the process stops.
Architecture checks run with the normal test suite, or separately with:
```sh
mvn -pl agiledancers-be -Dtest=ArchitectureTest test
```
`ArchitectureTest` guards the following packages below `nl.agilecc.agiledancers`:
| Package | Responsibility | Allowed dependencies within the application |
| --- | --- | --- |
| `model` | Domain entities and specifications | Model |
| `service` | Application use cases and future ports | Model, repositories |
| `repository` | Current in-memory persistence | Model |
| `rest` | Future REST services | Services, model |
| `solver` | Future OR-Tools calculations | Services (ports), model |
REST and solver packages are optional until implemented. OR-Tools dependencies
are allowed only in `solver`; JAX-RS and RESTEasy dependencies only in `rest`.
The model may depend only on itself, Java and Lombok. Package cycles and
production classes outside these packages fail the architecture tests.
API models live in `rest.apimodel`, each in its own Java file as a public top-level
class or record. Their names end in `Request`, `Response`, `View`, `Dto` or `DTO`.
Architecture tests enforce this naming and location convention and require all
records in the REST layer to reside in `rest.apimodel`.
The current services instantiate concrete repositories, so this is not yet strict
Onion architecture. That requires repository interfaces in the core and injected
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
All endpoints consume and produce JSON. Resources are scoped to their dance school.
A dancer can belong to multiple dance groups within the same school. Memberships
are managed separately from group details and survive updates to dancers and groups.
| Resource collection | Item URL |
| --- | --- |
| `/api/dance-schools` | `/api/dance-schools/{schoolId}` |
| `/api/dance-schools/{schoolId}/dancers` | `.../dancers/{dancerId}` |
| `/api/dance-schools/{schoolId}/dance-groups` | `.../dance-groups/{groupId}` |
| `/api/dance-schools/{schoolId}/shows` | `.../shows/{showId}` |
Each collection supports `GET` (list) and `POST` (create). Each item supports `GET`,
`PUT` (replace editable details), and `DELETE`. Creation returns `201` with a
`Location` header; updates return `200`; deletion returns `204`. Invalid input
returns `400`, and missing resources or resources belonging to another school
return `404`. Client-supplied IDs are not used.
Request bodies (dates use ISO `yyyy-MM-dd`):
```json
{"name": "24 Dance Studio"}
```
```json
{"name": "Sam", "birthDate": "2010-05-12"}
```
```json
{
"category": "Teens",
"type": "REGULAR",
"startAge": 12,
"endAge": 18,
"styles": ["HIPHOP", "JAZZ"]
}
```
```json
{"name": "Annual show", "dayParts": [{"date": "2027-06-12", "type": "AFTERNOON"}]}
```
Names and categories must be nonblank. Birth dates cannot be in the future.
Both ages must be nonnegative and `startAge <= endAge`; `styles` is required
and may be empty. Group types are `SHOW_CASE`, `PRE_EDUCATION`, `SELECTION`,
`REGULAR`, `PRE_SCHOOLER`. Styles are `HIPHOP`, `JAZZ`, `MODERN`, `STREETDANCE`,
`CLASSIC`, `BREAKDANCE`.
School responses include `dancerIds`, `danceGroupIds`, and `showIds`; group
responses include `dancerIds`. To add or remove a dancer's group membership:
```sh
curl -X PUT http://localhost:8080/api/dance-schools/0/dance-groups/0/dancers/0
curl -X DELETE http://localhost:8080/api/dance-schools/0/dance-groups/0/dancers/0
```
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, 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:
```sh
mvn -pl agiledancers-be -Dtest=SchoolManagementResourceTest test
```
## Angular frontend
The Angular 22 frontend is in [`agiledancers-fe`](agiledancers-fe/README.md),
next to `agiledancers-be`. It provides Dutch-language screens to manage schools,
dancers, groups, memberships and shows through the REST API.
Use Node.js 22.22.3+ (22.x), 24.15.0+ (24.x), or 26+.
Start the backend as described above, then run in a second terminal:
```sh
cd agiledancers-fe
npm ci
npm start
```
Open <http://localhost:4200>. The development proxy forwards API requests to the
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.
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.

138
agiledancers-be/pom.xml Normal file
View File

@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>nl.agilecc.agiledancers</groupId>
<artifactId>agiledancers</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>agiledancers-be</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
<groupId>com.google.ortools</groupId>
<artifactId>ortools-java</artifactId>
</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>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.datafaker</groupId>
<artifactId>datafaker</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<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>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,31 @@
package nl.agilecc.agiledancers.model;
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
@EqualsAndHashCode
@ToString
@NoArgsConstructor
public abstract class AbstractEntity {
private Long id;
protected <T> void addNullSafe(final Set<T> set, final T t) {
emptyIfNull(set).add(t);
}
private <T> Set<T> emptyIfNull(final Set<T> set) {
return set == null ? new HashSet<>() : set;
}
protected <T> Set<T> unmodifiableNullSafe(final Set<T> set) {
return set == null ? Collections.emptySet() : Collections.unmodifiableSet(set);
}
}

View File

@@ -0,0 +1,20 @@
package nl.agilecc.agiledancers.model;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class Coach extends AbstractEntity {
@ToString.Include
private final String name;
@Setter
private Dancer dancer;
}

View File

@@ -0,0 +1,70 @@
package nl.agilecc.agiledancers.model;
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;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
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;
public Set<Dancer> getDancers() {
return super.unmodifiableNullSafe(dancers);
}
public void addDancer(final Dancer dancer) {
super.addNullSafe(dancers, dancer);
}
public Set<DanceGroupStyle> getStyles() {
return super.unmodifiableNullSafe(styles);
}
public void addStyle(final DanceGroupStyle style) {
super.addNullSafe(styles, style);
}
public void removeDancer(final Dancer dancer) {
dancers.remove(dancer);
}
public void replaceStyles(final Set<DanceGroupStyle> styles) {
this.styles.clear();
this.styles.addAll(styles);
}
}

View File

@@ -0,0 +1,78 @@
package nl.agilecc.agiledancers.model;
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)
@Builder
@ToString(onlyExplicitlyIncluded = true)
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<>();
public Set<Dancer> getDancers() {
return super.unmodifiableNullSafe(dancers);
}
public Set<DanceGroup> getDanceGroups() {
return super.unmodifiableNullSafe(danceGroups);
}
public Set<Show> getShows() {
return super.unmodifiableNullSafe(shows);
}
public Set<Coach> getCoaches() {
return super.unmodifiableNullSafe(coaches);
}
public void addDancer(final Dancer dancer) {
super.addNullSafe(dancers, dancer);
}
public void addDanceGroup(final DanceGroup danceGroup) {
super.addNullSafe(danceGroups, danceGroup);
}
public void addShow(final Show show) {
super.addNullSafe(shows, show);
}
public void addCoach(final Coach coach) {
super.addNullSafe(coaches, coach);
}
public void removeDancer(final Dancer entity) {
dancers.remove(entity);
}
public void removeDanceGroup(final DanceGroup entity) {
danceGroups.remove(entity);
}
public void removeShow(final Show entity) {
shows.remove(entity);
}
}

View File

@@ -0,0 +1,28 @@
package nl.agilecc.agiledancers.model;
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)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class Dancer extends AbstractEntity {
@ToString.Include
@Setter
private String name;
@ToString.Include
@Setter
private LocalDate birthDate;
public int age() {
return Period.between(birthDate, LocalDate.now()).getYears();
}
}

View File

@@ -0,0 +1,32 @@
package nl.agilecc.agiledancers.model;
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)
@Builder
@ToString(onlyExplicitlyIncluded = true)
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(final DanceGroup group) {
danceGroups = danceGroups.stream()
.filter((final DanceGroup value) -> value != group)
.toList();
}
}

View File

@@ -0,0 +1,33 @@
package nl.agilecc.agiledancers.model;
import java.util.List;
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)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class Show extends AbstractEntity {
@Setter
private String name;
@Setter
private Integer preferredDayParts;
@Setter
private boolean finalized;
@Setter
private Map<DanceGroupType, Integer> appearances;
@ToString.Include
@Setter
private List<DayPart> dayParts;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.repository;
import nl.agilecc.agiledancers.model.Coach;
public class CoachRepository extends AbstractRepository<Coach> {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
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.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 {
private @Inject SchoolManagementService service;
private @PathParam("schoolId") long schoolId;
@GET
public List<GroupView> list() {
return service.groups(schoolId).stream().map(GroupView::of).toList();
}
@GET
@Path("/{id}")
public GroupView get(final @PathParam("id") long id) {
return GroupView.of(service.group(schoolId, id));
}
@POST
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(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(final @PathParam("id") long id) {
service.deleteGroup(schoolId, id);
return Response.noContent().build();
}
@PUT
@Path("/{id}/dancers/{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(final @PathParam("id") long id, final @PathParam("dancerId") long dancerId) {
service.removeGroupDancer(schoolId, id, dancerId);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,63 @@
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.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 {
private @Inject SchoolManagementService service;
@GET
public List<SchoolView> list() {
return service.schools().stream().map(SchoolView::of).toList();
}
@GET
@Path("/{id}")
public SchoolView get(final @PathParam("id") long id) {
return SchoolView.of(service.school(id));
}
@POST
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(final @PathParam("id") long id, final @NotNull @Valid SchoolRequest request) {
return SchoolView.of(service.updateSchool(id, request.name()));
}
@DELETE
@Path("/{id}")
public Response delete(final @PathParam("id") long id) {
service.deleteSchool(id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,65 @@
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.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 {
private @Inject SchoolManagementService service;
private @PathParam("schoolId") long schoolId;
@GET
public List<DancerView> list() {
return service.dancers(schoolId).stream().map(DancerView::of).toList();
}
@GET
@Path("/{id}")
public DancerView get(final @PathParam("id") long id) {
return DancerView.of(service.dancer(schoolId, id));
}
@POST
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(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(final @PathParam("id") long id) {
service.deleteDancer(schoolId, id);
return Response.noContent().build();
}
}

View File

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

View File

@@ -0,0 +1,24 @@
package nl.agilecc.agiledancers.rest;
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.rest.apimodel.ErrorView;
import nl.agilecc.agiledancers.service.ManagementException;
@Provider
public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> {
@Override
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();
}
}

View File

@@ -0,0 +1,72 @@
package nl.agilecc.agiledancers.rest;
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.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 {
private @Inject SchoolManagementService service;
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 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(final String json) {
try {
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");
}
return snapshot;
} catch (final Exception e) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "Ongeldig JSON-bestand: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,120 @@
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.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 {
private @Inject SchoolManagementService service;
private @Inject SchedulingService scheduling;
private @Inject ShowSchedules schedules;
private @PathParam("schoolId") long schoolId;
@GET
public List<ShowView> list() {
return service.shows(schoolId).stream().map(this::view).toList();
}
@GET
@Path("/{id}")
public ShowView get(final @PathParam("id") long id) {
return view(service.show(schoolId, id));
}
@POST
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(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(final @PathParam("id") long id) {
service.deleteShow(schoolId, id);
return Response.noContent().build();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import java.time.LocalDate;
public record DancerRequest(
@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
package nl.agilecc.agiledancers.rest.apimodel;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
public record ErrorView(String message) {}

View File

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

View File

@@ -0,0 +1,36 @@
package nl.agilecc.agiledancers.rest.apimodel;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import nl.agilecc.agiledancers.model.DanceGroup;
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 GroupView {
styles = styles == null ? null : Collections.unmodifiableSet(new LinkedHashSet<>(styles));
dancerIds = dancerIds == null ? null : Collections.unmodifiableList(new ArrayList<>(dancerIds));
}
public static GroupView of(final DanceGroup group) {
return new GroupView(
group.getId(),
group.getCategory(),
group.getType(),
group.getStartAge(),
group.getEndAge(),
Set.copyOf(group.getStyles()),
group.getDancers().stream().map(Dancer::getId).toList());
}
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotBlank;
public record SchoolRequest(@NotBlank String name) {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
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.repository.DanceGroupRepository;
import nl.agilecc.agiledancers.service.model.GroupDetails;
public class DanceGroupService {
private final DanceGroupRepository danceGroupRepository = new DanceGroupRepository();
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(details.styles() == null ? null : new LinkedHashSet<>(details.styles()))
.coach(coach)
.build();
return danceGroupRepository.add(danceGroup);
}
}

View File

@@ -0,0 +1,33 @@
package nl.agilecc.agiledancers.service;
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(final String name) {
final DanceSchool danceSchool = DanceSchool.builder().name(name).build();
return danceSchoolRepository.add(danceSchool);
}
public void addDancer(final Long danceSchoolId, final Dancer dancer) {
danceSchoolRepository.findById(danceSchoolId).addDancer(dancer);
}
public void addDanceGroup(final Long danceSchoolId, final DanceGroup danceGroup) {
danceSchoolRepository.findById(danceSchoolId).addDanceGroup(danceGroup);
}
public void addCoach(final Long danceSchoolId, final Coach coach) {
danceSchoolRepository.findById(danceSchoolId).addCoach(coach);
}
public void addShow(final Long danceSchoolId, final Show show) {
danceSchoolRepository.findById(danceSchoolId).addShow(show);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,432 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Singleton;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import nl.agilecc.agiledancers.model.AbstractEntity;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.ScheduleRules;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.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
public class SchoolManagementService {
private final DanceSchoolRepository schools = new DanceSchoolRepository();
private final DancerRepository dancers = new DancerRepository();
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(final long schoolId) {
return required(schools.findById(schoolId), "Dance school", schoolId);
}
public synchronized DanceSchool createSchool(final String name) {
return schools.add(DanceSchool.builder().name(name).build());
}
public synchronized DanceSchool updateSchool(final long schoolId, final String name) {
final DanceSchool school = school(schoolId);
school.setName(name);
return school;
}
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(final long schoolId) {
return List.copyOf(school(schoolId).getDancers());
}
public synchronized Dancer dancer(final long schoolId, final long dancerId) {
return member(school(schoolId).getDancers(), dancerId, "Dancer");
}
public synchronized Dancer createDancer(final long schoolId, final String name, final LocalDate birthDate) {
final DanceSchool school = school(schoolId);
final Dancer dancer =
dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
school.addDancer(dancer);
return dancer;
}
public synchronized Dancer updateDancer(
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(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(final long schoolId) {
return List.copyOf(school(schoolId).getDanceGroups());
}
public synchronized DanceGroup group(final long schoolId, final long groupId) {
return member(school(schoolId).getDanceGroups(), groupId, "Dance group");
}
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(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(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(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(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(final long schoolId) {
return List.copyOf(school(schoolId).getShows());
}
public synchronized Show show(final long schoolId, final long showId) {
return member(school(schoolId).getShows(), showId, "Show");
}
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(
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.setDayParts(retained);
show.setAppearances(Map.copyOf(counts));
if (changed) {
touch(show);
}
return show;
}
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 synchronized SchoolSnapshot exportSchool(final long id) {
return SchoolSnapshot.of(school(id));
}
public synchronized ImportPreview preview(final SchoolSnapshot snapshot) {
snapshot.restore();
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());
}
/** 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(
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.
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());
});
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);
}
importedDancers.forEach(dancers::add);
rebuiltGroups.forEach(groups::add);
rebuiltShows.forEach(shows::add);
return schools.add(imported);
}
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(ManagementExceptionKind.INVALID_INPUT, "startAge must not exceed endAge");
}
}
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(final T value, final String type, final long id) {
if (value == null) {
throw new ManagementException(ManagementExceptionKind.NOT_FOUND, type + " " + id + " not found");
}
return value;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.Loader;
import com.google.ortools.sat.CpModel;
import com.google.ortools.sat.CpSolver;
import com.google.ortools.sat.CpSolverStatus;
import com.google.ortools.sat.LinearExpr;
import jakarta.inject.Singleton;
import java.util.List;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton
public class OrToolsSchedulingEngine implements SchedulingEngine {
private final double timeLimitSeconds;
public OrToolsSchedulingEngine() {
this(1800);
}
/** Allows callers to impose a shorter run budget, for example in automated tests. */
public OrToolsSchedulingEngine(final double timeLimitSeconds) {
this.timeLimitSeconds = timeLimitSeconds;
}
@Override
public SchedulingResult solve(final SchedulingInput input) {
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
Loader.loadNativeLibraries();
final CpModel model = new CpModel();
final OrderedAssignments vars = OrderedAssignments.create(model, input);
final LinearExpr attendance = SchedulingObjectives.attendance(model, input, vars);
final LinearExpr variety = SchedulingObjectives.variety(model, input, vars);
final CpSolver solver = new CpSolver();
solver.getParameters().setCatchSigintSignal(false);
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
SchedulingResult best = null;
for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) {
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
if (remaining <= 0) {
if (best != null) {
return new SchedulingResult(false, best.assignments());
}
requireSolution(CpSolverStatus.UNKNOWN);
}
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
model.minimize(objective);
final CpSolverStatus status = solver.solve(model);
if (status == CpSolverStatus.UNKNOWN && best != null) {
return new SchedulingResult(false, best.assignments());
}
requireSolution(status);
best = new SchedulingResult(status == CpSolverStatus.OPTIMAL, vars.read(input, solver));
if (status != CpSolverStatus.OPTIMAL) {
return best;
}
model.addEquality(objective, Math.round(solver.objectiveValue()));
}
return best;
}
private static void requireSolution(final CpSolverStatus status) {
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
switch (status) {
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
case UNKNOWN ->
"The 30-minute limit was reached without a valid result. Existing assignments were"
+ " preserved.";
default -> "Solver failed (" + status + "). Existing assignments were preserved.";
});
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,133 @@
package nl.agilecc.agiledancers;
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.
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);
private @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 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.
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");
private @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 MODEL_IS_INDEPENDENT_OF_FRAMEWORKS = classes()
.that()
.resideInAPackage(ROOT + ".model..")
.should()
.onlyDependOnClassesThat()
.resideInAnyPackage(ROOT + ".model..", "java..", "lombok..");
private @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 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 PACKAGES_ARE_FREE_OF_CYCLES =
slices().matching(ROOT + ".(*)..").should().beFreeOfCycles();
}

View File

@@ -0,0 +1,14 @@
package nl.agilecc.agiledancers;
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 {
public @Test void applicationIsReady() {
given().when().get("/q/health/ready").then().statusCode(200).body("status", equalTo("UP"));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,294 @@
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 java.util.Map;
import org.junit.jupiter.api.Test;
@QuarkusTest
class SchoolManagementResourceTest {
private static final String SCHOOLS = "/api/dance-schools";
private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
private static final Map<String, Object> GROUP = Map.of(
"category", "Teens", "type", "REGULAR", "startAge", 12, "endAge", 18, "styles", new String[] {"HIPHOP"});
private static final Map<String, Object> SHOW =
Map.of("name", "Annual show", "dayParts", new Object[] {Map.of("date", "2027-06-12", "type", "AFTERNOON")});
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"));
final long dancerId = create(school + "/dancers", DANCER);
final String dancer = school + "/dancers/" + dancerId;
put(dancer, Map.of("name", "Sam updated", "birthDate", "2011-06-13"))
.statusCode(200)
.body("birthDate", equalTo("2011-06-13"));
given().get(dancer).then().statusCode(200).body("name", equalTo("Sam updated"));
given().get(school + "/dancers").then().statusCode(200).body("id", contains((int) dancerId));
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));
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());
} finally {
given().delete(school).then().statusCode(204);
}
given().get(school).then().statusCode(404);
}
public @Test void dancerCanBelongToMultipleGroupsAndDeletingDancerRemovesMemberships() {
final String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Membership school"));
try {
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));
}
given().delete(first + "/dancers/" + dancerId).then().statusCode(204);
given().delete(first + "/dancers/" + dancerId).then().statusCode(204);
given().get(first).then().body("dancerIds", empty());
given().get(second).then().body("dancerIds", contains((int) dancerId));
given().put(first + "/dancers/" + dancerId).then().statusCode(204);
given().delete(school + "/dancers/" + dancerId).then().statusCode(204);
given().get(first).then().body("dancerIds", empty());
given().get(second).then().body("dancerIds", empty());
} finally {
given().delete(school).then().statusCode(204);
}
}
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 {
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().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty());
given().delete(first).then().statusCode(204);
given().get(first + "/dancers").then().statusCode(404);
given().get(first + "/dance-groups").then().statusCode(404);
given().get(first + "/shows").then().statusCode(404);
given().get(second).then().statusCode(200);
} finally {
given().delete(first);
given().delete(second).then().statusCode(204);
}
}
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);
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"));
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().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);
} finally {
given().delete(school).then().statusCode(204);
}
}
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 String manageGroup(final String school, final long dancerId) {
final long groupId = create(school + "/dance-groups", GROUP);
final String group = school + "/dance-groups/" + groupId;
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
put(
group,
Map.of(
"category",
"Selection",
"type",
"SELECTION",
"startAge",
10,
"endAge",
20,
"styles",
new String[] {"JAZZ", "MODERN"}))
.statusCode(200)
.body("category", equalTo("Selection"))
.body("dancerIds", contains((int) dancerId))
.body("styles", containsInAnyOrder("JAZZ", "MODERN"));
given().get(group).then().statusCode(200).body("type", equalTo("SELECTION"));
given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId));
return group;
}
private static String manageShow(final String school) {
final long showId = create(school + "/shows", SHOW);
final String show = school + "/shows/" + showId;
given().get(show)
.then()
.statusCode(200)
.body("dayParts.type", contains("AFTERNOON"))
.body("dayParts.danceGroupIds", everyItem(empty()));
put(show, Map.of("name", "Summer show", "dayParts", new Object[] {
Map.of("date", "2027-07-01", "type", "EVENING")
}))
.statusCode(200)
.body("dayParts.date", contains("2027-07-01"));
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
return show;
}
private static void rejectsInvalidDancersAndGroups(final String school) {
for (final Object body : new Object[] {
Map.of("name", "Sam"),
Map.of("name", "Sam", "birthDate", "2999-01-01"),
Map.of("name", "Sam", "birthDate", "not-a-date")
}) {
given().contentType("application/json")
.body(body)
.post(school + "/dancers")
.then()
.statusCode(400);
}
for (final String body : new String[] {
"{}",
"{\"category\":\"Teens\",\"type\":\"INVALID\"}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"
}) {
given().contentType("application/json")
.body(body)
.post(school + "/dance-groups")
.then()
.statusCode(400);
}
}
private static long create(final String path, final Object body) {
return given().contentType("application/json")
.body(body)
.post(path)
.then()
.statusCode(201)
.header("Location", matchesPattern(".*/[0-9]+$"))
.extract()
.jsonPath()
.getLong("id");
}
private static ValidatableResponse put(final String path, final Object body) {
return given().contentType("application/json").body(body).put(path).then();
}
}

View File

@@ -0,0 +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;
@QuarkusTest
class SchoolSnapshotResourceTest {
private static final String BASE = "/api/dance-schools";
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));
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");
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
} finally {
given().delete(BASE + "/" + id);
}
}
private static void rejectInvalidFiles(final long id, final String json) {
for (final String bad : new String[] {
"{",
"null",
json.replace("\"formatVersion\" : 3", "\"formatVersion\" : 1"),
json.replace("\"name\" :", "\"unknown\" :"),
json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")
}) {
given().contentType("application/json")
.body(bad)
.post(BASE + "/import?replaceId=" + id)
.then()
.statusCode(400)
.body("message", not(emptyOrNullString()));
}
}
}

View File

@@ -0,0 +1,130 @@
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.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.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(
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))));
}
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());
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()));
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((final DanceGroup g) -> assertTrue(g.getDancers().isEmpty()));
assertEquals(1, existing.getDancers().size());
final DanceGroup group = imported.getDanceGroups().iterator().next();
service.deleteGroup(imported.getId(), group.getId());
assertEquals(
1,
imported.getShows()
.iterator()
.next()
.getDayParts()
.getFirst()
.getDanceGroups()
.size());
assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore());
}
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());
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()));
}
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()));
}
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()));
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
node_modules
.angular
.git
test-results
playwright-report
unit-results

View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

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