Compare commits

...
Author SHA1 Message Date
nessar d74dfb9938 ci: migrate to Gitea Actions
CI / policy (push) Successful in 48s
CI / test (push) Canceled after 0s
CI / benchmark (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / images (push) Canceled after 0s
CI / validate (push) Canceled after 1m8s
CI / policy (pull_request) Successful in 9s
CI / validate (pull_request) Successful in 1m6s
CI / test (pull_request) Successful in 3m30s
CI / benchmark (pull_request) Failing after 5m39s
CI / build (pull_request) Successful in 1m5s
CI / security (pull_request) Successful in 1m27s
CI / required (push) Failing after 1s
CI / images (pull_request) Failing after 18s
CI / required (pull_request) Failing after 1s
2026-10-13 19:43:08 +02:00
nessar 7d60157863 docs: migrate repository guidance to Gitea 2026-10-13 19:38:32 +02:00
nessar 0d900fd72d feat(planning): make optimization rules composable
Closes #70, #71, #72, #73, and #74

See merge request nessar/vac-optimizer!189
2026-10-10 14:56:48 +00:00
nessar ae68e31575 docs: describe the advisory planning benchmark 2026-10-10 16:42:04 +02:00
nessar 98e87fecee ci: make the deferred planning benchmark non-blocking
Run the unchanged benchmark as a separate advisory job while #78 is open.
Keep functional coverage and other checks required, and retain the 2 GB heap
and 300-second protocol limit. Record #78's obligation to restore the gate.

Refs: #75, #78
2026-10-10 16:36:11 +02:00
nessar 6811142299 feat(planning): make optimization rules composable
Evaluate explicit, ordered optimization configurations through one shared
planner model. Add six editable presets, durable one-time legacy migration,
and consistent completed and cancelled plan provenance.

Include functional regressions and the agreed trace-removal and benchmark
follow-up specs. Performance acceptance remains in #78 under unchanged gates.

Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
2026-10-10 16:07:34 +02:00
80 changed files with 9611 additions and 3394 deletions

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ Inspect `git status --short` and distinguish pre-existing user changes from task
Look for the originating spec, in this order:
1. The user's explicit spec, ticket, path, or acceptance criteria in the conversation. Preserve it as authoritative even when commits reference other work.
2. Issue/MR references in relevant commit messages — fetch via `docs/agents/issue-tracker.md` when no explicit source was supplied.
2. Issue/PR references in relevant commit messages — fetch via `docs/agents/issue-tracker.md` when no explicit source was supplied.
3. A PRD/spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature.
4. If nothing is found or the source is inaccessible, continue the Standards review and report the Spec coverage limitation. Ask for a source only when needed to answer the user's request; do not invent requirements or require tracker setup.
@@ -42,10 +42,11 @@ Preserve the configured tracker. For a new setup, infer a likely tracker from th
- **GitHub** — issues live in the repo's GitHub Issues (uses the `gh` CLI)
- **GitLab** — issues live in the repo's GitLab Issues (uses the [`glab`](https://gitlab.com/gitlab-org/cli) CLI)
- **Gitea** — issues live in the repo's Gitea Issues (uses `tea-cli`, or `tea` where that is the installed binary; inspect its help for supported commands)
- **Local markdown** — issues live as files under `.scratch/<feature>/` in this repo (good for solo projects or repos without a remote)
- **Other** (Jira, Linear, etc.) — ask the user to describe the workflow in one paragraph; the skill will record it as freeform prose
Record the choice in `docs/agents/issue-tracker.md`. The GitHub and GitLab templates carry a "PRs as a request surface" flag, defaulted **off** — leave it off and don't raise it; a user who wants external PRs in the triage queue can flip the flag in the file later.
Record the choice in `docs/agents/issue-tracker.md`. The GitHub, GitLab, and Gitea templates carry a "PRs as a request surface" flag, defaulted **off** — leave it off and don't raise it; a user who wants external PRs in the triage queue can flip the flag in the file later.
**Section B — Triage label vocabulary.** Skip this section entirely if the `triage` skill isn't installed (exploration told you) — an uninstalled skill needs no labels.
@@ -105,6 +106,7 @@ Then write the docs files using the seed templates in this skill folder as a sta
- [issue-tracker-github.md](./issue-tracker-github.md) — GitHub issue tracker
- [issue-tracker-gitlab.md](./issue-tracker-gitlab.md) — GitLab issue tracker
- [issue-tracker-gitea.md](./issue-tracker-gitea.md) — Gitea issue tracker
- [issue-tracker-local.md](./issue-tracker-local.md) — local-markdown issue tracker
- [triage-labels.md](./triage-labels.md) — label mapping (only if `triage` is installed)
- [domain.md](./domain.md) — domain doc consumer rules + layout
@@ -0,0 +1,48 @@
# Issue tracker: Gitea
Issues and specs for this repo live as Gitea issues. Use the [`tea`](https://gitea.com/gitea/tea) CLI, called `tea-cli` on some systems. Record the instance URL, repository owner/name, installed command version, and supported operations when adapting this template.
## Access and conventions
Use the existing configured login and infer the repository from `git remote -v`. Check the installed binary's help before selecting commands: tea 1.0 changes the command groups, authentication, flags, and output formats. The examples here use `tea-cli` 0.9.2; options precede positional issue or PR numbers.
- **Authenticate**: use the existing login; for a new login, run interactive `tea-cli login add` with the repository's instance URL.
- **Create an issue**: `tea-cli issues create --title "..." --description "..." --labels "needs-triage"`. Pass multiline descriptions as one quoted argument.
- **Read an issue**: `tea-cli issues --comments <number>`.
- **List issues**: `tea-cli issues --state open --labels "..." --fields index,title,body,state,labels,assignees,url --output yaml`. Paginate with `--page` and `--limit` until all matching issues have been read.
- **Comment**: `tea-cli comment <number> "..."`.
- **Close**: post the explanation first, then `tea-cli issues close <number>`.
- **Pull requests**: `tea-cli pulls create --base <integration-branch> --title "..." --description "..."`, `tea-cli pulls --comments <number>`, and `tea-cli pulls --state open --output yaml`.
- **Milestones**: `tea-cli milestones --state all --fields id,title --output tsv`. Verify migrated milestone IDs rather than copying source-tracker IDs.
This version has no JSON output, issue-edit command, or `api` command. Structured output flags apply to lists; detail views render for display. For unsupported operations, use the instance's `/api/swagger` explorer and `/swagger.v1.json` schema, or the Gitea UI. Authenticate API requests with an authorized token without displaying or committing it. API paths below are relative to the instance's `/api/v1`:
- Read issue details and comments with `GET /repos/<owner>/<repo>/issues/<number>` and its `/comments` endpoint; paginate comments.
- Resolve label IDs with `GET /repos/<owner>/<repo>/labels`. Add labels with `POST /repos/<owner>/<repo>/issues/<number>/labels`, body `{"labels":[<label-id>]}`; remove one with `DELETE` on `/labels/<label-id>`. Preserve unrelated labels.
- Edit a body or assignment with `PATCH /repos/<owner>/<repo>/issues/<number>`, body `{"body":"..."}` or `{"assignees":["<username>"]}`. Read current assignees before updating the list.
- Read a PR diff with `GET /repos/<owner>/<repo>/pulls/<number>.diff`.
## Pull requests as a triage surface
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external pull requests as feature requests; `/triage` reads this flag.)_
When enabled, read and list with `tea-cli pulls`; keep only external requests after checking collaborator or organization membership. Use `tea-cli comment`, the issue-label API, and `tea-cli pulls close <number>` for comment / label / close operations.
Resolve bare `#<number>` references by checking the resource kind with `tea-cli issues --kind all --fields index,kind,title,url --output yaml` or the issue API's `pull_request` field. Issue URLs use `/issues/<number>`; PR URLs use `/pulls/<number>`.
## When a skill says "publish to the issue tracker"
Create a Gitea issue with `tea-cli issues create`.
## When a skill says "fetch the relevant ticket"
Run `tea-cli issues --comments <number>` and read its labels and assignees.
## Wayfinding operations
- **Map**: an issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. Create it with `tea-cli issues create --title "..." --description "..." --labels "wayfinder:map"`.
- **Child ticket**: an issue with `Part of #<map>` at the top of its description, labels `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`), and an ordered link in the map's body.
- **Blocking**: `POST /repos/<owner>/<repo>/issues/<child>/dependencies`, body `{"owner":"<owner>","repo":"<repo>","index":<blocker-number>}`. `GET` lists blockers; `DELETE` with the same body removes one. Where native dependencies are unavailable, use `Blocked by: #<n>, #<n>` at the top of the description. Every blocker must be closed before the ticket is unblocked.
- **Frontier query**: list all open children with bodies and assignees, drop assigned tickets and those with an open native or description-line blocker; first in map order wins.
- **Claim**: assign the authenticated username through the issue API or Gitea UI — the session's first write.
- **Resolve**: comment with `tea-cli comment`, close with `tea-cli issues close`, and update the map's Decisions-so-far through the issue API or Gitea UI.
+1 -1
View File
@@ -19,7 +19,7 @@ A wizard is ephemeral by default — built for one run, saved to a scratch or `s
Work out every manual step the human must take and every value that gets captured along the way. Read the repo first — don't ask cold:
- For setup: configuration examples, README, compose/framework configuration, and the actual CI files (this repository uses `.gitlab-ci.yml`). Inspect secret names and references without displaying existing secret values. Include only values needed for the requested procedure, not every variable in the repository.
- For setup: configuration examples, README, compose/framework configuration, and the actual CI files. This repository is hosted on Gitea; read `.gitea/workflows/` and `docs/gitea-actions.md` for the configured workflow and owner-activation requirements. Inspect secret names and references without displaying existing secret values. Include only values needed for the requested procedure, not every variable in the repository.
- For a migration or transition: the current state, the target state, and the irreversible actions between them.
Then show the user the ordered list of stages and the values each produces, and confirm — they may add, drop, or reorder.
+8 -5
View File
@@ -6,7 +6,8 @@ All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to
- The overall project code coverage (Statements, Lines, and Functions) must be maintained at **80% or higher**.
- Verify coverage by running:
```bash
bun run test --watch=false --coverage
bun scripts/generate-langs.js
NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
```
- If your changes introduce new code or modify existing code in a way that drops the coverage below 80%, you must write appropriate unit tests in `.spec.ts` files to bring it back up.
@@ -24,10 +25,12 @@ All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to
bun run build
```
4. **GitLab Pipeline Checks**:
- You must execute the local equivalent of the GitLab CI/CD pipeline steps before completing your task. All of the following commands must pass:
4. **CI Checks**:
- You must execute the local validation commands before completing your task. The Gitea workflow is `.gitea/workflows/ci.yml`; owner activation is tracked in issue #268, and local checks do not satisfy the migration gate. All of the following commands must pass:
- **Linting check**: `bun run lint` (or use `bun run lint:fix` to resolve auto-fixable issues)
- **Formatting check**: `bun run format:check`
- **TypeScript Type check**: `bunx tsc --noEmit`
- **Unit Tests and Coverage**: `bun run test --watch=false --coverage`
- **TypeScript Type check**: `bunx tsc --noEmit -p tsconfig.app.json` and `bunx tsc --noEmit -p tsconfig.spec.json`
- **Unit Tests and Coverage**: generate the language registry, then run the coverage command in rule 1, matching the Gitea functional-test job.
- **CI policy and publication**: `node scripts/ci-policy.test.mjs` and `node scripts/ci-image.test.mjs`
- **Production Build**: `bun run build -- --configuration production`
- The separate performance benchmark remains advisory while issue #78 is open. Run it outside coverage with `NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1` when verifying performance; see `docs/benchmark/README.md`.
@@ -0,0 +1,5 @@
${PullRequestTitle}
${ClosingIssues}
See pull request ${PullRequestReference}
+210
View File
@@ -0,0 +1,210 @@
name: CI
on:
push:
pull_request:
types: [opened, reopened, synchronize, edited]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
jobs:
policy:
name: policy
runs-on: ubuntu-latest
container: node:24.21.0
outputs:
slug: ${{ steps.policy.outputs.slug }}
tag: ${{ steps.policy.outputs.tag }}
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Policy regression tests
run: node scripts/ci-policy.test.mjs && node scripts/ci-image.test.mjs
- name: Validate event and commit
id: policy
run: node scripts/ci-policy.mjs
validate:
name: validate
runs-on: ubuntu-latest
container: node:24.21.0
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run format:check
- run: bunx prettier --check 'scripts/ci-*.mjs' '.gitea/workflows/*.yml'
- run: bunx tsc --noEmit -p tsconfig.app.json
- run: bunx tsc --noEmit -p tsconfig.spec.json
test:
name: test
runs-on: ubuntu-latest
container: node:24.21.0
env:
NODE_OPTIONS: --max-old-space-size=2048
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- run: bun install --frozen-lockfile
- run: bun scripts/generate-langs.js
- name: Functional coverage
run: bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
- name: Coverage report
if: ${{ always() }}
uses: https://gitea.com/actions/upload-artifact@v3
with:
name: coverage
path: coverage/
retention-days: 7
if-no-files-found: error
benchmark:
name: benchmark
runs-on: ubuntu-latest
container: node:24.21.0
# shortcut: advisory while #78 is open; restore the gate after the protocol passes.
continue-on-error: true
env:
NODE_OPTIONS: --max-old-space-size=2048
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- run: bun install --frozen-lockfile
- run: bun scripts/generate-langs.js
- name: Unchanged benchmark protocol
run: timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
build:
name: build
runs-on: ubuntu-latest
container: node:24.21.0
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
persist-credentials: false
- run: npm install --global bun@1.4.2
- run: bun install --frozen-lockfile
- run: bun run build -- --configuration production
- uses: https://gitea.com/actions/upload-artifact@v3
with:
name: dist
path: dist/
retention-days: 7
if-no-files-found: error
security:
name: security
runs-on: ubuntu-latest
container: node:24.21.0
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
persist-credentials: false
- name: Install scanners
run: |
apt-get update
apt-get install --yes --no-install-recommends python3-venv
python3 -m venv /tmp/semgrep
/tmp/semgrep/bin/pip install semgrep==1.180.0
case "$(uname -m)" in
x86_64) trivy_arch=64bit ;;
aarch64) trivy_arch=ARM64 ;;
*) echo 'Unsupported Trivy architecture' >&2; exit 1 ;;
esac
archive="trivy_0.75.0_Linux-${trivy_arch}.tar.gz"
curl --fail --location --silent --show-error "https://github.com/aquasecurity/trivy/releases/download/v0.75.0/${archive}" --output "/tmp/${archive}"
curl --fail --location --silent --show-error https://github.com/aquasecurity/trivy/releases/download/v0.75.0/trivy_0.75.0_checksums.txt --output /tmp/trivy-checksums.txt
(cd /tmp && sha256sum --check --ignore-missing trivy-checksums.txt)
tar -xzf "/tmp/${archive}" -C /usr/local/bin trivy
- name: JavaScript and TypeScript SAST
run: /tmp/semgrep/bin/semgrep scan --config p/javascript --config p/typescript --metrics off --sarif --output semgrep-results.sarif src scripts
- name: Dependency and secret scan
if: ${{ !cancelled() }}
run: trivy fs --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
- name: Security reports
if: ${{ always() }}
uses: https://gitea.com/actions/upload-artifact@v3
with:
name: security
path: '*-results.sarif'
retention-days: 7
if-no-files-found: error
images:
name: images
needs: [policy, validate, test, build, security]
if: >-
success() &&
(github.event_name == 'push' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository))
runs-on: ubuntu-latest
container: docker:29.8.2
concurrency:
group: image-${{ needs.policy.outputs.tag != '' && 'stable' || needs.policy.outputs.slug }}
cancel-in-progress: false
services:
docker:
image: docker:29.8.2-dind
env:
DOCKER_TLS_CERTDIR: ''
options: >-
--privileged
--health-cmd "docker info"
--health-interval 5s
--health-timeout 5s
--health-retries 12
env:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ''
defaults:
run:
shell: sh
steps:
- name: Install checkout and policy runtimes
run: apk add --no-cache nodejs git
- uses: https://gitea.com/actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Build branch images or promote a release
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: node scripts/ci-image.mjs
required:
name: required
if: ${{ always() }}
needs: [policy, validate, test, build, security, images]
runs-on: ubuntu-latest
container: node:24.21.0
env:
CHECK_RESULTS: ${{ needs.policy.result }} ${{ needs.validate.result }} ${{ needs.test.result }} ${{ needs.build.result }} ${{ needs.security.result }}
IMAGE_RESULT: ${{ needs.images.result }}
IMAGE_EXPECTED: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}
steps:
- name: Require successful validation and expected publication
run: |
test "$CHECK_RESULTS" = 'success success success success success'
if [ "$IMAGE_EXPECTED" = 'true' ]; then
test "$IMAGE_RESULT" = 'success'
else
test "$IMAGE_RESULT" = 'skipped'
fi
-172
View File
@@ -1,172 +0,0 @@
image: oven/bun:latest
workflow:
auto_cancel:
on_new_commit: interruptible
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_PIPELINE_SOURCE == "push"
when: never
- if: $CI_PIPELINE_SOURCE == "push"
default:
interruptible: true
stages:
- validate
- test
- build
- security
- docker
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ''
AST_ENABLE_MR_PIPELINES: 'true'
cache:
key:
files:
- bun.lock
paths:
- .bun/
.node_setup:
rules:
- when: on_success
before_script:
- bun install --frozen-lockfile
# -- Validate Stage --
ci-policy:
image: node:24.21.0
stage: validate
variables:
GIT_DEPTH: '0'
rules:
- when: on_success
script:
- node scripts/ci-policy.test.mjs
- node scripts/ci-policy.mjs
lint:
extends: .node_setup
stage: validate
script:
- bunx eslint "src/**/*.ts"
prettier:
extends: .node_setup
stage: validate
script:
- bunx prettier --check "src/**/*.{ts,html,scss,css,json}"
type-check:
extends: .node_setup
stage: validate
script:
- bunx tsc --noEmit
# -- Test Stage --
test:
image: node:24.21.0
extends: .node_setup
stage: test
variables:
NODE_OPTIONS: --max-old-space-size=2048
before_script:
- npm install --global bun@1.4.2
- bun install --frozen-lockfile
script:
- |
node -e 'console.log("CI memory (bytes)", { available: process.availableMemory(), heapLimit: require("node:v8").getHeapStatistics().heap_size_limit })'
- bun scripts/generate-langs.js
# ponytail: one worker bounds peak RAM; increase concurrency after adding runner memory.
- bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
# Keep the complete benchmark outside coverage instrumentation.
- bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
artifacts:
when: always
paths:
- coverage/
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
expire_in: 7 days
# -- Build Stage --
build:
image: node:24.21.0
extends: .node_setup
stage: build
before_script:
- npm install --global bun@1.4.2
- bun install --frozen-lockfile
script:
- bun run build -- --configuration production
artifacts:
paths:
- dist/
expire_in: 7 days
# -- Security Stage --
include:
- template: Security/SAST.gitlab-ci.yml
trivy_scan:
stage: security
image:
name: aquasec/trivy:latest
entrypoint: ['']
before_script: [] # override global before_script
rules:
- when: on_success
script:
# Trivy will scan the project directory
- trivy fs --format sarif --output trivy-results.sarif .
artifacts:
reports:
sast: trivy-results.sarif
expire_in: 7 days
# -- Docker Stage --
.docker:
stage: docker
image: docker:29.8.2
services:
- docker:29.8.2-dind
before_script:
- printf '%s' "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
cache: []
dependencies: []
docker-build:
extends: .docker
resource_group: image-$CI_COMMIT_REF_SLUG
script:
# ponytail: GitLab slugs truncate at 63 bytes; add a branch-name hash if colliding names are needed.
- docker build -t "$CI_REGISTRY_IMAGE:branch-$CI_COMMIT_REF_SLUG" -t "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA"
- docker push "$CI_REGISTRY_IMAGE:branch-$CI_COMMIT_REF_SLUG"
rules:
- if: $CI_COMMIT_BRANCH
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_EVENT_TYPE == "detached" && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_ID
docker-release:
extends: .docker
interruptible: false
resource_group: stable-image
script:
- docker pull "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA"
- docker tag "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker tag "$CI_REGISTRY_IMAGE:sha-$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:latest"
- docker push "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_TAG
@@ -6,9 +6,9 @@
**Status:** ready-for-agent
**GitLab:** [#61](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/61)
**Gitea:** [#61](https://gitea.nessar.fr/nessar/vac-optimizer/issues/61)
Source : [issue #13](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/13), entretien et spécification de développement. Respecter le glossaire du domaine et les ADR 0002, 0005, 0006 et 0007.
Source : [issue #13](https://gitea.nessar.fr/nessar/vac-optimizer/issues/13), entretien et spécification de développement. Respecter le glossaire du domaine et les ADR 0002, 0005, 0006 et 0007.
- [ ] Un mode explicite « Saisir les jours déjà posés » présente l’horizon actuellement choisi et permet de sélectionner CP, RTT ou Autre. Autre couvre toutes les autres raisons de ne pas travailler ; ANY reste uniquement un sélecteur de règle.
- [ ] Un clic sur une date admissible ajoute le type sélectionné ; un deuxième clic avec ce type supprime la déclaration ; un clic avec un autre type la remplace. Une date porte au maximum une déclaration.
@@ -4,13 +4,13 @@
**Blocked by:**
- 01 — Saisir, sauvegarder et figer les jours déjà posés ([#61](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/61)).
- 01 — Saisir, sauvegarder et figer les jours déjà posés ([#61](https://gitea.nessar.fr/nessar/vac-optimizer/issues/61)).
**Status:** ready-for-agent
**GitLab:** [#62](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/62)
**Gitea:** [#62](https://gitea.nessar.fr/nessar/vac-optimizer/issues/62)
Source : [issue #13](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/13), entretien et spécification de développement. Respecter les définitions de période de repos, gain en jours non travaillés, séquence de travail et semaine de jour férié du glossaire, ainsi que les ADR 0006 et 0007.
Source : [issue #13](https://gitea.nessar.fr/nessar/vac-optimizer/issues/13), entretien et spécification de développement. Respecter les définitions de période de repos, gain en jours non travaillés, séquence de travail et semaine de jour férié du glossaire, ainsi que les ADR 0006 et 0007.
- [ ] Un vendredi ordinaire déclaré Autre, sans solde ni règle, produit une période vendredi–dimanche de trois jours. Cette période existe sans nouveau placement et ne provoque aucun débit.
- [ ] Les déclarations, nouveaux placements, week-ends et jours fériés adjacents forment des périodes contiguës fusionnées. Chaque date contribue une seule fois à leur durée ; l’origine et le type des déclarations restent distincts des placements.
@@ -4,13 +4,13 @@
**Blocked by:**
- 01 — Saisir, sauvegarder et figer les jours déjà posés ([#61](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/61)).
- 01 — Saisir, sauvegarder et figer les jours déjà posés ([#61](https://gitea.nessar.fr/nessar/vac-optimizer/issues/61)).
**Status:** ready-for-agent
**GitLab:** [#63](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/63)
**Gitea:** [#63](https://gitea.nessar.fr/nessar/vac-optimizer/issues/63)
Source : [issue #13](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/13), entretien et spécification de développement. Respecter les quantités, fenêtres inclusives, obligations conjointes, priorités et contributions partagées du glossaire, ainsi que l’ADR 0007.
Source : [issue #13](https://gitea.nessar.fr/nessar/vac-optimizer/issues/13), entretien et spécification de développement. Respecter les quantités, fenêtres inclusives, obligations conjointes, priorités et contributions partagées du glossaire, ainsi que l’ADR 0007.
- [ ] Dans une semaine ordinaire, avec quatre CP restants, un mercredi déclaré CP et une obligation de cinq CP du lundi au vendredi, le calcul ajoute quatre CP et débite uniquement ces quatre jours.
- [ ] Les déclarations compatibles contribuent aux obligations exactes et aux préférences demandant jusqu’à une quantité donnée, dans l’intersection entre la fenêtre inclusive de la règle et l’horizon soumis. Une déclaration extérieure à ces limites ne contribue pas à la règle.
@@ -4,14 +4,14 @@
**Blocked by:**
- 02 — Optimiser les périodes de repos autour des jours déclarés ([#62](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/62)).
- 03 — Compter les jours déclarés dans les règles de période ([#63](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/63)).
- 02 — Optimiser les périodes de repos autour des jours déclarés ([#62](https://gitea.nessar.fr/nessar/vac-optimizer/issues/62)).
- 03 — Compter les jours déclarés dans les règles de période ([#63](https://gitea.nessar.fr/nessar/vac-optimizer/issues/63)).
**Status:** ready-for-agent
**GitLab:** [#64](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/64)
**Gitea:** [#64](https://gitea.nessar.fr/nessar/vac-optimizer/issues/64)
Source : [issue #13](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/13), entretien et spécification de développement. Respecter les occurrences et modes IF_THEN, les droits acquis et l’ordre des objectifs du glossaire, ainsi que les ADR sur les artefacts du même calcul et l’ADR 0007.
Source : [issue #13](https://gitea.nessar.fr/nessar/vac-optimizer/issues/13), entretien et spécification de développement. Respecter les occurrences et modes IF_THEN, les droits acquis et l’ordre des objectifs du glossaire, ainsi que les ADR sur les artefacts du même calcul et l’ADR 0007.
- [ ] Les motifs IF_THEN utilisent les véritables jours fériés français nationaux et les modes before, after, around et auto existants. Une déclaration ne crée pas une occurrence de jour férié et ne permet pas de déplacer le motif hors de ses limites admissibles.
- [ ] Une déclaration compatible occupe sa position normale dans le motif et réduit le nombre de nouveaux placements nécessaires. Une déclaration incompatible ne peut être retypée ou déplacée, ni être sautée pour reporter la position demandée sur un jour plus éloigné. Les obligations restent obligatoires pour chaque occurrence ; les préférences conservent leur exigence de motif complet et leurs priorités.
@@ -6,9 +6,9 @@
**Status:** ready-for-agent
**GitLab:** [#65](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/65)
**Gitea:** [#65](https://gitea.nessar.fr/nessar/vac-optimizer/issues/65)
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), in milestone %3: V1 stats. Follow the domain glossary and ADRs 0002, 0004, 0006, and 0007.
Source: the agreed Plan Statistics Popup spec for [#3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [#29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), in milestone V1 stats. Follow the domain glossary and ADRs 0002, 0004, 0006, and 0007.
- [ ] A labelled calendar control opens one statistics popup for the latest calculated valid plan. Opening it runs no optimization and changes no planning data.
- [ ] The popup shows the number of leave placement dates with an explicit leave-day label. Declared non-working days consume no balance and are excluded from this count. A valid plan with no placements displays 0 and still permits opening the popup.
@@ -5,13 +5,13 @@
**Blocked by:**
- 01 — Open plan statistics for calculated leave usage.
- [#62 — Optimiser les périodes de repos autour des jours déclarés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/62), which establishes time-off periods and work sequences that include declared non-working days.
- [#62 — Optimiser les périodes de repos autour des jours déclarés](https://gitea.nessar.fr/nessar/vac-optimizer/issues/62), which establishes time-off periods and work sequences that include declared non-working days.
**Status:** ready-for-agent
**GitLab:** [#66](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/66)
**Gitea:** [#66](https://gitea.nessar.fr/nessar/vac-optimizer/issues/66)
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including [#7](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/7). The existing longest work-sequence metric from #28 is reused. Follow the domain glossary and ADRs 0004, 0006, and 0007.
Source: the agreed Plan Statistics Popup spec for [#3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [#29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), including [#7](https://gitea.nessar.fr/nessar/vac-optimizer/issues/7). The existing longest work-sequence metric from #28 is reused. Follow the domain glossary and ADRs 0004, 0006, and 0007.
- [ ] Add five labelled metrics: non-working-day gain, average work-sequence length, longest work-sequence length, average time-off period length, and longest time-off period length. Work lengths use working days; break lengths use calendar days.
- [ ] The work-sequence average includes every non-empty sequence inside the submitted horizon, including both edges. Weekends and French public holidays neither count nor split a sequence; leave placements and declared non-working days interrupt it. Consecutive days off introduce no zero-length samples.
@@ -5,13 +5,13 @@
**Blocked by:**
- 01 — Open plan statistics for calculated leave usage.
- [#64 — Appliquer les motifs IF_THEN et les droits acquis aux jours déclarés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/64), which establishes finalized earned entitlements and the fixed Europe/Paris calculation-date cutoff.
- [#64 — Appliquer les motifs IF_THEN et les droits acquis aux jours déclarés](https://gitea.nessar.fr/nessar/vac-optimizer/issues/64), which establishes finalized earned entitlements and the fixed Europe/Paris calculation-date cutoff.
**Status:** ready-for-agent
**GitLab:** [#67](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/67)
**Gitea:** [#67](https://gitea.nessar.fr/nessar/vac-optimizer/issues/67)
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including [#8](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/8). Follow the domain glossary and ADRs 0004, 0006, and 0007.
Source: the agreed Plan Statistics Popup spec for [#3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [#29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), including [#8](https://gitea.nessar.fr/nessar/vac-optimizer/issues/8). Follow the domain glossary and ADRs 0004, 0006, and 0007.
- [ ] Display gross earned leave from every applicable generating rule, including Samedi Malin, with a per-rule breakdown whose quantities sum to the total.
- [ ] A plan earning 3 days and consuming 2 reports 3 earned days. Fully consumed earned entitlements remain included even when absent from remaining balances; generating a future entitlement does not require consuming it inside the horizon.
@@ -5,13 +5,13 @@
**Blocked by:**
- 01 — Open plan statistics for calculated leave usage.
- [#62 — Optimiser les périodes de repos autour des jours déclarés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/62), which provides shared time-off periods including declared non-working days and adjacent context dates.
- [#62 — Optimiser les périodes de repos autour des jours déclarés](https://gitea.nessar.fr/nessar/vac-optimizer/issues/62), which provides shared time-off periods including declared non-working days and adjacent context dates.
**Status:** ready-for-agent
**GitLab:** [#68](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/68)
**Gitea:** [#68](https://gitea.nessar.fr/nessar/vac-optimizer/issues/68)
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including [#9](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/9). Follow the domain glossary and ADRs 0004, 0006, and 0007.
Source: the agreed Plan Statistics Popup spec for [#3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [#29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), including [#9](https://gitea.nessar.fr/nessar/vac-optimizer/issues/9). Follow the domain glossary and ADRs 0004, 0006, and 0007.
- [ ] Show the number of distinct Monday-to-Friday French public-holiday dates in planned time-off periods containing at least one Saturday or Sunday. Each qualifying period contains at least one leave placement or declared non-working day.
- [ ] Count direct Monday/Friday connections and connections through leave, such as a Tuesday holiday joined to a weekend by Monday off. Declared non-working days can provide the same connection without consuming a balance.
@@ -5,13 +5,13 @@
**Blocked by:**
- 01 — Open plan statistics for calculated leave usage.
- [#61 — Saisir, sauvegarder et figer les jours déjà posés](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/61), which carries fixed declared non-working days with the producing calculation and its outcomes.
- [#61 — Saisir, sauvegarder et figer les jours déjà posés](https://gitea.nessar.fr/nessar/vac-optimizer/issues/61), which carries fixed declared non-working days with the producing calculation and its outcomes.
**Status:** ready-for-agent
**GitLab:** [#69](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/69)
**Gitea:** [#69](https://gitea.nessar.fr/nessar/vac-optimizer/issues/69)
Source: the agreed Plan Statistics Popup spec for [#3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [#29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), including the clarification that working only one day in a full week is undesirable. Follow the domain glossary and ADRs 0004, 0006, and 0007.
Source: the agreed Plan Statistics Popup spec for [#3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [#29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), including the clarification that working only one day in a full week is undesirable. Follow the domain glossary and ADRs 0004, 0006, and 0007.
- [ ] Count Monday-to-Sunday calendar weeks whose five Monday-to-Friday dates all lie inside the submitted half-open horizon and that contain exactly one remaining working day after excluding French public holidays, leave placements, and declared non-working days.
- [ ] Four weekdays off and one remaining workday qualify. Weeks with zero or two remaining workdays do not qualify, including two-workday weeks containing separate one-day work sequences.
@@ -1,6 +1,6 @@
# 01 — Unifier l’évaluation et la comparaison des objectifs
**GitLab:** [#70](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/70)
**Gitea:** [#70](https://gitea.nessar.fr/nessar/vac-optimizer/issues/70)
**What to build:** Le planificateur conserve ses résultats actuels pendant une préparation qui rend les six objectifs évaluables à partir de configurations de règles. Le contrat commun permet ensuite aux règles fournies et aux règles personnelles d’utiliser les mêmes mesures, sans dépendre de leur nom ou de leur identité.
@@ -1,6 +1,6 @@
# 02 — Piloter la prévention des pertes depuis les règles
**GitLab:** [#71](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/71)
**Gitea:** [#71](https://gitea.nessar.fr/nessar/vac-optimizer/issues/71)
**What to build:** L’utilisateur crée une règle d’optimisation de prévention des pertes, l’enregistre, la déplace parmi ses préférences et constate son effet sur les congés placés et les soldes restants. La désactiver retire réellement sa priorité automatique.
@@ -1,6 +1,6 @@
# 03 — Composer les règles de semaines fériées et de repos
**GitLab:** [#72](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/72)
**Gitea:** [#72](https://gitea.nessar.fr/nessar/vac-optimizer/issues/72)
**What to build:** L’utilisateur crée et règle les préférences « Compléter les semaines avec un jour férié » et « Profiter des week-ends et jours fériés », puis choisit laquelle prime dans le planning et dans ses explications.
@@ -1,6 +1,6 @@
# 04 — Composer les règles de séquences de travail
**GitLab:** [#73](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/73)
**Gitea:** [#73](https://gitea.nessar.fr/nessar/vac-optimizer/issues/73)
**What to build:** L’utilisateur compose des préférences sur ses séquences de travail : réduire la plus longue séquence ou limiter le déficit des reprises courtes, avec un seuil configurable. Leur position parmi les autres préférences influence le planning.
@@ -1,6 +1,6 @@
# 05 — Piloter l’économie des jours d’appoint
**GitLab:** [#74](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/74)
**Gitea:** [#74](https://gitea.nessar.fr/nessar/vac-optimizer/issues/74)
**What to build:** L’utilisateur ajoute une préférence « Économiser les jours d’appoint » et décide, par sa position, jusqu’où les améliorations du planning peuvent consommer des congés qui expirent après l’horizon.
@@ -1,26 +1,34 @@
# 06 — Migrer les règles sauvegardées et valider l’ensemble
# 06 — Migrer les règles sauvegardées et valider le comportement fonctionnel
**GitLab:** [#75](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/75)
**Gitea:** [#75](https://gitea.nessar.fr/nessar/vac-optimizer/issues/75)
**What to build:** Au prochain chargement, l’utilisateur retrouve ses règles et les six anciens comportements du moteur sous forme de préréglages actifs. Il peut ensuite les modifier ou les supprimer durablement, et l’ensemble est validé avant sa mise en service.
Blocked by: #74
**Blocked by:** 05 — Piloter l’économie des jours d’appoint.
Les exigences de performance sont transférées à #78. La clôture de ce ticket dépend de la migration et de la validation fonctionnelle, pas du passage du protocole de performance. Les limites du benchmark restent inchangées. À la demande de l’utilisateur, son job CI est séparé et non bloquant tant que #78 est ouvert ; #78 doit rétablir ce contrôle obligatoire après réussite du protocole. Les tests fonctionnels et les autres contrôles CI restent obligatoires.
**Status:** ready-for-agent
## What to build
Livraison intégrée de l’ADR 0008 sur la branche commune aux tickets 01 à 05. La préservation des données enregistrées et la migration unique ont été explicitement approuvées : ne pas réinitialiser les données pour simplifier le changement de contrat.
Au prochain chargement, l’utilisateur retrouve ses règles et les six anciens comportements du moteur sous forme de préréglages actifs. Il peut ensuite les modifier ou les supprimer durablement, et l’ensemble est validé avant sa mise en service.
- [ ] À l’initialisation, fournir les six préréglages actifs avec cet ordre : prévention des pertes, règles personnelles ou règles initiales existantes dans leur ordre, semaines fériées, gain de jours non ouvrés, séquence maximale, déficit des reprises et économie des placements d’appoint.
- [ ] Migrer une seule fois les listes enregistrées avant cette évolution, y compris une ancienne liste vide. Préserver intégralement l’identité, le nom, les paramètres, les dates, la force, l’activation et l’ordre relatif des règles existantes.
- [ ] Enregistrer de façon cohérente les règles migrées et le fait que la migration a eu lieu. Ne pas considérer l’opération terminée si les règles n’ont pas été sauvegardées ; gérer les échecs de stockage sans écraser les données utilisateur valides.
- [ ] Les chargements suivants ne dupliquent pas les préréglages, ne rétablissent pas ceux supprimés et ne réactivent pas ceux désactivés. Une liste vidée volontairement après migration reste vide. Les nouveaux utilisateurs reçoivent directement la nouvelle configuration initiale.
- [ ] La restauration et la migration précèdent le premier calcul utilisant ces données. L’utilisateur peut immédiatement éditer les six configurations avec les mêmes commandes que ses règles personnelles et retrouver ses choix après rechargement.
- [ ] Vérifier de bout en bout les six préréglages dans l’ordre initial, leurs réordonnancements et leurs désactivations. Une configuration personnelle différente d’un préréglage traverse édition, sauvegarde, worker, calcul et explication ; les paramètres ont un effet vérifiable.
- [ ] La liste entièrement désactivée, la liste supprimée après migration et une requête directe sans règle active n’ajoutent aucun congé malgré des soldes à risque. Les jours déclarés, les quantités restantes et les raisons pertinentes restent cohérents.
- [ ] Les obligations conjointes, les conditions des congés gagnés et les contraintes de validité sont respectées pour tout ordre des préférences. Une annulation restitue le meilleur plan valide disponible et sa trace originale selon la configuration soumise.
- [ ] Les explications et analyses contrefactuelles utilisent le même ordre actif que le calcul. Modifier les règles durant le calcul ou pendant la livraison tardive de la trace ne change pas ses résultats. Les anciens objectifs implicites n’interviennent plus dans aucune comparaison ou protection.
- [ ] Les fixtures et scénarios de benchmark qui dépendaient d’une optimisation implicite demandent explicitement les préréglages nécessaires. Les tests d’absence de règles n’utilisent aucun défaut implicite pour retrouver l’ancien résultat.
- [ ] Exécuter les suites concernées de règles, interface, moteur, worker réel, explication, annulation et estimation de durée, ainsi que les contrôles habituels de compilation et de qualité. Vérifier les scénarios de benchmark existants, dont les horizons longs, pour le déterminisme, la réactivité et les durées, sans introduire de délai d’arrêt automatique.
- [ ] La vérification commune des tickets 01 à 06 est la condition de mise en service. Documenter les choix finaux du contrat composable et les limites mesurées ; ne laisser ni double moteur, ni chemin historique implicite, ni adaptateur provisoire ajouté pour rendre les étapes indépendamment livrables.
Livraison intégrée de l’ADR 0008 sur la branche commune aux tickets #70, #71, #72, #73, #74. La préservation des données enregistrées et la migration unique ont été explicitement approuvées : ne pas réinitialiser les données pour simplifier le changement de contrat.
## Acceptance criteria
- [x] À l’initialisation, fournir les six préréglages actifs avec cet ordre : prévention des pertes, règles personnelles ou règles initiales existantes dans leur ordre, semaines fériées, gain de jours non ouvrés, séquence maximale, déficit des reprises et économie des placements d’appoint.
- [x] Migrer une seule fois les listes enregistrées avant cette évolution, y compris une ancienne liste vide. Préserver intégralement l’identité, le nom, les paramètres, les dates, la force, l’activation et l’ordre relatif des règles existantes.
- [x] Enregistrer de façon cohérente les règles migrées et le fait que la migration a eu lieu. Ne pas considérer l’opération terminée si les règles n’ont pas été sauvegardées ; gérer les échecs de stockage sans écraser les données utilisateur valides.
- [x] Les chargements suivants ne dupliquent pas les préréglages, ne rétablissent pas ceux supprimés et ne réactivent pas ceux désactivés. Une liste vidée volontairement après migration reste vide. Les nouveaux utilisateurs reçoivent directement la nouvelle configuration initiale.
- [x] La restauration et la migration précèdent le premier calcul utilisant ces données. L’utilisateur peut immédiatement éditer les six configurations avec les mêmes commandes que ses règles personnelles et retrouver ses choix après rechargement.
- [x] Vérifier de bout en bout les six préréglages dans l’ordre initial, leurs réordonnancements et leurs désactivations. Une configuration personnelle différente d’un préréglage traverse édition, sauvegarde, worker, calcul et explication ; les paramètres ont un effet vérifiable.
- [x] La liste entièrement désactivée, la liste supprimée après migration et une requête directe sans règle active n’ajoutent aucun congé malgré des soldes à risque. Les jours déclarés, les quantités restantes et les raisons pertinentes restent cohérents.
- [x] Les obligations conjointes, les conditions des congés gagnés et les contraintes de validité sont respectées pour tout ordre des préférences. Une annulation restitue le meilleur plan valide disponible selon la configuration soumise. La suppression de la trace originale est suivie séparément dans #76.
- [x] Les explications et analyses contrefactuelles utilisent le même ordre actif que le calcul. Modifier les règles durant le calcul ou pendant la livraison des explications ne change pas ses résultats. Les anciens objectifs implicites n’interviennent plus dans aucune comparaison ou protection.
- [x] Les fixtures et scénarios de benchmark qui dépendaient d’une optimisation implicite demandent explicitement les préréglages nécessaires. Les tests d’absence de règles n’utilisent aucun défaut implicite pour retrouver l’ancien résultat.
- [x] Exécuter les suites fonctionnelles concernées de règles, interface, moteur, worker réel, explication, annulation et estimation de durée, ainsi que les contrôles habituels de compilation et de qualité. Préserver les assertions de résultat et de déterminisme des fixtures, y compris les horizons longs. L’exécution complète du protocole de benchmark, la réactivité mesurée, les durées, la consommation mémoire et les optimisations nécessaires relèvent de #78 et ne conditionnent plus la clôture de #75. Ne pas introduire de délai d’arrêt automatique.
- [x] La vérification fonctionnelle commune des tickets #70, #71, #72, #73, #74, #75 est la condition de clôture fonctionnelle de ce ticket. Documenter les choix finaux du contrat composable ; ne laisser ni double moteur, ni chemin historique implicite, ni adaptateur provisoire ajouté pour rendre les étapes indépendamment livrables. Les limites de performance mesurées et leur amélioration sont suivies dans #78 ; les tests fonctionnels et les autres contrôles CI restent obligatoires, avec la seule exception temporaire du job de benchmark décrite ci-dessus.
**Vérification de bout en bout :** Partir d’une ancienne sauvegarde contenant des règles personnalisées et désactivées, charger la nouvelle version, vérifier le premier calcul, modifier ou supprimer des préréglages, recharger et constater la conservation exacte des choix.
## Blocked by
- #74 — Piloter l’économie des jours d’appoint
@@ -0,0 +1,29 @@
# 01 — Remove the original decision trace end to end
**Gitea:** [#76](https://gitea.nessar.fr/nessar/vac-optimizer/issues/76)
## What to build
Calculations no longer record, retain or deliver an original decision trace. Users receive the displayed plan, its stable explanation and remaining-balance reasons, and can still analyze an alternative date for a displayed placement. The advanced trace explorer, historical calendar replay and historical attempt lists disappear completely.
This is one cohesive contract replacement across the planner, calculation service, workers and UI. Use a scoped integration branch containing the existing #70–#75 implementation work; #75's final verification must not be treated as a prerequisite for removing tracing. The shared functional integration verification gate is #77 — Verify integrated planning after trace removal. Benchmark performance acceptance and any required optimization are owned by #78; performance is no longer a completion gate for #75.
## Acceptance criteria
- [ ] Delete trace capture, materialization, candidate-history retention, replay, historical eligibility and caches or evidence pools used exclusively for tracing. Remove observational work while preserving search breadth, allocation previews, rule priority and incumbent selection.
- [ ] Replace trace contracts directly and update all callers in the same change. Remove trace promises, callbacks, public types, streaming messages, status indexes, acknowledgements and backpressure queues across real-worker, browser-fallback and server-side calculation paths. Do not retain debug toggles, compatibility adapters, empty trace results or replacement history.
- [ ] Preserve immutable calculation, valid-incumbent and final-state snapshots needed by retained behavior. Rename retained state contracts independently of tracing; do not delete cancellation or explanation data because it currently has a trace-related name.
- [ ] Retain stable plan explanations, including placement entitlement and rule provenance, objective contributions and the first decisive objective, time-off periods, remaining balances and concrete non-placement reasons. Preserve invalid-input and conflict explanations and incomplete-calculation behavior.
- [ ] Keep alternative-date analysis for a displayed placement using the captured calculation configuration, the same entitlement, leave type and provenance, and fixed unrelated placements. Remove analysis based on historical decisions; hypothetical analysis never mutates the displayed plan.
- [ ] Remove the advanced trace panel, replay controls, historical calendar overlays and candidate statuses. Remove remaining-balance historical attempt lists, pagination and loading state while retaining classification, validity, earned-entitlement source and truthful displayed-plan reasons. Leave no dead controls or trace-unavailable messages.
- [ ] Preserve explicit cancellation before and after a valid incumbent, best-valid-partial-plan recovery, heartbeat monitoring, stale-response rejection and the existing technical-budget contract. Interrupted calculations do not imply an obligation conflict. Explanation delivery failures do not discard an available outcome, and planning workers do not wait for trace acknowledgements after required delivery.
- [ ] Preserve completed placements, balances, objective values, joint obligation satisfaction, earned-entitlement chronology, deterministic tie-breaking, user-controlled priorities and empty-active-rule behavior. Do not reset saved entitlements, declarations or rule configurations or change the existing migration.
- [ ] Update component styles and translations across supported languages, preserving keyboard access and accessibility for retained explanations, calendar interactions and date analysis.
- [ ] Adapt existing planner, service, worker, explanation, cancellation, calendar and remaining-balance tests. Delete tests exclusively for tracing while retaining assertions for supported behavior in mixed tests. Update benchmark callers, output and assertions to remove obsolete trace requirements without reducing scenarios or changing performance gates.
- [ ] Supersede trace-specific requirements in the domain vocabulary and explanation/cancellation ADRs, and update benchmark documentation. Preserve the remaining planning contracts and document that the original decision trace and historical analysis are removed.
No new dependency, replacement telemetry, optimization-quality change, allocation-preview reduction or benchmark deadline relaxation is part of this ticket. No separate prefactor is required: retained explanation and hypothetical-analysis behavior already exists.
## Blocked by
None — can start immediately.
@@ -0,0 +1,27 @@
# 02 — Verify integrated planning after trace removal
**Gitea:** [#77](https://gitea.nessar.fr/nessar/vac-optimizer/issues/77)
Blocked by: #76
## What to build
Demonstrate that the integrated #70–#75 work retains its supported behavior after trace removal. Provide a functionally verified trace-free baseline for #78, which now owns benchmark performance acceptance and optimization. Benchmark performance is no longer a completion gate for #75 or this functional verification ticket.
## Acceptance criteria
- [ ] Review the integrated contract replacement across all calculation paths and UI callers. Confirm there is no trace recorder, trace transport, acknowledgement wait, historical replay API or retained trace-dependent control or compatibility shell.
- [ ] Compare completed outcomes and stable explanations against existing deterministic fixtures, including typed joint obligations, earned-entitlement chains, custom objective formulas, reordered priorities, two- and three-year horizons and no active optimization rules. Do not rewrite placement expectations to hide a behavioral regression.
- [ ] Verify real-worker outcome and explanation delivery, heartbeat monitoring, stale-response rejection and explanation failure handling. Exercise cancellation before any valid incumbent and after a valid incumbent, and confirm that partial outcomes remain valid without false conflicts.
- [ ] Verify displayed-placement alternative-date analysis and remaining-balance reasons without historical data. Check that captured settings remain immutable and hypothetical alternatives do not change the displayed plan.
- [ ] Run the existing full correctness, coverage, static, formatting and build checks appropriate to the integrated change, and resolve regressions within the trace-removal scope. Preserve the repository's required thresholds.
- [ ] Complete browser QA for the retained explanation, calendar, cancellation and alternative-date flows, including keyboard interaction and a narrow viewport. Confirm the advanced trace explorer, historical overlays and historical attempt controls are absent.
- [ ] Verify that benchmark callers use the supported trace-free calculation contract and retain their correctness, determinism, horizon, placement-count and completed-stage assertions. Full protocol execution, the unchanged 2 GB/300-second performance gates and any required optimization belong to #78.
- [ ] Hand off the functionally verified integrated baseline and unchanged benchmark inputs to #78. Verify retained outcome and explanation delivery without waiting for removed trace artifacts or inventing replacement trace counters.
- [ ] Keep the original #70–#75 dependency order and report the functional integration result. Apply #75’s revised functional completion criteria: a benchmark performance failure alone does not block #75 or this ticket. Outstanding benchmark requirements remain open in #78, without deadline relaxation or an automatic engine cutoff.
The removal is agreed; its measured speedup is unresolved and is now evaluated in #78. This ticket owns functional verification after trace removal. It does not itself implement performance optimizations or close other issues.
## Blocked by
- #76 — Remove the original decision trace end to end (https://gitea.nessar.fr/nessar/vac-optimizer/issues/76).
@@ -0,0 +1,39 @@
# 03 — Verify and optimize planning to meet the benchmarks from #75
**Gitea:** [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78)
Blocked by: #75, #77
## Parent
#75 — Performance acceptance separated from the functional migration ticket at the user's request.
## What to build
Verify the integrated planner against the benchmark requirements originally attached to #75 and apply performance optimizations when needed until those requirements pass. Start from the functionally verified implementation after original-decision-trace removal. #75 can finish on its functional requirements; performance acceptance belongs to this ticket.
If the measured baseline already passes, keep the engine unchanged and record the evidence. Otherwise profile the real failing path, apply the smallest complete improvement and verify both performance and retained planning behavior.
## Acceptance criteria
- [ ] Keep the existing seven benchmark scenarios and inputs, including the loaded year, typed joint obligations, chained earned entitlements and two- and three-year horizons. Keep explicit optimization configurations, quotas, expected outcomes, placement counts, completed-stage and determinism assertions.
- [ ] Run the real existing benchmark protocol: one warm-up and two measured repetitions per scenario, outside coverage instrumentation, with a 2 GB Node heap limit and the 300-second timeout for the full protocol. All scenarios and repetitions must finish and pass; a timeout, skipped scenario or partial report does not satisfy this ticket.
- [ ] Restore the benchmark as a required CI check after the unchanged protocol passes. While this ticket is open, the user approved a separate automatic benchmark job with `allow_failure: true` so its failure does not block functional delivery. Passing CI during this exception does not satisfy benchmark acceptance or justify closing this ticket.
- [ ] After #76 removes tracing and #77 verifies the retained behavior, benchmark outcome and explanation delivery without obsolete trace counters, trace transfer or trace-acknowledgement waits. Do not reintroduce tracing as profiling infrastructure.
- [ ] When the baseline fails, profile CPU, allocation and repeated planning work; apply optimizations and rerun the unchanged protocol until it passes. Preserve planning semantics, rule ordering, joint obligations, earned-entitlement chronology, final-plan quality and deterministic tie-breaking. Use behavior-level or differential tests to demonstrate that caching, deduplication or pruning is valid across the relevant configurations.
- [ ] Preserve responsive browser interaction, explicit cancellation, heartbeat monitoring and best-valid-partial-plan recovery during representative and long-horizon calculations. Keep the existing duration-estimation contract and recalibrate estimates only when measured results require it.
- [ ] Do not increase the benchmark timeout or heap allowance, reduce scenarios or repetitions, weaken correctness assertions, add an automatic elapsed-time engine cutoff, or reduce search breadth or planning quality to make the protocol pass.
- [ ] Record reproducible before/after preparation, planning, total-duration, responsiveness and available peak-heap measurements, including environment and protocol. Clearly distinguish actual completed benchmark runs from diagnostics or interrupted measurements and document any reference-hardware checks that could not be performed.
- [ ] Run required correctness, coverage, static and build checks after any optimization. Preserve the captured configuration, stable explanations, displayed-placement alternative-date analysis and saved-user-data behavior.
- [ ] Update benchmark documentation with the final measured limits and evidence. Close this ticket only after the unchanged benchmark gates pass; a failed measurement remains an open performance requirement here rather than reopening #75 for performance alone.
## Existing evidence and scope
The previous real protocol timed out at 300 seconds without an out-of-memory failure. The loaded-year warm-up took about 184 seconds and its first measured repetition started about 252 seconds into the protocol. Original trace recording and repeated allocation previews were observed costs; the benefit of trace removal has not yet been measured. These are starting points for profiling, not permission to change optimization quality.
This ticket authorizes verification and implementation of performance improvements when needed. Functional migration remains in #75, trace removal in #76 and trace-removal functional verification in #77. Existing benchmark limits remain unchanged; only the benchmark job's blocking status is temporarily waived at the user's request until this ticket restores it.
## Blocked by
- #75 — Finish the functional saved-rule migration and integrated planning validation.
- #77 — Verify integrated planning after trace removal (itself blocked by #76).
+8 -6
View File
@@ -2,7 +2,7 @@
### Issue tracker
Issues and specs live in GitLab Issues; use `glab` for tracker operations. See `docs/agents/issue-tracker.md`.
Issues and specs live in Gitea Issues; use `tea-cli` for supported tracker operations. Before tracker work, read `docs/agents/issue-tracker.md` for commands and API alternatives.
### Triage labels
@@ -14,11 +14,13 @@ This is a single-context repo using root `CONTEXT.md` and `docs/adr/`. See `docs
## GitFlow
- `develop` is the default integration branch; `main` contains releases. Start `feature/*` branches from the current `origin/develop` and target their MRs to `develop`.
- Rebase working branches onto the latest `origin/develop` before opening or merging an MR. If `develop` advances, rebase and rerun the MR pipeline before merging.
- Integrate through MRs with successful pipelines and Maintainer-only merges. Use merge commits (`--no-ff`) with squashing disabled. Direct and forced pushes to `main` and `develop` are forbidden.
- Use Conventional Commits for new commits and MR titles.
- Create `release/X.Y.Z` from `develop` and `hotfix/X.Y.Z` from `main`. Only `release/*` and `hotfix/*` MRs target `main`. Merge each release or hotfix into both `main` and `develop`, keeping its branch until both MRs are merged.
- `develop` is the default integration branch; `main` contains releases. Start `feature/*` branches from the current `origin/develop` and target their PRs to `develop`.
- Rebase working branches onto the latest `origin/develop` before opening or merging a PR. If `develop` advances, rebase and rerun the PR checks before merging.
- Integrate through PRs with successful required checks and merges restricted to designated maintainers through Gitea's merge allowlist. Use merge commits (`--no-ff`) with squashing disabled. Direct and forced pushes to `main` and `develop` are forbidden.
- Use Conventional Commits for new commits and PR titles.
- Create `release/X.Y.Z` from `develop` and `hotfix/X.Y.Z` from `main`. Only `release/*` and `hotfix/*` PRs target `main`. Merge each release or hotfix into both `main` and `develop`, keeping its branch until both PRs are merged.
- Use merges and back-merges for releases and hotfixes. Never rebase `main`, `develop`, or a frozen release branch.
The CI workflow is `.gitea/workflows/ci.yml`. Activation is tracked in Gitea issue #268; Actions is enabled with owner approval, and the migration gate stays required until live workflow validation and merge-message review. Before changing CI, image publication, or activation settings, read [Gitea Actions](docs/gitea-actions.md).
Before preparing a release or hotfix, creating a tag, or changing image publication, read [GitFlow and releases](README.md#gitflow-and-releases) for package versions, explicit stable tags, and image promotion.
+22 -19
View File
@@ -2,6 +2,8 @@
VacOptimizer is a personal leave planner for turning dated leave balances into a deterministic plan around weekends and French public holidays. It handles mandatory rules, ordered preferences, earned leave, expiring balances, and interrupted calculations without relying on a backend solver.
Source code, issues, and pull requests are hosted on [Gitea](https://gitea.nessar.fr/nessar/vac-optimizer). The [issue-tracker guide](docs/agents/issue-tracker.md) documents `tea-cli` access and tracker conventions.
The planner answers these questions:
- Is the request valid?
@@ -164,7 +166,7 @@ The trace explorer navigates original phases and atomic decisions, expands candi
- Vitest through the Angular test builder
- ESLint and Prettier
- Angular server build hosted by Express
- GitLab CI and a multi-stage Docker image
- Gitea repository hosting and a multi-stage Docker image
The `/benchmark` route and planner core are lazy-loaded, so the normal application does not execute the benchmark or bundle the planner core into its main entry point.
@@ -236,35 +238,34 @@ The image builds the Angular application with Bun and Node, then runs the genera
## Continuous integration
GitLab CI runs independent validation, test, build, security, and image stages:
[`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) defines Gitea Actions validation, functional coverage, production builds, security scans, and Docker image publication. [Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) tracks activation. Actions is enabled with owner approval; publisher credentials and live workflow validation are still pending. The [activation guide](docs/gitea-actions.md) records runner, credential, permission, and merge-gate requirements.
- ESLint, Prettier, and TypeScript checks;
- Vitest with coverage artifacts;
- an optimized production build;
- GitLab SAST and Trivy filesystem scanning;
- GitFlow, Conventional Commit, and release-version checks;
- Docker image publication for every successful branch push, including branches with an open MR.
The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, application/test type checks, Vitest coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
Pipelines switch to MR pipelines when a branch has an open MR, avoiding duplicate builds. Docker publication from MR pipelines is limited to detached pipelines within this project. Tag pipelines promote the already-built commit image instead of rebuilding it.
Functional tests use one worker and a 2 GiB Node heap. The separate benchmark job runs without coverage and keeps its five-minute protocol timeout. At the user's request, it has `continue-on-error: true` while [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) is open. #78 must restore the benchmark gate after the protocol passes. Passing functional checks during this exception does not establish benchmark acceptance.
The test job runs Vitest directly with one worker and a 2 GiB Node heap limit. It runs application tests with coverage, then the complete benchmark protocol separately without coverage instrumentation. The benchmark uses a five-minute execution timeout to allow for shared-runner contention; all protocol assertions remain mandatory. Timing measurements are benchmark evidence, not CI performance thresholds.
The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes and same-repository PRs. Registry credentials are confined to the publishing step.
Both protected branches still require `gitlab-to-gitea/ci-conversion-required` until the owner approves the first merge message and a successful Gitea PR run, then replaces the migration gate with the actual pull-request context for `required`. Local validation does not satisfy this status. Historical CI reports and artifacts remain in administrator rollback backups; the original configuration is retained in Git history.
### GitFlow and releases
`develop` is the default integration branch. Create `feature/*` branches from it, rebase them onto the current `origin/develop` before opening or merging their MR, and merge into `develop` with a merge commit (`--no-ff`). Existing working-branch names such as `feat/*` follow the same rules. Working branches cannot merge `develop` into themselves instead of rebasing.
`develop` is the default integration branch. Create `feature/*` branches from it, rebase them onto the current `origin/develop` before opening or merging their PR, and merge into `develop` with a merge commit (`--no-ff`). Existing working-branch names such as `feat/*` follow the same rules. Working branches cannot merge `develop` into themselves instead of rebasing.
Create `release/X.Y.Z` from `develop` when the release scope is ready. Set `package.json` to `X.Y.Z` on this branch, stabilize it, then merge it into `main` and back into `develop`. Keep the release branch until both MRs have merged. A `hotfix/X.Y.Z` starts from `main`, changes the package version, and follows the same two-target merge process. Do not rebase `main`, `develop`, or a frozen release onto ongoing development.
Create `release/X.Y.Z` from `develop` when the release scope is ready. Set `package.json` to `X.Y.Z` on this branch, stabilize it, then merge it into `main` and back into `develop`. Keep the release branch until both PRs have merged. A `hotfix/X.Y.Z` starts from `main`, changes the package version, and follows the same two-target merge process. Do not rebase `main`, `develop`, or a frozen release onto ongoing development.
Use Conventional Commits for new commits and MR titles, for example `feat(calendar): add planning periods`, `fix(planner): handle expired balances`, or `chore(release): prepare 1.6.0`. Scopes are optional; `!` marks a breaking change. GitLab generates merge messages from the validated MR title. Legacy history is not rewritten.
Use Conventional Commits for new commits and PR titles, for example `feat(calendar): add planning periods`, `fix(planner): handle expired balances`, or `chore(release): prepare 1.6.0`. Scopes are optional; `!` marks a breaking change. The merge commit subject must preserve the validated PR title. Gitea supports a [Git-based merge-message template](https://docs.gitea.com/usage/issues-prs/merge-message-templates/) at `.gitea/default_merge_message/MERGE_TEMPLATE.md`, using `${PullRequestTitle}` and `${PullRequestReference}`. The committed template and a checked example expansion are documented in the [activation guide](docs/gitea-actions.md); legacy history is not rewritten.
The image publisher implements this tag contract in `gitea.nessar.fr/nessar/vac-optimizer`:
| Trigger | Published image tags |
| --- | --- |
| Successful push to any branch | `branch-<GitLab ref slug>` and `sha-<full commit SHA>` |
| Successful push to any branch | `branch-<normalized branch slug>` and `sha-<full commit SHA>` |
| Explicit release tag `vX.Y.Z` | `vX.Y.Z` and `latest`, using the existing SHA image |
For example, `feature/calendar` publishes `branch-feature-calendar`; integration publishes `branch-develop`. GitLab normalizes ref slugs to lowercase and truncates them to 63 bytes, so choose branch names with distinct slugs. Only valid release tags can update `latest`.
For example, `feature/calendar` uses `branch-feature-calendar`; integration uses `branch-develop`. Preserve the previous slug normalization: lowercase the branch name, replace characters other than `a-z` and `0-9` with `-`, truncate to 63 bytes, and remove leading/trailing `-`. Choose branch names with distinct slugs. Only valid release tags can update `latest`. Automatic image publication and tag promotion begin after the owner provisions publisher credentials and validates publication permissions.
After the MR into `main` and its full pipeline have succeeded, tag the published main commit:
After owner activation, the PR into `main` has merged, and its full checks and SHA image publication have succeeded, tag the published main commit:
```bash
git fetch origin
@@ -272,11 +273,13 @@ git tag -a v1.6.0 origin/main -m "Release 1.6.0"
git push origin v1.6.0
```
The tag must match the checked-out `package.json` version, use stable `vMAJOR.MINOR.PATCH` syntax, and point to a commit reachable from `main`. Wait for the main pipeline before tagging: the release job requires its `sha-<commit>` image. Tags do not increment the version automatically.
The tag must match the tagged commit's `package.json` version, use stable `vMAJOR.MINOR.PATCH` syntax, and point to a commit reachable from `main`. Wait for the main checks and image publication before tagging: promotion requires its `sha-<commit>` image. Tags do not increment the version automatically.
GitLab project settings must use `develop` as the default branch, the **Merge commit** method, squashing disabled, and **Pipelines must succeed** with skipped pipelines disallowed. Protect `main` and `develop` with no direct or forced pushes and Maintainer-only merges. Set the merge commit template to `%{title}` followed by the MR reference, preserving Conventional Commit subjects. The CI permits only `release/*` and `hotfix/*` into `main`; working MRs target `develop`. Rebase checks validate the target head at pipeline time: if `develop` advances afterward, rebase and rerun the MR pipeline before merging.
The Gitea repository policy requires `develop` as the default branch and **Merge commit** as the merge method, with squash and rebase merge methods disabled. Configure [branch protection](https://docs.gitea.com/usage/access-control/protected-branches/) for `main` and `develop` to disable direct and forced pushes, restrict the merge allowlist to designated maintainers, require successful status checks, and block outdated PRs. Administrators must follow the protection rules. After merge-message approval and a successful reviewed PR run, replace the migration gate with the reported pull-request context for the `required` job, as described in the activation guide. These are required settings; the migration does not establish that every setting has already been applied.
Run the policy integration check locally with `node scripts/ci-policy.test.mjs`.
Only `release/*` and `hotfix/*` PRs may target `main`; working PRs target `develop`. CI validates these rules and checks working-branch rebases against the fetched integration head. If `develop` advances after validation, rebase and rerun the PR checks before merging.
Run `node scripts/ci-policy.test.mjs` and `node scripts/ci-image.test.mjs` locally to verify GitFlow event handling and image publication without writing to a registry. The adapter reads Gitea webhook data through the runner's `GITHUB_*` compatibility variables. Local checks do not satisfy the migration gate.
## Domain and design references
@@ -1,6 +1,6 @@
# User-Selected Horizons and Cancellable Calculations
Accepted for [issue #12](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/12). Fixed, saved planning horizons are implemented by [issue #57](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/57); user cancellation with a retained valid plan and original trace is implemented by [issue #58](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/58); removal of the automatic calculation deadline and heartbeat-based worker liveness are implemented by [issue #59](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/59). The indicative duration estimate is implemented by [issue #60](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/60); its initial model, session-local refinement and calendar-rendering measurement are documented in [planning-duration.md](../planning-duration.md).
Accepted for [issue #12](https://gitea.nessar.fr/nessar/vac-optimizer/issues/12). Fixed, saved planning horizons are implemented by [issue #57](https://gitea.nessar.fr/nessar/vac-optimizer/issues/57); user cancellation with a retained valid plan and original trace is implemented by [issue #58](https://gitea.nessar.fr/nessar/vac-optimizer/issues/58); removal of the automatic calculation deadline and heartbeat-based worker liveness are implemented by [issue #59](https://gitea.nessar.fr/nessar/vac-optimizer/issues/59). The indicative duration estimate is implemented by [issue #60](https://gitea.nessar.fr/nessar/vac-optimizer/issues/60); its initial model, session-local refinement and calendar-rendering measurement are documented in [planning-duration.md](../planning-duration.md).
`deadlineMs` remains an opt-in contract for technical callers: a finite non-negative planning-work budget with no ten-second cap. An omitted value is unlimited. It starts after calendar preparation and excludes trace capture, observer callbacks, and post-outcome artifact work. The calendar and benchmark omit it. A one-second worker heartbeat renews the coordinator's thirty-second inactivity timer during cooperative preparation and search; total elapsed time never triggers that timer while activity continues. Worker errors and unavailable execution retain their explicit outcomes, and monitoring ends when the outcome arrives so delayed trace delivery cannot invalidate it.
@@ -4,7 +4,7 @@ status: accepted
# Declared Non-Working Days and Reported Leave Balances
For [issue #13](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/13), users declare fixed non-working dates among normal working days, with a CP, RTT, or Other type; Other covers every other reason for not working. Reported balances already account for booked leave, so these declarations consume no remaining balance and cannot receive another placement; they count towards compatible planning rules, including rules that earn leave.
For [issue #13](https://gitea.nessar.fr/nessar/vac-optimizer/issues/13), users declare fixed non-working dates among normal working days, with a CP, RTT, or Other type; Other covers every other reason for not working. Reported balances already account for booked leave, so these declarations consume no remaining balance and cannot receive another placement; they count towards compatible planning rules, including rules that earn leave.
Declared dates can form time-off periods without new leave placements, interrupt work sequences, and guide further optimization. Each calculation uses today's Europe/Paris calendar date, with reported balances assumed current as of that date: the planner adds only entitlements available strictly afterward, while earlier entitlements are already represented in the reported balances. This keeps existing bookings useful without debiting them again or asking users to manually add prospective gains; it relies on the user keeping reported balances current as the employer's system credits earned leave.
@@ -20,4 +20,6 @@ After all active rules compare equally, earlier balance expiry and then earlier
Only active rules drive placement decisions and preference comparisons; disabled or deleted optimization rules have no residual influence through fixed optimization stages or protected metrics. With no active rules, the planner adds no leave placements, even when balances are at risk of expiring, and retains declared non-working days. Final tie-breakers cannot independently trigger new placements.
Implementation is pending.
Implemented in the #70–#75 series. Saved-rule migration and functional verification
are covered by #75. Benchmark performance acceptance and any required optimization
are tracked separately in #78; the benchmark limits remain unchanged.
+36 -25
View File
@@ -1,46 +1,57 @@
# Issue tracker: GitLab
# Issue tracker: Gitea
Issues and specs for this repo live as GitLab issues. Use the [`glab`](https://gitlab.com/gitlab-org/cli) CLI for all operations.
Issues and specs live in [nessar/vac-optimizer on Gitea](https://gitea.nessar.fr/nessar/vac-optimizer). Use the configured [`tea-cli`](https://gitea.com/gitea/tea) login for supported operations.
## Access and command version
The installed binary is `tea-cli` 0.9.2. Its commands below use the pre-1.0 interface; other installations may call the binary `tea`. Check `tea-cli --help` and subcommand help before using commands from another version. This version supports YAML, TSV, CSV, simple, and table output; it has no JSON output, issue-edit command, or `api` command.
Run inside the clone so the CLI discovers the repository and login from `origin`. Use `--repo nessar/vac-optimizer` and `--login <login-name>` when an explicit selection is needed. Options go before positional issue or PR numbers. Existing authentication can be checked with `tea-cli whoami`; for a new login, use interactive `tea-cli login add` and select `https://gitea.nessar.fr`.
## Conventions
- **Create an issue**: `glab issue create --title "..." --description "..."`. Use a heredoc for multi-line descriptions. Pass `--description -` to open an editor.
- **Read an issue**: `glab issue view <number> --comments`. Use `-F json` for machine-readable output.
- **List issues**: `glab issue list -F json` with appropriate `--label` filters.
- **Comment on an issue**: `glab issue note <number> --message "..."`. GitLab calls comments "notes".
- **Apply / remove labels**: `glab issue update <number> --label "..."` / `--unlabel "..."`. Multiple labels can be comma-separated or by repeating the flag.
- **Close**: `glab issue close <number>`. `glab issue close` does not accept a closing comment, so post the explanation first with `glab issue note <number> --message "..."`, then close.
- **Merge requests**: GitLab calls PRs "merge requests". Use `glab mr create`, `glab mr view`, `glab mr note`, etc. — the same shape as `gh pr ...` with `mr` in place of `pr` and `note`/`--message` in place of `comment`/`--body`.
- **Create an issue**: `tea-cli issues create --title "..." --description "..." --labels "needs-triage"`. Multiple labels or assignees are comma-separated. For multiline text, prepare a draft and pass its contents as one quoted description argument.
- **Read an issue**: `tea-cli issues --comments <number>`. Detail views render for display even with `--output yaml`; use list output or the REST operations below for structured data.
- **List issues**: `tea-cli issues --state open --labels "needs-triage" --fields index,title,body,state,labels,assignees,url --output yaml`. Use `--page` and `--limit` to read all pages; the server may cap the requested page size.
- **Comment**: `tea-cli comment <number> "..."`; this works for issues and PRs.
- **Apply / remove labels, edit a body, or assign**: use the Gitea UI or the REST operations below; this CLI version does not support these edits. Preserve unrelated labels and assignees.
- **Close**: post the explanation with `tea-cli comment <number> "..."`, then run `tea-cli issues close <number>`.
- **Create a pull request**: `tea-cli pulls create --base develop --title "docs: update Gitea documentation" --description "..."`. Read with `tea-cli pulls --comments <number>`; list with `tea-cli pulls --state open --output yaml`.
- **List milestones**: `tea-cli milestones --state all --fields id,title --output tsv`. Issue numbers were preserved during migration, but milestone IDs changed; V1 stats is [milestone 13](https://gitea.nessar.fr/nessar/vac-optimizer/milestone/13).
Infer the repo from `git remote -v` — `glab` does this automatically when run inside a clone.
## REST operations for CLI gaps
## Merge requests as a triage surface
The instance's [API explorer](https://gitea.nessar.fr/api/swagger) and [schema](https://gitea.nessar.fr/swagger.v1.json) describe the supported endpoints. Paths below are relative to `https://gitea.nessar.fr/api/v1`; authenticate with an existing authorized Gitea API token without displaying or committing it. Check the installed CLI help first when it is upgraded.
**MRs as a request surface: no.** _(Set to `yes` if this repo treats external merge requests as feature requests; `/triage` reads this flag.)_
- **Read full issue data or comments as JSON**: `GET /repos/nessar/vac-optimizer/issues/<number>` and `GET /repos/nessar/vac-optimizer/issues/<number>/comments`. Paginate comment lists.
- **Find label IDs**: `GET /repos/nessar/vac-optimizer/labels`; paginate and match exact names from `triage-labels.md`.
- **Add labels**: `POST /repos/nessar/vac-optimizer/issues/<number>/labels` with `{"labels":[<label-id>]}`. **Remove one label**: `DELETE /repos/nessar/vac-optimizer/issues/<number>/labels/<label-id>`. These operations preserve other labels.
- **Edit a body or assign**: `PATCH /repos/nessar/vac-optimizer/issues/<number>` with `{"body":"..."}` or `{"assignees":["<username>"]}`. Read current assignees before updating the list.
- **Read a PR diff**: `GET /repos/nessar/vac-optimizer/pulls/<number>.diff`.
When set to `yes`, MRs run through the same labels and states as issues, using the `glab mr` equivalents:
## Pull requests as a triage surface
- **Read an MR**: `glab mr view <number> --comments` and `glab mr diff <number>` for the diff.
- **List external MRs for triage**: `glab mr list -F json`, then keep only MRs whose author is not a project member/owner (a contributor's MR, not a maintainer's in-flight work).
- **Comment / label / close**: `glab mr note`, `glab mr update --label`/`--unlabel`, `glab mr close`.
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external pull requests as feature requests; `/triage` reads this flag.)_
Unlike GitHub, GitLab numbers issues and MRs separately, so `#42` is unambiguous once you know which surface the maintainer means.
When enabled, use `tea-cli pulls --comments <number>` to read and `tea-cli pulls --state open --fields index,title,body,author,labels,url --output yaml` to list. Keep only external contributors' requests, checking repository collaborators or organization membership rather than guessing from the author's name. Comment with `tea-cli comment`, edit labels through the issue-label API, and close with `tea-cli pulls close <number>`.
For a bare `#<number>`, check the resource kind: `tea-cli issues --kind all --fields index,kind,title,url --output yaml`, or inspect the issue API's `pull_request` field. PR URLs use `/pulls/<number>`; issue URLs use `/issues/<number>`.
## When a skill says "publish to the issue tracker"
Create a GitLab issue.
Create a Gitea issue with `tea-cli issues create`.
## When a skill says "fetch the relevant ticket"
Run `glab issue view <number> --comments`.
Run `tea-cli issues --comments <number>` and read its labels and assignees.
## Wayfinding operations
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `glab issue create --label wayfinder:map`. (On GitLab tiers with native epics, an epic may hold the map instead; a labelled issue works everywhere.)
- **Child ticket**: an issue carrying `Part of #<map>` at the top of its description and labels `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
- **Blocking**: GitLab's **native blocking link** — the canonical, UI-visible representation. Add it with the `/blocked_by #<n>` quick action, posted as a note (`glab issue note <child> --message "/blocked_by #<blocker>"`). Native blocking links are a Premium/Ultimate feature; on the free tier (or where unavailable) fall back to a `Blocked by: #<n>, #<n>` line at the top of the description. A ticket is unblocked when every blocker is closed.
- **Frontier query**: `glab issue list -F json` scoped to the map's children, drop any with an open blocker — a native `blocked_by` link to an open issue (`glab api projects/:id/issues/:iid/links`), or an open issue in the `Blocked by` line — or an assignee; first in map order wins.
- **Claim**: `glab issue update <n> --assignee @me` — the session's first write.
- **Resolve**: `glab issue note <n> --message "<answer>"`, then `glab issue close <n>`, then append a context pointer (gist + link) to the map's Decisions-so-far.
- **Map**: create an issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body, with `tea-cli issues create --title "..." --description "..." --labels "wayfinder:map"`.
- **Child ticket**: an issue carrying `Part of #<map>` at the top of its description and labels `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Maintain the ordered child links in the map's body. Once claimed, the ticket is assigned to the driving dev.
- **Blocking**: use Gitea's native dependencies. `POST /repos/nessar/vac-optimizer/issues/<child>/dependencies` with `{"owner":"nessar","repo":"vac-optimizer","index":<blocker-number>}` makes the child depend on that blocker. Read blockers with `GET` on the same path; use `DELETE` with the same body to remove a dependency. Where dependencies are unavailable, retain a `Blocked by: #<n>, #<n>` line at the top of the description. A ticket is unblocked when every blocker is closed.
- **Frontier query**: list the map's open children with `tea-cli issues --state open --fields index,title,body,assignees,labels --output yaml`, reading all pages. Drop tickets with an assignee or an open native dependency or description-line blocker; first in map order wins.
- **Claim**: assign the authenticated username through the issue `PATCH` endpoint or Gitea UI — the session's first write.
- **Resolve**: `tea-cli comment <n> "<answer>"`, then `tea-cli issues close <n>`, then append a context pointer (gist + link) to the map's Decisions-so-far through the issue `PATCH` endpoint or Gitea UI.
+47 -13
View File
@@ -1,5 +1,39 @@
# Mobile leave-planner benchmark
## Composable optimization rules: current verification status
The #70–#75 implementation uses explicit active optimization configurations in
the benchmark fixtures. The latest full functional coverage run passes 652 tests
across 34 files under the 2 GB Node heap limit, excluding the separate benchmark.
This does not establish a passing performance protocol.
Before the Gitea migration, GitLab pipeline 320 passed all 652 functional tests
in 109.43 seconds, then the seven-scenario protocol timed out at its unchanged
300-second limit without an out-of-memory failure. In an earlier diagnostic run, the loaded-year warm-up took
184.36 seconds and its first measured repetition began about 251.78 seconds into
the protocol. Those diagnostic timings precede the final period-projection cache
and the planned original-decision-trace removal, and are not a result for either.
At the user's request, #75 finishes on saved-rule migration and functional
verification. #78 owns benchmark verification and implementation of further
optimizations when needed, after #76 removes tracing and #77 verifies the retained
behavior. All seven scenarios, one warm-up and two measured repetitions per
scenario, the 2 GB heap limit and the 300-second timeout remain required for #78.
At the user's request, the Gitea CI configuration defines the benchmark in a
separate job with `continue-on-error: true` while #78 is open. Functional coverage
tests and all other required checks remain blocking. #78 must restore the required benchmark gate
after the unchanged protocol passes. A green pipeline during this exception
does not establish a passing benchmark. No automatic elapsed-time planning
cutoff is introduced. Gitea Actions is enabled; live workflow validation is tracked in
[issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268). The converted
workflow preserves the separate advisory benchmark job. See the
[CI migration status](../../README.md#continuous-integration).
The results below document earlier implementations and protocols. They are
historical measurements rather than the performance result for composable rules.
## Reference-phone protocol
Open the deployed application at `/benchmark` on the reference phone. Tap
**Run benchmark**, wait for the seven scenarios to finish, then tap **Copy
report** and save the tab-separated output with the date and build identifier.
@@ -90,10 +124,10 @@ and each flow sent exactly one planning request. All 1,949 and 3,956 trace
packets respectively were acknowledged, carrying 122,621 and 249,234 candidate
records without truncation. Heartbeats continued during the producing runs.
| Horizon | Calendar usable | Later trace delivery | Placements | Maximum UI timer delay before calendar / during trace | Peak page heap before calendar / during trace | Peak Chrome process-tree RSS before calendar / during trace |
| --- | --- | --- | --- | --- | --- | --- |
| Two years | 5.08 s | 2.55 s | 60 | 1.47 s / 0.70 s | 22.7 / 314.6 MiB | 1,143.2 / 1,606.8 MiB |
| Three years | 10.86 s | 4.14 s | 90 | 2.11 s / 1.48 s | 22.9 / 597.6 MiB | 1,386.8 / 2,217.8 MiB |
| Horizon | Calendar usable | Later trace delivery | Placements | Maximum UI timer delay before calendar / during trace | Peak page heap before calendar / during trace | Peak Chrome process-tree RSS before calendar / during trace |
| ----------- | --------------- | -------------------- | ---------- | ----------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------- |
| Two years | 5.08 s | 2.55 s | 60 | 1.47 s / 0.70 s | 22.7 / 314.6 MiB | 1,143.2 / 1,606.8 MiB |
| Three years | 10.86 s | 4.14 s | 90 | 2.11 s / 1.48 s | 22.9 / 597.6 MiB | 1,386.8 / 2,217.8 MiB |
A 50 ms page timer measured extra event-loop delay and sampled available
main-thread JS heap. RSS was sampled every 500 ms across that isolated Chrome
@@ -111,10 +145,10 @@ completed stage. An editable input accepted focus while the worker remained
active. Both flows kept a 44 px Cancel control, fit the viewport without
horizontal overflow, and delivered the original trace after their outcome.
| Cancellation point | Request to outcome | Request to complete artifacts | Retained placements |
| --- | --- | --- | --- |
| Calendar preparation | 21.9 ms | 874.8 ms | 0 |
| Active holiday-week optimization, keyboard | 21.5 ms | 1,431.7 ms | 90 |
| Cancellation point | Request to outcome | Request to complete artifacts | Retained placements |
| ------------------------------------------ | ------------------ | ----------------------------- | ------------------- |
| Calendar preparation | 21.9 ms | 874.8 ms | 0 |
| Active holiday-week optimization, keyboard | 21.5 ms | 1,431.7 ms | 90 |
No browser errors occurred. Cancellation issued one cancel request and no
additional calculation. Separate tests cover late artifact errors and stale
@@ -151,11 +185,11 @@ at the worker's terminal artifact delivery. The application's own calibration
uses its request timestamp and `afterNextRender` endpoint as described in
[planning-duration.md](../planning-duration.md).
| Horizon | Range before Calculate (with one local sample) | Observed calendar wait | Additional trace delivery | Range after completion | Placements |
| --- | --- | --- | --- | --- | --- |
| One year | 1–4 s | 1.74 s | 1.23 s | 1–5 s | 30 |
| Two years | 2–16 s | 5.08 s | 2.88 s | 2–16 s | 60 |
| Three years | 7–44 s | 11.03 s | 5.60 s | 6–39 s | 90 |
| Horizon | Range before Calculate (with one local sample) | Observed calendar wait | Additional trace delivery | Range after completion | Placements |
| ----------- | ---------------------------------------------- | ---------------------- | ------------------------- | ---------------------- | ---------- |
| One year | 1–4 s | 1.74 s | 1.23 s | 1–5 s | 30 |
| Two years | 2–16 s | 5.08 s | 2.88 s | 2–16 s | 60 |
| Three years | 7–44 s | 11.03 s | 5.60 s | 6–39 s | 90 |
All three outcomes completed through `dates`, with no intermediate placements,
implicit deadline, extra request or date movement. The three-year result
+131
View File
@@ -0,0 +1,131 @@
# Gitea Actions conversion
[Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) converts the
repository's CI to [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml).
The workflow and merge-message template have passed implementation review.
Repository Actions is enabled with owner approval. Publisher credentials and
live workflow validation are still pending.
The existing `gitlab-to-gitea/ci-conversion-required` status stays in place until
that review, a successful Gitea PR run, and approval of the prepared first merge
message.
## Jobs and triggers
Every branch and tag push runs CI. Pull requests run on opening, reopening,
synchronization, and title/body edits. Manual dispatch runs validation without
publishing images. There is no schedule or deployment job in the preserved
configuration; the Compose files remain manual deployment options.
| Job | Behavior |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policy` | Tests the policy and publisher, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
| `validate` | Runs lint, formatting, and application/test type checks. |
| `test` | Runs functional coverage with one worker and a 2 GiB Node heap; uploads coverage for seven days even on failure. |
| `benchmark` | Runs outside coverage with the same worker/heap limits and 300-second protocol timeout. Advisory while #78 is open. |
| `build` | Builds browser/server production bundles and retains `dist` for seven days. |
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. |
| `images` | Publishes branch/SHA images after required validation, or promotes an existing SHA image for a stable tag. Runs only for pushes and same-repository PRs. |
| `required` | Fails on failed, cancelled, or skipped required validation. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. Excludes the advisory benchmark. |
Push and PR runs are separate so PR title and rebase checks run on the PR event.
New runs cancel older runs for the same event/ref. Image jobs serialize per
branch slug; stable promotions share one group and are not cancelled in progress.
## Runners and permissions
Validation uses Node 24.21.0 and Bun 1.4.2 with the frozen lockfile. The image
job uses Docker 29.8.2 and its matching Docker-in-Docker service. Scanner
versions are Semgrep 1.180.0 and Trivy 0.75.0; Trivy downloads are checksum-checked.
Jobs select the `ubuntu-latest` label and require Linux container-job support.
Allow at least the preserved 2 GiB Node heap plus runner and container overhead.
The instance currently has an online inherited runner named `srvhome`, labelled
`ubuntu-latest` and `ubuntu-24.04`. Repository runners, secrets, and variables
were empty at implementation time. The owner must verify that this runner can
run the job containers, JavaScript actions, artifact uploads, and the privileged
Docker-in-Docker service before replacing the migration gate. Use isolated runners for untrusted
contributor code; the privileged publishing service belongs to trusted runs.
The workflow requests a read-only repository token. Checkout removes persisted
credentials; publication credentials are passed only to the image step. Fork PRs
and manual dispatches cannot run that step. Gitea's job token cannot publish
container packages, so provision the two repository Actions secrets below.
See [job-token permissions](https://docs.gitea.com/usage/actions/token-permissions/)
and [package authorization](https://docs.gitea.com/usage/actions/comparison/).
| Secret | Purpose |
| ------------------- | --------------------------------------------------------------------------- |
| `REGISTRY_USERNAME` | The package publisher's Gitea username, authorized to publish for `nessar`. |
| `REGISTRY_PASSWORD` | A dedicated personal access token with `write:package` permission. |
Keep secret values in Gitea's Actions secret store. The configured tea account's
administrator token is not a CI publishing credential. No SSH or deployment
secret is required by this workflow.
## Images and releases
Images use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
at `gitea.nessar.fr/nessar/vac-optimizer`. Branch pushes and same-repository PRs
publish `branch-<slug>` and `sha-<full commit SHA>`. Slugs retain the previous
lowercase, non-alphanumeric-to-hyphen, 63-byte normalization; choose distinct
branch slugs.
Only stable `vX.Y.Z` tags matching the tagged commit's `package.json` version
and reachable from `main` may promote images. Promotion first pulls the existing
SHA image, then publishes the version tag and `latest`; it never rebuilds.
An absent SHA image fails before either stable tag changes. Wait for the main
image job before tagging. No version increment or deployment is automatic.
## Merge messages and activation
The [merge template](../.gitea/default_merge_message/MERGE_TEMPLATE.md) translates
the previous title, closing-issue references, and review reference using
[Gitea's supported variables](https://docs.gitea.com/usage/issues-prs/merge-message-templates/).
For a PR titled `ci: migrate to Gitea Actions`, closing #268, with reference #271,
the checked expansion is:
```text
ci: migrate to Gitea Actions
close #268
See pull request #271
```
The owner has enabled Actions and approved validation before adding publisher
secrets. Complete the remaining activation checks in this order:
1. Review this workflow against the protected administrator exports for runner
requirements, triggers/schedules, permissions, secrets, publishing, and
deployment dependencies. The original CI definition is retained in Git:
`git show 0d900fd72d1a405d257f5a566c55cc0cb3d2bc61:.gitlab-ci.yml`.
2. Publish the reviewed feature branch and open its PR to register the workflow.
Once registered, manual dispatch verifies validation and artifacts without
publishing images. Push and PR validation can also run now; their image and
aggregate checks remain blocked until publisher secrets are provisioned.
3. Verify runner isolation and Docker service support, then provision the two
publisher secrets. Review the package publisher's permissions and registry
namespace before permitting publication. Rerun the PR workflow and require a
successful **pull-request run** on the final commit, including PR title and
rebase checks, artifact handling, and actual image publication. A push run
or manual run alone does not validate the PR policy and publication.
4. Prepare and review the first PR's merge message before replacing the migration
gate. The target branch will not contain the new template until that first
merge, so explicitly use the validated PR title, closing references, and PR
reference for its merge message. Keep the migration gate throughout this
review.
5. After the successful PR run and merge-message approval, replace the migration
status requirement on both protected branches with the **actual reported
pull-request context** for the `required` job. Select it from the PR's reported
checks rather than a push run or a guessed context spelling. Fork PRs must
also be able to satisfy this check without base-repository push permissions.
Keep direct/force pushes disabled, merges restricted to maintainers, outdated
PRs blocked, and administrator protection bypass disabled. Select merge
commits and disable squash/rebase merge methods.
6. Merge the first PR using the approved message, then verify the resulting merge
commit. Subsequent merges read the committed template. No direct
integration-branch push is needed.
Historical job execution, reports, and downloadable artifacts remain in the
administrator rollback backups. They are not recreated or uploaded by this
conversion.
+1 -1
View File
@@ -1,6 +1,6 @@
# Cancelling a planning calculation
Issue [#58](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/58) implements the cancellation portion of ADR 0006. Issue #59 removes the automatic deadline while keeping the same cancellation and original-trace protocol. The duration estimate remains in issue #60.
Issue [#58](https://gitea.nessar.fr/nessar/vac-optimizer/issues/58) implements the cancellation portion of ADR 0006. Issue #59 removes the automatic deadline while keeping the same cancellation and original-trace protocol. The duration estimate remains in issue #60.
`PlanningCalculationContext.cancel()` requests user cancellation once while its outcome is pending. `CalendarYear` keeps an accessible Cancel button visible during preparation, obligation search, and optimization. It announces the request, disables duplicate cancellation, and moves keyboard focus to the result heading when the outcome arrives. Cancellation never triggers Calculate. A subsequent explicit Calculate creates a fresh immutable calculation snapshot.
+286
View File
@@ -0,0 +1,286 @@
# Composable planning objective contract
The common implementation for tickets #70–#75 is on `feature/optimization-rules-70-75`.
`src/app/services/planning-objectives.ts` evaluates supported configurations; rule names and
identifiers never enter the evaluator. Supplied and personal optimization rules share this
model, editor, persistence and ordered calculation pipeline.
## Configuration
`OptimizationConfiguration` contains `elements`, `conditions`, `measure`, `aggregation`,
`direction`, and, only for `work-sequence-deficit`, `thresholdDays`. Conditions are a list
of distinct selectors, combined with AND; an empty list selects every observed element.
Directions are `minimize` and `maximize`. The finite `OPTIMIZATION_CATALOG` contains the
following compatible combinations. Each row accepts any subset of its listed conditions.
| Elements | Applicable conditions | Measure | Aggregations | Measure unit | Parameters |
| ---------------------- | ----------------------- | --------------------------- | ------------------------- | --------------------- | ------------------------------------------------------ |
| `leave-balances` | `at-risk` | `unplaced-quantity` | `sum`, `count`, `maximum` | working days | none |
| `public-holiday-weeks` | `complete` | `public-holiday-week-value` | `sum`, `count`, `maximum` | calendar-day distance | none |
| `time-off-periods` | none | `non-working-day-gain` | `sum`, `count`, `maximum` | calendar days | none |
| `work-sequences` | `internal`, `non-empty` | `work-sequence-length` | `sum`, `count`, `maximum` | working days | none |
| `work-sequences` | `internal`, `non-empty` | `work-sequence-deficit` | `sum`, `count`, `maximum` | working days | `thresholdDays`: positive safe integer in working days |
| `leave-placements` | `supporting` | `placement-count` | `sum`, `count`, `maximum` | count | none |
`count` counts selected elements, including selected elements whose measured value is zero;
its unit is always count. `sum` and `maximum` aggregate the selected measured values.
All three return **zero on an empty collection**. An empty work sequence has zero deficit,
even if a configuration selects it. The maximum of an empty work-sequence collection is zero;
the existing optional average statistics keep their separate null semantics.
`validateOptimizationConfiguration(unknown)` returns `{ valid: true, configuration }` or
`{ valid: false, code, message }`. Malformed values use `invalid-optimization-configuration`;
unsupported elements/measure/condition/aggregation/parameter combinations use
`unsupported-optimization-configuration`. Unknown fields, duplicate conditions, missing,
fractional, non-positive, non-finite and unsafe thresholds are rejected. Other measures
reject `thresholdDays`. Direct and worker calculations attach each configuration error to
its rule, including malformed sparse selector lists; they never replace it with a preset.
For example, a personal rule can select internal non-empty work sequences, sum their
deficits at a threshold of three, and minimize the result. It uses the same measure as
the five-day preset. Another can maximize the longest sequence or count complete holiday
weeks; composition never dispatches by a preset identifier.
## Six supplied recipes
`OPTIMIZATION_PRESETS` provides configurations, without identities, display labels or enabled
states. `RuleService` wraps copies in named planning rules and persists the full configuration.
| Export key | Elements and conditions | Measure and aggregation | Direction |
| ---------------------- | --------------------------------------- | ---------------------------------- | --------- |
| `unplacedAtRisk` | leave balances, at risk | unplaced quantity, sum | minimize |
| `holidayWeekValue` | public-holiday weeks, complete | public-holiday week value, sum | maximize |
| `nonWorkingDayGain` | time-off periods | non-working-day gain, sum | maximize |
| `longestWorkSequence` | work sequences, including horizon edges | work-sequence length, maximum | minimize |
| `shortSequenceDeficit` | internal non-empty work sequences | deficit at `thresholdDays: 5`, sum | minimize |
| `supportingPlacements` | leave placements, supporting | placement count, count | minimize |
## Concrete observations and boundaries
`OptimizationObservations` carries collections from one concrete valid plan:
- Leave balances expose unplaced working-day quantity and the at-risk flag. Expiry on either
the horizon start or its exclusive end is at risk, preserving the existing classification;
placement dates themselves stay within the half-open horizon.
- Public-holiday weeks expose their weekday holiday value and whether every other working
date is a placement or declared non-working day. Monday through Friday contributions are
**1, 2, 3, 2, 1**; several holidays in one week are summed once in that week's observation.
Ordinary fully declared weeks never become holiday weeks. A partial horizon only admits
a week when every required date outside it is already declared, preserving the existing rule.
- Time-off periods carry distinct natural non-working dates; periods are disjoint and include
adjacent weekends, holidays and declared days at either horizon edge. Placements and declared
working days anchor periods but do not themselves contribute non-working-day gain.
- Work sequences carry a working-day length and an `internal` flag. Weekends and holidays
neither count nor split them. Both horizon-edge sequences contribute to the maximum and
are excluded by the `internal` condition. Internal zero-length observations contribute zero
deficit and are excluded by `non-empty`.
- Leave placements expose their supporting classification. Declared days are fixed observations
and do not consume balances or count as supporting placements.
`evaluateOptimization(configuration, observations)` validates the configuration and evaluates
these collections. The core's `optimizationObservations(state, context, periods?)` builds the
collections once; `evaluateStateOptimization(configuration, state, context)` is the shared core
seam for a personal rule. The preset metrics call the same evaluator, with no second engine.
## Ordered rule execution
Named `type: 'optimization'` rules contain a full `configuration` and preference-only
force. Storage, editor buffers, direct calculation snapshots and worker transport copy
the nested configuration. The core executes only explicitly submitted active rules;
it never adds presets to a request.
Obligations are solved jointly. Each active preference then runs in the displayed
rule order. Disabled rules are excluded before validation and search. A request
with no active rules preserves declared days and adds no placements, including
when balances are at risk of expiry. Statistics may still report preset metrics;
only the active configured objectives affect selection.
The shared core seams are:
- `activeObjectiveVector(state, context, throughIndex?)` evaluates active preferences
in display order, with unique `optimization:<rule-id>` and `preference:<rule-id>`
identities. Optimization measurements use their full configurations; calendar
preferences measure actual matching dates/patterns.
- `evaluateRuleCandidate(before, candidate, context, throughIndex, dates, sourceDate?,
addition?, baseline?, beforeVector?, incumbentVector?)` validates mandatory type/date/pattern memberships and earned
entitlement availability and compares the active prefix. Its comparison preserves
the source state; technical selection facts compare with the actual current best
candidate. It returns the valid state, its full vector and
optional trace evaluation. Additions must improve an active prefix objective.
- `optimizeRulePlacements(state, context, throughIndex, allowAdd, rule?)` generates
single additions/removals/moves, atomic balance reassignments and balance swaps.
`relocateForAddition` handles augmenting chains when a scarce balance needs an
occupied date. Complete typed calendar patterns are reassigned atomically too.
Coordinated family bundles can pass through `evaluateRuleCandidate` and rank through
`compareObligationPreviews(candidate, incumbent, context)` without fixed metric guards.
- `applyPreferenceStep` dispatches every optimization and calendar window through
the same kernel. Earlier preferences replay when a later rule changes their state
or creates an earned entitlement.
Earned entitlements follow actual completed active producer patterns, including patterns
already fulfilled by another rule or declared days. Shared finalization reconciles those
rights before candidate comparisons, cancellation retention and unchanged-state caching.
Availability starts after the complete merged producing period; its own reward cannot
finance a placement inside that period. Earning is a validity consequence, rather than
an extra objective that an active configuration can suppress.
`compareObjectiveValues`, `compareObjectiveLists` and `firstDifferentObjective`
serve both selection and explanation. Earlier balance expiry and placement dates
are final tie-breaks after all active objectives compare equally; they only reshape
existing placements. Equal expiry values are compared independently of arbitrary balance
identifiers, so earlier placement dates remain the next tie-break. Captured rule identities, names and configurations label the
result independently of subsequent editor changes.
Loaded plans retain the established sampling strategy for flexible move destinations
above 24 placements, including non-working-adjacent, weekly and calendar-window dates.
Excluded move destinations are recorded as `not-examined`, never as proven rejected.
There is no automatic elapsed-time cutoff. Complete holiday weeks, joined time-off
periods and work-sequence changes are compared as coordinated valid bundles.
Moves that preserve every active objective can be pruned when their final tie-breaks
cannot improve. Per-iteration vectors are reused; preset statistics are materialized
only for selected incumbents and published plans.
For large non-earned window obligation searches above the existing exact-branch
threshold, the joint feasibility search retains its existing limit. Feasible
solutions are ranked by actual active objectives and final ties; the first eight
receive full optimization previews. Other feasible solutions are recorded as
`not-examined`. Small searches and earned-entitlement chains keep all previews.
This deterministic sampling has no elapsed-time cutoff or user-facing setting.
A deferred optimization addition is supporting only when it provides one of the
calendar benefits in the domain definition, rather than merely extending a period.
Holiday occurrences and eligible patterns are immutable per request and cached
independently of mutable placement states.
## Saved rules and initialization
`vacationRules` uses one versioned envelope: `{ version: 1, rules: [...] }`.
`RuleService` restores it synchronously before the first duration estimate or calculation.
A legacy array, including `[]`, receives loss prevention first, every original record in
its original relative order, then the other five presets. Each original raw record is
preserved, including names, IDs, dates, parameters, disabled state and a missing legacy
force. Getters interpret missing force as preference without rewriting that record.
Preset IDs use collision-free suffixes when a personal rule already uses a proposed ID.
New users receive the six active presets around the existing Samedi Malin and Christmas
calendar defaults.
The migration and its version are written by one synchronous `setItem`; no separate
marker can succeed while the rules fail to save. A failed write leaves the original
key intact and permits a later complete save or a fresh reload to retry. Failed reads,
malformed saved data and unsupported versions disable writes for that service instance
so later editing cannot overwrite unknown persisted user data. Storage unavailable
in the runtime leaves an in-memory configuration usable.
Every later edit, toggle, reorder or deletion saves the full versioned list. Versioned
empty lists stay empty; disabled and deleted presets stay that way. With no active
rules, eligible unused leave remains visible with its original expiry classification
and a translated explanation that the active planning rules justified no addition.
Physical availability, occupation, expiry and incomplete-calculation reasons keep
precedence where they apply. Declared days remain fixed and consume no leave.
## Search and trace limits
These are deterministic search limits, rather than claims of global optimality:
- Above 24 placements, flexible moves examine the existing non-working-adjacent,
declared-adjacent, weekly and calendar-window destinations. Other destinations
retain `not-examined` trace records.
- Non-earned obligation searches whose estimated branch count exceeds 10,000 retain
at most 4,096 feasible solutions. Large window searches rank them by the actual
active vector and final ties, then fully preview the first eight; the other
feasible solutions are marked `not-examined`. Earned chains and small searches
retain all previews.
- Calendar bundles rank original donors once by the actual active vector of the
source plan with each donor released, then retain that order during recursive
allocation. They keep the first prefix-valid allocation and collapse equivalent
donor tokens. Work-sequence bundles use the nearest center per balance and the
first accepted allocation. These choices can miss another coordinated allocation.
- When a completed specialized bundle strictly improves the current active prefix,
the kernel commits its full-vector best before scanning generic alternatives for
the old state. The old generic neighborhood is marked `not-examined`, then bundles
and generic candidates continue from the improved state. Earlier preferences replay
and the final expiry/date pass still runs. This scheduling can make a better unseen
old-state generic branch unreachable; it is an explicit heuristic rather than an
exhaustive comparison of all bundles and moves.
- Additions require a strict improvement through the current rule. A custom
maximum/minimize unplaced-quantity rule with two tied at-risk CP1 balances can
therefore keep both unused: either single addition leaves the maximum at one,
while a coordinated addition of both would reduce it to zero. This balance
plateau has no specialized bundle generator; the supplied sum/minimize loss
preset does consume both.
A completely enumerated single obligation solution has no alternative to rank, so
its discarded full preference preview is skipped; the real ordered execution still
runs in full. Earlier rules reuse their completed evaluation only when the complete
ordered incoming state matches their last output, including balance quantities and
validity, earned activations, placement insertion order, supporting status and rule
provenance. The trace records that unchanged state as `not-examined` with the earlier
decisions retained. A candidate unable to beat a valid local incumbent already
offered to cancellation retention keeps its primary comparison but avoids a duplicate
global incumbent comparison. These are exact reuse decisions, not additional sampling.
At the first explicit sum/minimize quantity preference, complete single additions can
reuse an evaluated funding score when eligible at-risk balances have the same expiry
and matching signature across every enabled time-window rule, including obligations.
The first original-order funder is preserved; later equivalent alternatives are recorded
as `not-examined` without fabricated comparisons. This applies only without active
IF_THEN rules or earned activations, without a balance maximum objective, and with a
safe combined remaining quantity. Recursive bundles and augmenting allocation retain
every funding choice: equal current scores do not prove equivalent future capacity.
A first-position sum/minimize unplaced-quantity rule finishes its preference stage when
its reconciled value reaches zero. The same bound applies to a prefix consisting of
that zero quantity followed only by fully satisfied time-window preferences or maximizing
public-holiday-week preferences at their configured upper bound. Windows are capped at
their requested quantity. A holiday maximum is evaluated from the same immutable request
weeks, marking every week complete and using its configured sum, count or maximum;
partial horizon weeks retain the existing declaration and eligibility semantics. The whole bounded stage is recorded as
`not-examined`; later preferences, replay after actual state changes and the final
full-vector expiry/date pass still run. Other configurations and preference orders
retain their search. This schedules lower priorities later; it does not claim an
exhaustive full-vector search in the bounded stage.
When every balance is exhausted and there is no active IF_THEN rule, a complete generic
iteration that finds no full-vector improvement can be reused for the identical ordered
state at a later preference. Explicit balance assignments use the same dates, validity,
provenance and configured move sampling regardless of the current rule. Rule-specific
bundles still run, and any balance, placement, supporting, provenance or activation change
invalidates reuse. Bound returns and interrupted iterations never admit a state. Reuse
is recorded as `not-examined` with the earlier sampled generic decisions retained; the
final expiry/date pass still examines its neighborhood.
Captured trace vectors, scalar facts, placements, ordered date lists and deltas are immutable detached evidence. Bounded
request-local indexes share only identical complete values and metadata; the vector
index uses recorded date keys and holds at most 65,536 entries, including variants in
one bucket. Eviction changes allocation only, with every examined candidate and its
ordered factual fields retained. Mutable candidate shells and emitted replacement
snapshots remain independent. Search never reads these sharing indexes. Calendar facts also reuse a bounded 4,096-entry request-local cache keyed by the complete occupied date set; balance quantities, earned metadata, provenance and supporting classifications are refreshed separately. Removing a placement copies the placement map without editing surviving entries; later assignment and metadata edits still detach them. Candidate locations are resolved from the latest recorded decision only when a later update or selection needs them, avoiding a permanent index for observation-only candidates.
Cancellation retains the best valid examined plan under the captured active vector
and its original trace. Counterfactual and explanation comparisons use that same
snapshot; later editor changes and delayed artifact delivery do not alter it.
An explicit caller-owned planning-work budget remains supported; the application
introduces no automatic duration-based stop.
## Confirmed decisions and technical assumptions
Confirmed by ADR 0008: one composable model for supplied and personal optimization rules;
preference-only force; the six recipes' existing business meanings; declared-day and horizon
semantics; user-controlled active preference order; expiry then date tie-breaks; preserving
saved user data. The integrated implementation replaces the former implicit policy
with explicit active rules and performs the one-time saved-data addition.
The supported configuration boundary is only sum/count/maximum and both directions; empty aggregate
zero; deficit threshold is a positive working-day integer, initially five; every listed
compatible condition subset and aggregation is supported; no other filters or measures.
Public-holiday weights retain the existing business definition rather than becoming a parameter.
## Verification scope
Ticket #75 covers saved-rule migration and functional integration of #70–#75.
Performance acceptance and any further optimization are tracked in #78, after
original-decision-trace removal in #76 and functional verification in #77. The
seven-scenario benchmark, its warm-up and two measured repetitions, the 2 GB heap
limit and the 300-second timeout remain unchanged. CI runs the benchmark as a
separate non-blocking job at the user's request while #78 is open; functional
tests and other checks remain required. #78 must restore the required benchmark
gate after the protocol passes. Completing the functional migration or passing
CI during this exception does not establish a passing benchmark.
+1 -1
View File
@@ -1,6 +1,6 @@
# Plan Statistics Popup
Local development draft for [issue #3](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/3) and [issue #29](https://gitlab.nessar.fr/nessar/vac-optimizer/-/issues/29), within [milestone %3: V1 stats](https://gitlab.nessar.fr/nessar/vac-optimizer/-/milestones/3). The behavior below consolidates the decisions confirmed during grill-with-docs; proposals and remaining presentation choices are identified separately.
Local development draft for [issue #3](https://gitea.nessar.fr/nessar/vac-optimizer/issues/3) and [issue #29](https://gitea.nessar.fr/nessar/vac-optimizer/issues/29), within [milestone V1 stats](https://gitea.nessar.fr/nessar/vac-optimizer/milestone/13). The behavior below consolidates the decisions confirmed during grill-with-docs; proposals and remaining presentation choices are identified separately.
## Problem Statement
+35
View File
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs';
// Gitea exposes its event payload through the GitHub-compatible runner variables.
export function readCiContext() {
const env = process.env;
const eventName = env.GITHUB_EVENT_NAME;
if (!['push', 'pull_request', 'workflow_dispatch'].includes(eventName)) {
throw new Error('Only push, pull_request and workflow_dispatch events are supported.');
}
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
const commit = env.GITHUB_SHA;
if (!/^[a-f0-9]{40}$/.test(commit ?? '')) throw new Error('A commit SHA is required.');
const pullRequest = eventName === 'pull_request' ? event.pull_request : undefined;
if (eventName === 'pull_request' && pullRequest?.head?.sha !== commit) {
throw new Error('CI must validate the pull request head commit.');
}
const tag = env.GITHUB_REF?.startsWith('refs/tags/') ? env.GITHUB_REF.slice(10) : '';
const branch = pullRequest
? pullRequest.head.ref
: env.GITHUB_REF?.startsWith('refs/heads/')
? env.GITHUB_REF.slice(11)
: '';
if (!tag && !branch) throw new Error('A branch or tag ref is required.');
const slug = branch
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.slice(0, 63)
.replace(/^-+|-+$/g, '');
const publish =
eventName === 'push' ||
(eventName === 'pull_request' &&
Boolean(env.GITHUB_REPOSITORY) &&
pullRequest.head.repo?.full_name === env.GITHUB_REPOSITORY);
return { commit, tag, branch, slug, publish, pullRequest, before: event.before };
}
+40
View File
@@ -0,0 +1,40 @@
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readCiContext } from './ci-context.mjs';
try {
const { commit, tag, slug, publish } = readCiContext();
if (!publish) throw new Error('Images can only be published by pushes or same-repository PRs.');
execFileSync(process.execPath, [fileURLToPath(new URL('./ci-policy.mjs', import.meta.url))]);
const env = process.env;
if (!env.REGISTRY_USERNAME || !env.REGISTRY_PASSWORD) {
throw new Error('REGISTRY_USERNAME and REGISTRY_PASSWORD are required for publication.');
}
const registry = new URL(env.GITHUB_SERVER_URL).host;
const image = `${registry}/${env.GITHUB_REPOSITORY.toLowerCase()}`;
const shaImage = `${image}:sha-${commit}`;
const docker = (...args) => execFileSync('docker', args, { stdio: 'inherit' });
execFileSync(
'docker',
['login', registry, '--username', env.REGISTRY_USERNAME, '--password-stdin'],
{
input: env.REGISTRY_PASSWORD,
stdio: ['pipe', 'inherit', 'inherit'],
},
);
if (tag) {
docker('pull', shaImage);
docker('tag', shaImage, `${image}:${tag}`);
docker('push', `${image}:${tag}`);
docker('tag', shaImage, `${image}:latest`);
docker('push', `${image}:latest`);
} else {
const branchImage = `${image}:branch-${slug}`;
docker('build', '-t', shaImage, '-t', branchImage, '.');
docker('push', shaImage);
docker('push', branchImage);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const directory = mkdtempSync(join(tmpdir(), 'ci-image-'));
const script = fileURLToPath(new URL('./ci-image.mjs', import.meta.url));
const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim();
const image = 'gitea.nessar.fr/nessar/vac-optimizer';
const commandsPath = join(directory, 'docker-commands.jsonl');
const bin = join(directory, 'bin');
const run = (variables = {}, expected = 0) => {
const commit = git('rev-parse', 'HEAD');
const eventName = variables.eventName ?? 'push';
const ref = variables.tag
? `refs/tags/${variables.tag}`
: eventName === 'pull_request'
? 'refs/pull/42/head'
: 'refs/heads/feature/calendar';
const event = { ref };
if (eventName === 'pull_request') {
event.pull_request = {
title: 'feat: calendar',
head: {
sha: commit,
ref: 'feature/calendar',
repo: { full_name: variables.headRepository ?? 'nessar/vac-optimizer' },
},
base: { ref: 'develop' },
};
}
writeFileSync(join(directory, 'event.json'), JSON.stringify(event));
writeFileSync(commandsPath, '');
const result = spawnSync(process.execPath, [script], {
cwd: directory,
encoding: 'utf8',
env: {
PATH: `${bin}:${process.env.PATH}`,
GITHUB_EVENT_NAME: eventName,
GITHUB_EVENT_PATH: join(directory, 'event.json'),
GITHUB_SHA: commit,
GITHUB_REF: ref,
GITHUB_REPOSITORY: 'nessar/vac-optimizer',
GITHUB_SERVER_URL: 'https://gitea.nessar.fr',
REGISTRY_USERNAME: 'publisher',
REGISTRY_PASSWORD: variables.registryPassword ?? 'test-token',
DOCKER_COMMANDS: commandsPath,
MISSING_IMAGE: variables.missingImage ? 'true' : '',
},
});
assert.equal(result.status, expected, result.stdout + result.stderr);
return readFileSync(commandsPath, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
};
try {
git('init', '--initial-branch=main');
git('config', 'user.name', 'CI image test');
git('config', 'user.email', 'ci-image@example.invalid');
git('remote', 'add', 'origin', directory);
writeFileSync(join(directory, 'package.json'), '{"version":"1.5.0"}\n');
git('add', 'package.json');
git('commit', '-m', 'chore: initial release');
git('branch', 'develop');
git('switch', '-c', 'feature/calendar');
git('commit', '--allow-empty', '-m', 'feat: calendar');
mkdirSync(bin);
const docker = join(bin, 'docker');
writeFileSync(
docker,
`#!/usr/bin/env node
import { appendFileSync, readFileSync } from 'node:fs';
const args = process.argv.slice(2);
const input = args[0] === 'login' ? readFileSync(0, 'utf8') : '';
appendFileSync(process.env.DOCKER_COMMANDS, JSON.stringify({ args, input }) + '\\n');
if (args[0] === 'pull' && process.env.MISSING_IMAGE === 'true') process.exit(1);
`,
);
chmodSync(docker, 0o755);
const commit = git('rev-parse', 'HEAD');
const commands = run();
assert.deepEqual(commands, [
{
args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
input: 'test-token',
},
{
args: [
'build',
'-t',
`${image}:sha-${commit}`,
'-t',
`${image}:branch-feature-calendar`,
'.',
],
input: '',
},
{ args: ['push', `${image}:sha-${commit}`], input: '' },
{ args: ['push', `${image}:branch-feature-calendar`], input: '' },
]);
assert.deepEqual(run({ eventName: 'pull_request' }), commands);
assert.deepEqual(
run({ eventName: 'pull_request', headRepository: 'contributor/vac-optimizer' }, 1),
[],
);
assert.deepEqual(run({ eventName: 'workflow_dispatch' }, 1), []);
assert.deepEqual(run({ registryPassword: '' }, 1), []);
assert.deepEqual(run({ tag: 'v1.5.0' }, 1), []);
git('switch', 'main');
git('tag', '-a', 'v1.5.0', '-m', 'Release 1.5.0');
const releaseCommit = git('rev-parse', 'HEAD');
assert.deepEqual(run({ tag: 'v1.5.0' }), [
{
args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
input: 'test-token',
},
{ args: ['pull', `${image}:sha-${releaseCommit}`], input: '' },
{ args: ['tag', `${image}:sha-${releaseCommit}`, `${image}:v1.5.0`], input: '' },
{ args: ['push', `${image}:v1.5.0`], input: '' },
{ args: ['tag', `${image}:sha-${releaseCommit}`, `${image}:latest`], input: '' },
{ args: ['push', `${image}:latest`], input: '' },
]);
assert.deepEqual(run({ tag: 'v1.5.0', missingImage: true }, 1), [
{
args: ['login', 'gitea.nessar.fr', '--username', 'publisher', '--password-stdin'],
input: 'test-token',
},
{ args: ['pull', `${image}:sha-${releaseCommit}`], input: '' },
]);
assert.deepEqual(run({ tag: 'v1.5.0-rc.1' }, 1), []);
assert.deepEqual(run({ tag: 'v1.6.0' }, 1), []);
console.log('CI image publication checks passed.');
} finally {
rmSync(directory, { recursive: true, force: true });
}
+17 -15
View File
@@ -1,5 +1,6 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { appendFileSync, readFileSync } from 'node:fs';
import { readCiContext } from './ci-context.mjs';
const env = process.env;
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
@@ -11,7 +12,7 @@ const requirePolicy = (condition, message) => {
};
try {
const commit = env.CI_COMMIT_SHA;
const { commit, tag, branch, slug, pullRequest, before } = readCiContext();
requirePolicy(commit === git('rev-parse', 'HEAD'), 'CI must validate the checked-out commit.');
git(
'fetch',
@@ -21,33 +22,32 @@ try {
'+refs/heads/develop:refs/remotes/origin/develop',
);
if (env.CI_COMMIT_TAG) {
if (tag) {
requirePolicy(
/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(env.CI_COMMIT_TAG),
/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tag),
'Release tags must use vMAJOR.MINOR.PATCH without leading zeroes.',
);
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
requirePolicy(env.CI_COMMIT_TAG === `v${version}`, 'Release tag must match package.json.');
requirePolicy(tag === `v${version}`, 'Release tag must match package.json.');
requirePolicy(ancestor(commit, 'origin/main'), 'Release commit must belong to main.');
} else {
const mr = env.CI_PIPELINE_SOURCE === 'merge_request_event';
const branch = mr ? env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME : env.CI_COMMIT_BRANCH;
const target = mr
? env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME
const target = pullRequest
? pullRequest.base?.ref
: branch === 'main' || branch?.startsWith('hotfix/')
? 'main'
: 'develop';
requirePolicy(branch, 'A branch name is required.');
requirePolicy(['main', 'develop'].includes(target), 'MRs must target main or develop.');
git('check-ref-format', `refs/heads/${branch}`);
requirePolicy(['main', 'develop'].includes(target), 'PRs must target main or develop.');
if (mr) {
const title = env.CI_MERGE_REQUEST_TITLE?.replace(
if (pullRequest) {
const title = pullRequest.title?.replace(
/^(?:Draft:|WIP:|\[Draft\]|\[WIP\]|\(Draft\)|\(WIP\))\s*/i,
'',
);
requirePolicy(
conventional.test(title ?? ''),
'MR title must use Conventional Commits.',
'PR title must use Conventional Commits.',
);
requirePolicy(
target !== 'main' || /^(release|hotfix)\/.+/.test(branch),
@@ -65,9 +65,8 @@ try {
}
}
const before = env.CI_COMMIT_BEFORE_SHA;
const base =
!mr && before && !/^0+$/.test(before) && ancestor(before, commit)
!pullRequest && before && !/^0+$/.test(before) && ancestor(before, commit)
? before
: git('merge-base', `origin/${target}`, commit);
const subjects = git('log', '--format=%h %s', `${base}..${commit}`);
@@ -78,6 +77,9 @@ try {
);
}
}
if (env.GITHUB_OUTPUT) {
appendFileSync(env.GITHUB_OUTPUT, `slug=${slug}\ntag=${tag}\n`);
}
console.log('GitFlow, version and commit policy passed.');
} catch (error) {
console.error(error.message);
+65 -38
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -8,26 +8,48 @@ import { fileURLToPath } from 'node:url';
const directory = mkdtempSync(join(tmpdir(), 'ci-policy-'));
const script = fileURLToPath(new URL('./ci-policy.mjs', import.meta.url));
const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim();
const check = (variables, expected = 0) => {
const check = (variables = {}, expected = 0) => {
const commit = git('rev-parse', 'HEAD');
const branch = variables.branch ?? 'feature/test';
const eventName = variables.eventName ?? 'push';
const ref = variables.tag
? `refs/tags/${variables.tag}`
: eventName === 'pull_request'
? 'refs/pull/42/head'
: `refs/heads/${branch}`;
const event = { ref, before: variables.before };
if (eventName === 'pull_request') {
event.pull_request = {
title: variables.title ?? 'feat(test): add policy checks',
head: {
ref: branch,
sha: variables.headSha ?? commit,
repo: { full_name: variables.headRepository ?? 'nessar/vac-optimizer' },
},
base: { ref: variables.target ?? 'develop' },
};
}
const eventPath = join(directory, 'event.json');
const outputPath = join(directory, 'outputs');
writeFileSync(eventPath, JSON.stringify(event));
writeFileSync(outputPath, '');
const result = spawnSync(process.execPath, [script], {
cwd: directory,
encoding: 'utf8',
env: {
PATH: process.env.PATH,
CI_PIPELINE_SOURCE: 'push',
CI_COMMIT_SHA: git('rev-parse', 'HEAD'),
CI_COMMIT_BRANCH: 'feature/test',
...variables,
GITHUB_EVENT_NAME: eventName,
GITHUB_EVENT_PATH: eventPath,
GITHUB_REF: ref,
GITHUB_SHA: variables.sha ?? commit,
GITHUB_REPOSITORY: 'nessar/vac-optimizer',
GITHUB_OUTPUT: outputPath,
},
});
assert.equal(result.status, expected, result.stdout + result.stderr);
return readFileSync(outputPath, 'utf8');
};
const mr = {
CI_PIPELINE_SOURCE: 'merge_request_event',
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'feature/test',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'develop',
CI_MERGE_REQUEST_TITLE: 'feat(test): add policy checks',
};
const pr = { eventName: 'pull_request' };
try {
git('init', '--initial-branch=main');
@@ -41,26 +63,31 @@ try {
git('switch', '-c', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat(test)!: add checks');
check({});
check(mr);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Draft: feat: test policy' });
check({ ...mr, CI_MERGE_REQUEST_TITLE: '(Draft) feat: test policy' });
check({ ...mr, CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main' }, 1);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Add checks' }, 1);
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
check({ CI_COMMIT_SHA: '0'.repeat(40) }, 1);
check(pr);
check({ ...pr, headRepository: 'contributor/vac-optimizer' });
check({ eventName: 'workflow_dispatch' });
assert.match(check({ branch: 'Feature/My_calendar' }), /slug=feature-my-calendar\n/);
check({ ...pr, headSha: '0'.repeat(40) }, 1);
check({ eventName: 'pull_request_target' }, 1);
check({ ...pr, title: 'Draft: feat: test policy' });
check({ ...pr, title: '(Draft) feat: test policy' });
check({ ...pr, target: 'main' }, 1);
check({ ...pr, title: 'Add checks' }, 1);
check({ tag: 'v1.5.0' }, 1);
check({ sha: '0'.repeat(40) }, 1);
git('switch', 'develop');
git('commit', '--allow-empty', '-m', 'fix: another change');
git('switch', 'feature/test');
check(mr, 1);
check(pr, 1);
git('rebase', 'develop');
check(mr);
check(pr);
const before = git('rev-parse', 'HEAD');
git('commit', '--allow-empty', '-m', 'not conventional');
check({ CI_COMMIT_BEFORE_SHA: before }, 1);
check(mr, 1);
check({ before: before }, 1);
check(pr, 1);
git('commit', '--amend', '--allow-empty', '-m', 'ci: fix the commit title');
check({ CI_COMMIT_BEFORE_SHA: before });
check({ before: before });
git('switch', 'develop');
git('merge', '--no-ff', 'feature/test', '-m', 'feat(test): add policy checks');
@@ -69,33 +96,33 @@ try {
git('add', 'package.json');
git('commit', '-m', 'chore(release): prepare 1.6.0');
check({
...mr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
...pr,
branch: 'release/1.6.0',
target: 'main',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0' });
check({ ...pr, branch: 'release/1.6.0' });
git('switch', 'main');
git('merge', '--no-ff', 'release/1.6.0', '-m', 'chore(release): publish 1.6.0');
check({ CI_COMMIT_TAG: 'v1.6.0' });
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
check({ CI_COMMIT_TAG: 'v01.6.0' }, 1);
check({ CI_COMMIT_TAG: 'v1.6.0-rc.1' }, 1);
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'main' });
check({ tag: 'v1.6.0' });
check({ tag: 'v1.5.0' }, 1);
check({ tag: 'v01.6.0' }, 1);
check({ tag: 'v1.6.0-rc.1' }, 1);
check({ ...pr, branch: 'main' });
git('switch', '-c', 'hotfix/1.6.1');
git('commit', '--allow-empty', '-m', 'fix: production issue');
check({
...mr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
...pr,
branch: 'hotfix/1.6.1',
target: 'main',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1' });
check({ ...pr, branch: 'hotfix/1.6.1' });
// The working branch must not pass by merging develop instead of rebasing.
git('switch', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat: keep working');
git('merge', '--no-ff', 'develop', '-m', 'chore: merge develop');
check(mr, 1);
check(pr, 1);
console.log('CI policy integration checks passed.');
} finally {
+3 -3
View File
@@ -32,7 +32,7 @@ describe('Application Settings', () => {
localStorage.clear();
localStorage.setItem('app.language', JSON.stringify('en'));
localStorage.setItem('leaveBalances', savedBalances);
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
localStorage.setItem('unrelated.entry', 'keep this value');
mediaQuery = Object.assign(new EventTarget(), {
matches: true,
@@ -461,7 +461,7 @@ describe('Application Settings', () => {
await (await dialog.getHarness(MatSlideToggleHarness)).check();
expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true');
expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
expect(localStorage.getItem('vacationRules')).toBe('[]');
expect(localStorage.getItem('vacationRules')).toBe('{"version":1,"rules":[]}');
expect(localStorage.getItem('unrelated.entry')).toBe('keep this value');
expect(fixture.nativeElement.querySelectorAll('.week-number').length).toBeGreaterThan(
0,
@@ -490,7 +490,7 @@ describe('Application Settings', () => {
TestBed.resetTestingModule();
await createApp();
expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
expect(localStorage.getItem('vacationRules')).toBe('[]');
expect(localStorage.getItem('vacationRules')).toBe('{"version":1,"rules":[]}');
expect(localStorage.getItem('unrelated.entry')).toBe('keep this value');
expect(document.body.classList.contains('dark-mode')).toBe(true);
expect(
@@ -41,10 +41,11 @@ describe('leave planner benchmark protocol', () => {
String(rttRule.startDate) <= String(cpRule.endDate),
).toBe(true);
const chain = scenarios.find((scenario) => scenario.name === 'chained earned entitlements');
expect(chain?.request.rules?.map((rule) => rule.id)).toEqual([
'wednesday-holidays',
'thursday-consumer',
]);
expect(
chain?.request.rules
?.filter(({ type }) => type !== 'optimization')
.map((rule) => rule.id),
).toEqual(['wednesday-holidays', 'thursday-consumer']);
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
+10 -1
View File
@@ -4,6 +4,7 @@ import {
PlanningRequest,
PlanningTrace,
} from '../services/leave-planner.service';
import { createOptimizationPresetRules } from '../services/rule-service';
export interface BenchmarkScenario {
name: string;
@@ -47,7 +48,8 @@ const balance = (
export function benchmarkScenarios(): BenchmarkScenario[] {
const horizon = { start: '2024-01-01', end: '2025-01-01' };
return [
const presets = createOptimizationPresetRules();
const scenarios: BenchmarkScenario[] = [
{
name: 'representative year',
request: {
@@ -169,6 +171,13 @@ export function benchmarkScenarios(): BenchmarkScenario[] {
}),
),
];
return scenarios.map((scenario) => ({
...scenario,
request: {
...scenario.request,
rules: [presets[0], ...scenario.request.rules, ...presets.slice(1)],
},
}));
}
function resultSummary(outcome: PlanningOutcome): {
@@ -1,3 +1,4 @@
import { createOptimizationPresetRules } from '../../services/rule-service';
import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
@@ -407,6 +408,8 @@ function jointTrace(): PlanningTrace {
};
}
const presets = createOptimizationPresetRules();
describe('CalendarYear planning outcome boundary', () => {
beforeEach(() => localStorage.clear());
afterEach(() => {
@@ -1342,7 +1345,7 @@ describe('CalendarYear planning outcome boundary', () => {
);
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
@@ -1421,7 +1424,7 @@ describe('CalendarYear planning outcome boundary', () => {
);
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
@@ -1496,7 +1499,7 @@ describe('CalendarYear planning outcome boundary', () => {
expiryDate: placementDate,
},
],
rules: [],
rules: [presets[0]],
declaredDays: [
{ date: '2024-08-01', type: 'cp' as const },
...(fixedAnchor ? [{ date: '2024-07-31', type: 'other' as const }] : []),
@@ -1524,7 +1527,7 @@ describe('CalendarYear planning outcome boundary', () => {
),
);
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
@@ -1582,7 +1585,7 @@ describe('CalendarYear planning outcome boundary', () => {
it('previews a growing and shrinking range across months, then saves it once on release', async () => {
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
await TestBed.configureTestingModule({
imports: [CalendarYear, UserInput],
providers: [provideZonelessChangeDetection()],
@@ -1749,7 +1752,7 @@ describe('CalendarYear planning outcome boundary', () => {
it('edits declarations from configuration and restores the preceding result when switched off', async () => {
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem(
'declaredNonWorkingDays',
@@ -1844,7 +1847,7 @@ describe('CalendarYear planning outcome boundary', () => {
});
it('keeps the preceding calendar until a submitted snapshot returns and ignores later edits', async () => {
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
localStorage.setItem('leaveBalances', '[]');
await TestBed.configureTestingModule({
imports: [CalendarYear, UserInput],
@@ -1987,7 +1990,7 @@ describe('CalendarYear planning outcome boundary', () => {
let now = 1_000;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
try {
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
const pending = deferred<PlanningOutcome>();
const trace = deferred<PlanningTrace | undefined>();
const { calendar, calculation } = calendarFor(
@@ -2100,7 +2103,7 @@ describe('CalendarYear planning outcome boundary', () => {
let now = 1_000;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
try {
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
const old = deferred<PlanningOutcome>();
const latest = deferred<PlanningOutcome>();
const { calendar, calculation } = calendarFor(plan);
@@ -1536,6 +1536,24 @@ export class CalendarYear {
}
protected objectiveCopy(id: string) {
if (id.startsWith('optimization:')) {
const ruleId = id.slice('optimization:'.length);
const rule = this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === ruleId,
);
const copy = this.tr().planning.explanation.objectives['optimization'];
return {
...copy,
label: copy.label
.replace('{rule}', this.ruleLabel(ruleId))
.replace(
'{summary}',
rule && 'configuration' in rule
? this.translationService.optimizationSummary(rule.configuration)
: '',
),
};
}
const key = id.startsWith('preference:') ? 'preference' : id;
const copy = this.tr().planning.explanation.objectives[key];
const rule =
@@ -1645,7 +1663,7 @@ export class CalendarYear {
if (stage === 'validation') return 5;
if (stage === 'obligations') return 20;
if (stage === 'at-risk') return 35;
if (stage.startsWith('preference:')) return 50;
if (stage.startsWith('preference:') || stage.startsWith('optimization:')) return 50;
if (stage === 'holiday-week-value') return 58;
if (stage === 'non-working-day-gain') return 65;
if (stage === 'longest-work-sequence') return 72;
@@ -0,0 +1,241 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatExpansionPanel } from '@angular/material/expansion';
import { RulesPanel } from './rules-panel';
import { RuleService, RuleType, type OptimizationRule } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import {
OPTIMIZATION_PRESETS,
type OptimizationConfiguration,
} from '../../services/planning-objectives';
import { calculatePlanningInternal } from '../../services/leave-planner.core';
describe('calendar rule editing and persistence', () => {
let fixture: ComponentFixture<RulesPanel>;
let service: RuleService;
let translations: TranslationService;
async function openEditor() {
await TestBed.configureTestingModule({
imports: [RulesPanel],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
service = TestBed.inject(RuleService);
translations = TestBed.inject(TranslationService);
translations.setLanguage('fr');
fixture = TestBed.createComponent(RulesPanel);
fixture.detectChanges();
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
await openEditor();
for (const rule of service.rules) service.deleteRule(rule.id);
fixture.detectChanges();
});
afterEach(() => localStorage.clear());
async function select(label: string, value: string) {
const fields: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('mat-form-field'),
);
const field = fields.find(
(candidate) => candidate.querySelector('mat-label')?.textContent?.trim() === label,
);
const control = field?.querySelector<HTMLElement>('mat-select');
if (!control) throw new Error(`Missing labelled control: ${label}`);
const labels = (control.getAttribute('aria-labelledby') ?? '')
.split(/\s+/)
.map((id) => document.getElementById(id)?.textContent?.trim());
expect(labels).toContain(label);
control.click();
await fixture.whenStable();
const option = Array.from(document.querySelectorAll<HTMLElement>('mat-option')).find(
(candidate) => candidate.textContent?.trim() === value,
);
if (!option) throw new Error(`Missing translated option: ${value}`);
option.click();
document.querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
await fixture.whenStable();
fixture.detectChanges();
}
async function add(name: string, configuration: OptimizationConfiguration) {
const tr = translations.tr().rules;
const buttons: HTMLButtonElement[] = Array.from(
fixture.nativeElement.querySelectorAll('.add-buttons button'),
);
buttons.find((button) => button.textContent?.includes(tr.optimization.newRule))!.click();
await fixture.whenStable();
const nameInput: HTMLInputElement =
fixture.nativeElement.querySelector('.rule-editor input');
nameInput.value = name;
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
await select(
tr.optimization.elementsLabel,
tr.optimization.elements[configuration.elements],
);
for (const condition of configuration.conditions)
await select(tr.optimization.conditionsLabel, tr.optimization.conditions[condition]);
await select(
tr.optimization.aggregationLabel,
tr.optimization.aggregations[configuration.aggregation],
);
await select(
tr.optimization.directionLabel,
tr.optimization.directions[configuration.direction],
);
const actions: HTMLButtonElement[] = Array.from(
fixture.nativeElement.querySelectorAll('.editor-actions button'),
);
actions.find((button) => button.textContent?.trim() === tr.add)!.click();
await fixture.whenStable();
fixture.detectChanges();
}
function calculate() {
let objectiveValues: readonly { id: string; value: number | string }[] = [];
const plan = calculatePlanningInternal(
{
calculationDate: '2024-04-01',
horizon: { start: '2024-04-29', end: '2024-05-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2024-04-29',
expiryDate: '2025-01-01',
},
],
rules: service.rules,
},
undefined,
({ explanation }) => {
if (explanation?.kind === 'PlanExplanation')
objectiveValues = explanation.placements[0]?.objectiveVector.objectives ?? [];
},
);
if (plan.kind !== 'Plan')
throw new Error('Expected a plan from saved editor configuration');
return { plan, objectiveValues };
}
it('creates both recipes, reverses their order, edits a compatible aggregation and restores it on reload', async () => {
await add('Mes semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue);
await add('Mes week-ends', OPTIMIZATION_PRESETS.nonWorkingDayGain);
expect(calculate().plan.metrics).toMatchObject({
holidayWeekValue: 5,
nonWorkingDayGain: 6,
});
const items: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('.rule-item'),
);
items[1].querySelector<HTMLButtonElement>('.rule-actions button')!.click();
await fixture.whenStable();
fixture.detectChanges();
expect(calculate().plan.metrics).toMatchObject({
holidayWeekValue: 0,
nonWorkingDayGain: 9,
});
const holiday = service.rules.find(
(rule): rule is OptimizationRule =>
rule.type === RuleType.OPTIMIZATION &&
rule.configuration.elements === 'public-holiday-weeks',
)!;
fixture.componentInstance.startEdit(holiday, new Event('click'));
await fixture.whenStable();
await select(
translations.tr().rules.optimization.aggregationLabel,
translations.tr().rules.optimization.aggregations.count,
);
fixture.componentInstance.saveEdit();
await fixture.whenStable();
await expect
.poll(
() =>
JSON.parse(localStorage.getItem('vacationRules')!).rules[1]?.configuration
?.aggregation,
)
.toBe('count');
const saved = service.rules;
TestBed.resetTestingModule();
await openEditor();
expect(service.rules).toEqual(saved);
expect(calculate().plan.metrics).toMatchObject({
holidayWeekValue: 0,
nonWorkingDayGain: 9,
});
fixture.componentInstance.moveRule(1, -1, new Event('click'));
const edited = calculate();
expect(edited.plan.metrics.holidayWeekValue).toBe(5);
expect(
edited.objectiveValues.find(
(objective) => objective.id === `optimization:${holiday.id}`,
)?.value,
).toBe(1);
});
it('creates economy, edits its name, moves it with the focusable control and restores disable/delete choices', async () => {
await add('Mes semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue);
await add('Économiser les jours d’appoint', OPTIMIZATION_PRESETS.supportingPlacements);
const economy = service.rules[1];
expect(economy).toMatchObject({
force: 'preference',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
});
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
const move: HTMLButtonElement = fixture.nativeElement
.querySelectorAll('.rule-item')[1]
.querySelector('.rule-actions button');
move.focus();
expect(document.activeElement).toBe(move);
move.click();
await fixture.whenStable();
expect(calculate().plan.placements).toEqual([]);
fixture.componentInstance.startEdit(service.rules[0], new Event('click'));
await fixture.whenStable();
const input: HTMLInputElement = fixture.nativeElement.querySelector('.rule-editor input');
input.value = 'Mes jours conservés';
input.dispatchEvent(new Event('input', { bubbles: true }));
fixture.componentInstance.saveEdit();
await fixture.whenStable();
const toggle: HTMLButtonElement = fixture.nativeElement
.querySelector('.rule-item')
.querySelector('button[role="switch"]');
toggle.click();
await fixture.whenStable();
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
await expect
.poll(() => JSON.parse(localStorage.getItem('vacationRules')!).rules[0]?.enabled)
.toBe(false);
const saved = service.rules;
TestBed.resetTestingModule();
await openEditor();
expect(service.rules).toEqual(saved);
expect(service.rules[0]).toMatchObject({ id: economy.id, name: 'Mes jours conservés' });
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
fixture.componentInstance.toggleRule(economy.id);
await fixture.whenStable();
expect(calculate().plan.placements).toEqual([]);
const remove: HTMLButtonElement = fixture.nativeElement
.querySelector('.rule-item')
.querySelector(`[aria-label="${translations.tr().rules.deleteRule}"]`);
remove.click();
await fixture.whenStable();
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
await expect
.poll(() => JSON.parse(localStorage.getItem('vacationRules')!).rules.length)
.toBe(1);
TestBed.resetTestingModule();
await openEditor();
expect(service.rules.map(({ id }) => id)).not.toContain(economy.id);
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
});
});
@@ -183,6 +183,8 @@
</mat-select>
</mat-form-field>
</div>
} @else if ($any(editBuffer).type === RuleType.OPTIMIZATION) {
<ng-container [ngTemplateOutlet]="optimizationEditor" />
} @else {
<div class="editor-row">
<mat-form-field>
@@ -239,6 +241,9 @@
</div>
}
@if (validationError()) {
<p role="alert">{{ validationError() }}</p>
}
<div class="editor-actions">
<button mat-button (click)="cancelEdit()">
{{ tr().rules.cancel }}
@@ -338,6 +343,8 @@
</mat-select>
</mat-form-field>
</div>
} @else if ($any(editBuffer).type === RuleType.OPTIMIZATION) {
<ng-container [ngTemplateOutlet]="optimizationEditor" />
} @else {
<div class="editor-row">
<mat-form-field>
@@ -394,6 +401,9 @@
</div>
}
@if (validationError()) {
<p role="alert">{{ validationError() }}</p>
}
<div class="editor-actions">
<button mat-button (click)="cancelEdit()">{{ tr().rules.cancel }}</button>
<button mat-flat-button color="primary" (click)="saveEdit()">
@@ -411,6 +421,89 @@
<button mat-stroked-button (click)="startAddNew(RuleType.TIME_WINDOW)">
<mat-icon>add</mat-icon> {{ tr().rules.newTimeWindowRule }}
</button>
<button mat-stroked-button (click)="startAddNew(RuleType.OPTIMIZATION)">
<mat-icon aria-hidden="true">add</mat-icon> {{ tr().rules.optimization.newRule }}
</button>
</div>
</div>
</mat-expansion-panel>
<ng-template #optimizationEditor>
@if (optimizationConfiguration; as configuration) {
<div class="editor-row">
<mat-form-field>
<mat-label>{{ tr().rules.optimization.elementsLabel }}</mat-label>
<mat-select
[ngModel]="configuration.elements"
(ngModelChange)="changeOptimizationElements($event)"
>
@for (elements of optimizationElements; track elements) {
<mat-option [value]="elements">{{
tr().rules.optimization.elements[elements]
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.optimization.measureLabel }}</mat-label>
<mat-select
[ngModel]="configuration.measure"
(ngModelChange)="changeOptimizationMeasure($event)"
>
@for (row of optimizationMeasures; track row.measure) {
<mat-option [value]="row.measure">{{
tr().rules.optimization.measures[row.measure]
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (optimizationConditions.length) {
<mat-form-field class="full-width">
<mat-label>{{ tr().rules.optimization.conditionsLabel }}</mat-label>
<mat-select multiple [(ngModel)]="configuration.conditions">
@for (condition of optimizationConditions; track condition) {
<mat-option [value]="condition">{{
tr().rules.optimization.conditions[condition]
}}</mat-option>
}
</mat-select>
</mat-form-field>
}
<div class="editor-row">
<mat-form-field>
<mat-label>{{ tr().rules.optimization.aggregationLabel }}</mat-label>
<mat-select [(ngModel)]="configuration.aggregation">
@for (aggregation of optimizationAggregations; track aggregation) {
<mat-option [value]="aggregation">{{
tr().rules.optimization.aggregations[aggregation]
}}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>{{ tr().rules.optimization.directionLabel }}</mat-label>
<mat-select [(ngModel)]="configuration.direction">
@for (direction of optimizationDirections; track direction) {
<mat-option [value]="direction">{{
tr().rules.optimization.directions[direction]
}}</mat-option>
}
</mat-select>
</mat-form-field>
@if (configuration.measure === 'work-sequence-deficit') {
<mat-form-field>
<mat-label>{{ tr().rules.optimization.thresholdLabel }}</mat-label>
<input
matInput
type="number"
min="1"
step="1"
required
[(ngModel)]="configuration.thresholdDays"
/>
</mat-form-field>
}
</div>
}
</ng-template>
@@ -7,6 +7,8 @@ import { By } from '@angular/platform-browser';
import { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { vi } from 'vitest';
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
import { MatExpansionPanel } from '@angular/material/expansion';
describe('RulesPanel', () => {
let component: RulesPanel;
@@ -36,7 +38,7 @@ describe('RulesPanel', () => {
});
it('should display initial rules from service', () => {
expect(component.rules.length).toBe(2);
expect(component.rules.length).toBe(8);
});
it('should start adding new If-Then rule', () => {
@@ -116,6 +118,69 @@ describe('RulesPanel', () => {
expect(component.rules[0].id).toBe(secondRuleId);
});
it('creates, edits and moves an optimization using the keyboard controls with preference-only mode', async () => {
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
component.startAddNew(RuleType.OPTIMIZATION);
fixture.detectChanges();
expect(component['forceOptions'].map((option) => option.value)).toEqual(['preference']);
expect(fixture.nativeElement.textContent).toContain('Éléments du planning');
component['editBuffer'].name = 'Mes pertes';
component.saveEdit();
await fixture.whenStable();
const rule = component.rules.at(-1)!;
expect(rule).toMatchObject({
name: 'Mes pertes',
type: RuleType.OPTIMIZATION,
force: 'preference',
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
expect(component.getRuleSummary(rule)).toContain('Quantité non placée');
const announce = vi.spyOn(TestBed.inject(LiveAnnouncer), 'announce').mockResolvedValue();
const buttons = fixture.nativeElement.querySelectorAll('.rule-item');
const moveUp: HTMLButtonElement =
buttons[buttons.length - 1].querySelector('.rule-actions button');
moveUp.focus();
moveUp.click();
await fixture.whenStable();
expect(component.rules[7].id).toBe(rule.id);
expect(document.activeElement).toBe(moveUp);
expect(announce).toHaveBeenCalledWith('Mes pertes : position 8 sur 9.');
component.startEdit(rule, new Event('click'));
component['editBuffer'].name = 'Pertes modifiées';
component.saveEdit();
expect(component.rules[7].name).toBe('Pertes modifiées');
component.toggleRule(rule.id);
expect(component.rules[7].enabled).toBe(false);
component.deleteRule(rule.id, new Event('click'));
expect(component.rules.some((candidate) => candidate.id === rule.id)).toBe(false);
});
it('keeps saved optimization parameters when an edit is cancelled and reports invalid parameters', () => {
component.startAddNew(RuleType.OPTIMIZATION);
component.saveEdit();
const rule = component.rules.at(-1)!;
component.startEdit(rule, new Event('click'));
const configuration = component['optimizationConfiguration']!;
configuration.conditions = [];
component.cancelEdit();
expect(component.rules.at(-1)).toMatchObject({
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
component.startEdit(rule, new Event('click'));
component.changeOptimizationMeasure('work-sequence-deficit');
const invalid = component['optimizationConfiguration'];
if (invalid?.measure !== 'work-sequence-deficit') throw new Error('Expected deficit');
invalid.thresholdDays = 0;
component.saveEdit();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain(
'seuil entier positif',
);
expect(component.rules.at(-1)).toMatchObject({
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
});
it('keeps cancelled drops unchanged and announces the position after a completed drop', () => {
const initialIds = component.rules.map((rule) => rule.id);
const movedName = component.rules[0].name;
@@ -133,8 +198,12 @@ describe('RulesPanel', () => {
expect(announce).not.toHaveBeenCalled();
list.dropped.emit({ ...drop, isPointerOverContainer: true });
expect(component.rules.map((rule) => rule.id)).toEqual([...initialIds].reverse());
expect(announce).toHaveBeenCalledWith(`${movedName} : position 2 sur 2.`);
expect(component.rules.map((rule) => rule.id)).toEqual([
initialIds[1],
initialIds[0],
...initialIds.slice(2),
]);
expect(announce).toHaveBeenCalledWith(`${movedName} : position 2 sur 8.`);
// CDK restores the original DOM before emitting dropped. The new order must
// be rendered before this callback returns, without waiting for another frame.
expect(fixture.nativeElement.querySelectorAll('.rule-name')[1].textContent).toContain(
+106 -5
View File
@@ -11,6 +11,7 @@ import {
RuleType,
IfThenRule,
TimeWindowRule,
OptimizationRule,
VacationRule,
PlacementStrategy,
} from '../../services/rule-service';
@@ -25,7 +26,15 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { FormsModule } from '@angular/forms';
import { NgTemplateOutlet } from '@angular/common';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import {
OPTIMIZATION_CATALOG,
OPTIMIZATION_PRESETS,
validateOptimizationConfiguration,
type OptimizationElements,
type OptimizationMeasure,
} from '../../services/planning-objectives';
import { DateAdapter } from '@angular/material/core';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
@@ -43,6 +52,7 @@ import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/d
MatSelectModule,
MatDatepickerModule,
FormsModule,
NgTemplateOutlet,
CdkDropList,
CdkDrag,
CdkDragHandle,
@@ -74,10 +84,67 @@ export class RulesPanel {
protected readonly rewardDayTypes = Object.values(SelectableDayType).filter(
(t) => t !== SelectableDayType.ANY,
);
protected readonly forceOptions = [
{ value: 'obligation' as const, label: () => this.tr().rules.obligation },
{ value: 'preference' as const, label: () => this.tr().rules.preference },
protected get forceOptions() {
const preference = {
value: 'preference' as const,
label: () => this.tr().rules.preference,
};
return this.editBuffer.type === RuleType.OPTIMIZATION
? [preference]
: [
{ value: 'obligation' as const, label: () => this.tr().rules.obligation },
preference,
];
}
protected readonly optimizationElements = [
...new Set(OPTIMIZATION_CATALOG.map((row) => row.elements)),
];
protected get optimizationConfiguration() {
return this.editBuffer.type === RuleType.OPTIMIZATION
? this.editBuffer.configuration
: undefined;
}
protected get optimizationMeasures() {
return OPTIMIZATION_CATALOG.filter(
(row) => row.elements === this.optimizationConfiguration?.elements,
);
}
protected get optimizationConditions() {
return (
this.optimizationMeasures.find(
(row) => row.measure === this.optimizationConfiguration?.measure,
)?.conditions ?? []
);
}
protected readonly optimizationAggregations = ['sum', 'count', 'maximum'] as const;
protected readonly optimizationDirections = ['minimize', 'maximize'] as const;
protected validationError = signal('');
public changeOptimizationElements(elements: OptimizationElements) {
this.changeOptimizationMeasure(
OPTIMIZATION_CATALOG.find((row) => row.elements === elements)!.measure,
);
}
public changeOptimizationMeasure(measure: OptimizationMeasure) {
if (this.editBuffer.type !== RuleType.OPTIMIZATION) return;
const row = OPTIMIZATION_CATALOG.find((item) => item.measure === measure)!;
const previous = this.editBuffer.configuration;
const conditions: readonly string[] = row.conditions;
this.editBuffer.configuration = {
elements: row.elements,
conditions:
previous?.conditions.filter((condition) => conditions.includes(condition)) ?? [],
aggregation: previous?.aggregation ?? 'sum',
direction: previous?.direction ?? 'minimize',
...(measure === 'work-sequence-deficit'
? {
measure,
thresholdDays: previous?.measure === measure ? previous.thresholdDays : 5,
}
: { measure }),
};
}
protected get daysOfWeek() {
const tr = this.tr();
@@ -128,9 +195,20 @@ export class RulesPanel {
public moveRule(index: number, offset: -1 | 1, event: Event) {
event.stopPropagation();
const trigger = event.currentTarget;
const destination = index + offset;
if (destination >= 0 && destination < this.rules.length)
if (destination >= 0 && destination < this.rules.length) {
this.reorderRule(index, destination);
this.changeDetector.detectChanges();
if (trigger instanceof HTMLButtonElement) {
const next = trigger.disabled
? trigger.parentElement?.querySelector<HTMLButtonElement>(
'button:not([disabled])',
)
: trigger;
next?.focus();
}
}
}
private reorderRule(from: number, to: number) {
@@ -152,16 +230,29 @@ export class RulesPanel {
event.stopPropagation();
this.editingRuleId.set(rule.id);
this.isAddingNew.set(false);
this.editBuffer = { ...rule };
this.editBuffer = structuredClone(rule);
this.validationError.set('');
}
public cancelEdit() {
this.editingRuleId.set(null);
this.isAddingNew.set(false);
this.editBuffer = {};
this.validationError.set('');
}
public saveEdit() {
if (!this.editBuffer.name?.trim()) {
this.validationError.set(this.tr().rules.optimization.nameRequired);
return;
}
if (this.editBuffer.type === RuleType.OPTIMIZATION) {
if (!validateOptimizationConfiguration(this.editBuffer.configuration).valid) {
this.validationError.set(this.tr().rules.optimization.invalidConfiguration);
return;
}
this.editBuffer.force = 'preference';
}
if (this.editBuffer.id) {
this.ruleService.updateRule(this.editBuffer as VacationRule);
} else {
@@ -183,6 +274,7 @@ export class RulesPanel {
this.isAddingNew.set(true);
this.editingRuleId.set(null);
this.newRuleType.set(type);
this.validationError.set('');
if (type === RuleType.IF_THEN) {
this.editBuffer = {
@@ -196,6 +288,13 @@ export class RulesPanel {
rewardDays: 1,
rewardType: SelectableDayType.OTHER,
} as Partial<IfThenRule>;
} else if (type === RuleType.OPTIMIZATION) {
this.editBuffer = {
name: this.tr().rules.optimization.defaultName,
type,
force: 'preference',
configuration: structuredClone(OPTIMIZATION_PRESETS.unplacedAtRisk),
} satisfies Partial<OptimizationRule>;
} else {
this.editBuffer = {
name: this.tr().rules.defaultNewPeriodName,
@@ -216,6 +315,8 @@ export class RulesPanel {
public getRuleSummary(rule: VacationRule): string {
const tr = this.tr();
if (rule.type === RuleType.OPTIMIZATION)
return this.translationService.optimizationSummary(rule.configuration);
if (rule.type === RuleType.IF_THEN) {
const ifThen = rule as IfThenRule;
const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? '';
@@ -0,0 +1,172 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatExpansionPanel } from '@angular/material/expansion';
import { vi } from 'vitest';
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { calculatePlanningInternal } from '../../services/leave-planner.core';
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
import { RulesPanel } from './rules-panel';
describe('work-sequence rule editor and reload', () => {
let fixture: ComponentFixture<RulesPanel>;
let component: RulesPanel;
async function choose(label: string, text: string) {
const fields: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('mat-form-field'),
);
const select = fields
.find((field) => field.querySelector('mat-label')?.textContent?.trim() === label)
?.querySelector<HTMLElement>('mat-select');
if (!select) throw new Error(`Missing select ${label}`);
select.click();
await fixture.whenStable();
const option = Array.from(document.querySelectorAll<HTMLElement>('mat-option')).find(
(candidate) => candidate.textContent?.trim() === text,
);
if (!option) throw new Error(`Missing option ${text}`);
option.click();
await fixture.whenStable();
document.querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
await fixture.whenStable();
}
async function setInput(selector: string, value: string) {
const input: HTMLInputElement = fixture.nativeElement.querySelector(selector);
input.value = value;
input.dispatchEvent(new Event('input'));
await fixture.whenStable();
}
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
await TestBed.configureTestingModule({
imports: [RulesPanel],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('fr');
fixture = TestBed.createComponent(RulesPanel);
component = fixture.componentInstance;
fixture.detectChanges();
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
});
afterEach(() => {
localStorage.clear();
vi.useRealTimers();
});
it('creates threshold five, edits it to three, and restores its effect, order and activation after reload', async () => {
component.startAddNew(RuleType.OPTIMIZATION);
await fixture.whenStable();
await setInput('input:not([type="number"])', 'Mes reprises');
await choose('Éléments du planning', 'Séquences de travail');
await choose('Mesure', 'Déficit des séquences de travail');
await choose('Conditions (toutes doivent être vérifiées)', 'Internes');
await choose('Conditions (toutes doivent être vérifiées)', 'Non vides');
const threshold: HTMLInputElement =
fixture.nativeElement.querySelector('input[type="number"]');
expect(threshold.value).toBe('5');
expect(threshold.min).toBe('1');
expect(threshold.step).toBe('1');
expect(fixture.nativeElement.textContent).toContain('Seuil de préférence en jours ouvrés');
component.saveEdit();
await fixture.whenStable();
const rule = component.rules[0];
expect(rule).toMatchObject({ configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit });
expect(component.getRuleSummary(rule)).toContain(
'Déficit des séquences de travail (jours ouvrés)',
);
const request = {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
};
expect(calculatePlanningInternal({ ...request, rules: component.rules })).toMatchObject({
kind: 'Plan',
placements: [{ date: '2024-01-09' }, { date: '2024-01-10' }, { date: '2024-01-11' }],
});
component.startEdit(rule, new Event('click'));
await fixture.whenStable();
await setInput('input[type="number"]', '3');
component.saveEdit();
await fixture.whenStable();
expect(component.getRuleSummary(component.rules[0])).toContain(
'seuil de préférence : 3 jours ouvrés',
);
expect(calculatePlanningInternal({ ...request, rules: component.rules })).toMatchObject({
kind: 'Plan',
placements: [],
});
component.startAddNew(RuleType.OPTIMIZATION);
await fixture.whenStable();
await setInput('input:not([type="number"])', 'Ma séquence maximale');
await choose('Éléments du planning', 'Séquences de travail');
await choose('Agrégation', 'Maximum');
component.saveEdit();
await fixture.whenStable();
const maximum = component.rules[1];
expect(maximum).toMatchObject({ configuration: OPTIMIZATION_PRESETS.longestWorkSequence });
component.toggleRule(maximum.id);
await fixture.whenStable();
const moveUp: HTMLButtonElement = fixture.nativeElement
.querySelectorAll('.rule-actions')[1]
.querySelector('button');
moveUp.focus();
moveUp.click();
await fixture.whenStable();
const saved = component.rules;
await new Promise((resolve) => setTimeout(resolve, 350));
TestBed.resetTestingModule();
const restored = TestBed.inject(RuleService).rules;
expect(restored).toEqual(saved);
expect(restored.map(({ id }) => id)).toEqual([maximum.id, rule.id]);
expect(restored[0].enabled).toBe(false);
expect(restored[1]).toMatchObject({
name: 'Mes reprises',
enabled: true,
configuration: { thresholdDays: 3 },
});
expect(calculatePlanningInternal({ ...request, rules: restored })).toMatchObject({
kind: 'Plan',
placements: [],
});
});
it('keeps the saved threshold and shows an accessible error for fractional input', async () => {
component.startAddNew(RuleType.OPTIMIZATION);
component.changeOptimizationMeasure('work-sequence-deficit');
await fixture.whenStable();
await setInput('input[type="number"]', '1.5');
component.saveEdit();
await fixture.whenStable();
expect(component.rules).toEqual([]);
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain(
'seuil entier positif',
);
expect(fixture.nativeElement.querySelector('input[type="number"]').value).toBe('1.5');
});
it('distinguishes aggregation units from the working-day threshold', () => {
const translations = TestBed.inject(TranslationService);
expect(
translations.optimizationSummary(OPTIMIZATION_PRESETS.longestWorkSequence),
).toContain('Longueur des séquences de travail (jours ouvrés)');
expect(
translations.optimizationSummary({
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
aggregation: 'count',
}),
).toContain('Déficit des séquences de travail (Nombre)');
expect(
translations.optimizationSummary({
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
aggregation: 'count',
}),
).toContain('seuil de préférence : 5 jours ouvrés');
});
});
@@ -1,3 +1,4 @@
import { createOptimizationPresetRules } from '../../services/rule-service';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
@@ -14,6 +15,8 @@ import { CalendarSettingsService } from '../../services/calendar-settings-servic
import { createPlanningCalculation, PlanningRequest } from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
const presets = createOptimizationPresetRules();
describe('UserInput', () => {
let component: UserInput;
let fixture: ComponentFixture<UserInput>;
@@ -145,7 +148,7 @@ describe('UserInput', () => {
},
],
declaredDays: [{ date: '2024-06-07', type: 'cp' }],
rules: [],
rules: [presets[3]],
});
const root = fixture.nativeElement as HTMLElement;
const card = root.querySelector<HTMLElement>('.warning-card')!;
@@ -162,7 +165,7 @@ describe('UserInput', () => {
fixture.detectChanges();
expect(root.querySelectorAll('.remaining-history > li').length).toBeGreaterThan(0);
expect(details.textContent).toContain('Ces dates ont réellement été examinées');
expect(details.textContent).toContain('Maximum de jours travaillés entre congés');
expect(details.textContent).toContain(presets[3].name);
expect(details.textContent).toContain('Évalué et rejeté');
expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
expect(JSON.stringify(userInputService.planningTraceSignal())).toBe(trace);
@@ -527,7 +530,7 @@ describe('UserInput', () => {
);
await rule.uncheck();
await expect.poll(() => localStorage.getItem('vacationRules')).not.toBeNull();
expect(JSON.parse(localStorage.getItem('vacationRules')!)[0].enabled).toBe(false);
expect(JSON.parse(localStorage.getItem('vacationRules')!).rules[0].enabled).toBe(false);
fixture.nativeElement.querySelector('.calculate-button').click();
await fixture.whenStable();
+3 -1
View File
@@ -102,7 +102,8 @@ export class UserInput {
const copy = this.tr().userInput;
return plan.remainingBalances.map((balance) => {
const code =
incomplete && ['preserved', 'not-established'].includes(balance.reason.code)
incomplete &&
['preserved', 'active-priorities', 'not-established'].includes(balance.reason.code)
? 'calculation-incomplete'
: balance.reason.code;
return {
@@ -214,6 +215,7 @@ export class UserInput {
private criterionLabel(name: string): string | undefined {
const id = name.replace(/^protected:/, '');
if (id.startsWith('optimization:')) return this.ruleName(id.slice('optimization:'.length));
const key =
id === 'obligations'
? 'obligation-validity'
@@ -0,0 +1,225 @@
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { promisify } from 'node:util';
import { Worker as NodeWorker } from 'node:worker_threads';
import { vi } from 'vitest';
import { createPlanningCalculation } from './leave-planner.service';
import type { OptimizationPlanningRule } from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
describe('calendar objectives in the real worker', () => {
let directory: string;
let bundle: string;
const threads = new Set<NodeWorker>();
beforeAll(async () => {
directory = mkdtempSync(join(tmpdir(), 'vac-calendar-worker-'));
bundle = join(directory, 'worker.mjs');
const entry = join(directory, 'entry.ts');
writeFileSync(
entry,
`import { parentPort } from 'node:worker_threads';
globalThis.self = { postMessage: (message) => parentPort.postMessage(message) };
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
`,
);
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
});
beforeEach(() => {
vi.setSystemTime(new Date('2024-04-01T12:00:00Z'));
class BrowserWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
private readonly thread = new NodeWorker(bundle);
constructor() {
threads.add(this.thread);
this.thread.on('message', (data) => this.onmessage?.({ data } as MessageEvent));
this.thread.on('error', () => this.onerror?.());
}
postMessage(message: unknown) {
this.thread.postMessage(message);
}
terminate() {
void this.thread.terminate();
}
}
vi.stubGlobal('Worker', BrowserWorker);
});
afterEach(async () => {
await Promise.all([...threads].map((thread) => thread.terminate()));
threads.clear();
vi.useRealTimers();
vi.unstubAllGlobals();
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it.each([false, true])(
'retains the submitted configuration, plan and artifact order (gain first: %s)',
async (gainFirst) => {
const holiday: OptimizationPlanningRule = {
id: 'holiday',
type: 'optimization' as const,
name: 'Captured holiday rule',
configuration: structuredClone(OPTIMIZATION_PRESETS.holidayWeekValue),
};
const gain: OptimizationPlanningRule = {
id: 'gain',
type: 'optimization' as const,
name: 'Captured gain rule',
configuration: structuredClone(OPTIMIZATION_PRESETS.nonWorkingDayGain),
};
const rules = gainFirst ? [gain, holiday] : [holiday, gain];
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-29', end: '2024-05-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2024-04-29',
expiryDate: '2025-01-01',
},
],
rules,
});
holiday.configuration = {
...holiday.configuration,
conditions: [],
direction: 'minimize',
};
const plan = await calculation.outcome;
if (plan.kind !== 'Plan')
throw new Error(`Expected a worker plan, received ${plan.kind}`);
expect(plan.metrics).toMatchObject(
gainFirst
? { holidayWeekValue: 0, nonWorkingDayGain: 9 }
: { holidayWeekValue: 5, nonWorkingDayGain: 6 },
);
expect(calculation.snapshot.rules.find((rule) => rule.id === 'holiday')).toMatchObject({
name: 'Captured holiday rule',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
});
const first = `optimization:${rules[0].id}`;
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation')
throw new Error('Expected a worker explanation');
expect(explanation.placements[0].firstDecisiveObjective).toBe(first);
expect((await calculation.trace)?.phases.map((phase) => phase.name)).toEqual([
'validation',
'obligations',
first,
`optimization:${rules[1].id}`,
'expiry',
'dates',
]);
const comparison = await calculation.analyzeCounterfactual(plan.placements[0]);
if (comparison.kind !== 'Counterfactual')
throw new Error('Expected a worker comparison');
expect(
comparison.results.find((result) => result.date === plan.placements[0].date)
?.comparison?.firstDecisiveObjective,
).toBe(first);
},
);
it.each([false, true])(
'lets economy block or permit the complete holiday bundle in a real worker (economy first: %s)',
async (economyFirst) => {
const economy: OptimizationPlanningRule = {
id: 'economy',
name: 'Captured supporting economy',
type: 'optimization',
configuration: structuredClone(OPTIMIZATION_PRESETS.supportingPlacements),
};
const holiday: OptimizationPlanningRule = {
id: 'holiday',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
};
const rules = economyFirst ? [economy, holiday] : [holiday, economy];
const calculation = createPlanningCalculation({
horizon: { start: '2026-11-09', end: '2026-11-14' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 2,
availableFrom: '2026-11-09',
expiryDate: '2027-01-01',
},
],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules,
});
economy.name = 'Edited later';
economy.configuration.conditions = [];
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
expect(outcome.metrics).toMatchObject(
economyFirst
? { holidayWeekValue: 0, supportingPlacements: 0 }
: { holidayWeekValue: 3, supportingPlacements: 2 },
);
expect(calculation.snapshot.rules.find(({ id }) => id === 'economy')).toMatchObject({
name: 'Captured supporting economy',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
});
const trace = await calculation.trace;
expect(trace?.phases.map(({ name }) => name)).toEqual([
'validation',
'obligations',
`optimization:${rules[0].id}`,
`optimization:${rules[1].id}`,
'expiry',
'dates',
]);
const candidates = trace?.phases.flatMap(({ decisions }) =>
decisions.flatMap(({ candidates }) => candidates),
);
expect(candidates).toContainEqual(
expect.objectContaining({
status: economyFirst ? 'evaluated-rejected' : 'chosen',
dates: ['2026-11-12', '2026-11-13'],
comparison: expect.objectContaining({
firstDecisiveObjective: economyFirst
? 'optimization:economy'
: 'optimization:holiday',
}),
}),
);
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
if (economyFirst) {
expect(outcome.placements).toEqual([]);
expect(outcome.remainingBalances[0]).toMatchObject({
id: 'cp',
quantity: 2,
classification: 'deferred',
});
expect(explanation.placements).toEqual([]);
return;
}
expect(outcome.remainingBalances).toEqual([]);
expect(explanation.placements.every(({ supporting }) => supporting)).toBe(true);
expect(explanation.placements[0].firstDecisiveObjective).toBe('optimization:holiday');
expect(explanation.placements[0].objectiveVector.objectives).toContainEqual(
expect.objectContaining({
id: 'optimization:economy',
value: 2,
label: 'Captured supporting economy',
}),
);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
expect(
analysis.results.find(({ date }) => date === outcome.placements[0].date)?.comparison
?.after.objectives,
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 2 }));
},
);
});
@@ -0,0 +1,466 @@
import { describe, expect, it } from 'vitest';
import {
analyzeCounterfactualInternal,
calculatePlanningInternal,
type OptimizationPlanningRule,
type PlanningArtifacts,
type PlanningRequest,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
const holiday: OptimizationPlanningRule = {
id: 'holiday',
name: 'Complete public-holiday weeks',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
};
const gain: OptimizationPlanningRule = {
id: 'gain',
name: 'Enjoy weekends and public holidays',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
};
const samedi: PlanningRequest['rules'][number] = {
id: 'samedi',
type: 'if_then',
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other',
};
const concurrent: PlanningRequest = {
horizon: { start: '2024-04-29', end: '2024-05-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2024-04-29',
expiryDate: '2025-01-01',
},
],
rules: [],
};
function calculate(request: PlanningRequest, calculationDate = '2024-04-01') {
let artifacts: PlanningArtifacts = {};
const plan = calculatePlanningInternal(
{ ...request, calculationDate },
undefined,
(value) => (artifacts = value),
);
if (plan.kind !== 'Plan') throw new Error(`Expected a plan, received ${plan.kind}`);
return { plan, artifacts };
}
describe('composed calendar objectives', () => {
it('follows either displayed order when a completed holiday week competes with greater natural gain', () => {
const holidayFirst = calculate({ ...concurrent, rules: [holiday, gain] }).plan;
const gainFirst = calculate({ ...concurrent, rules: [gain, holiday] }).plan;
expect(holidayFirst.placements.map((placement) => placement.date)).toEqual([
'2024-05-06',
'2024-05-07',
'2024-05-10',
]);
expect(holidayFirst.metrics).toMatchObject({ holidayWeekValue: 5, nonWorkingDayGain: 6 });
expect(gainFirst.placements.map((placement) => placement.date)).toEqual([
'2024-04-29',
'2024-05-10',
'2024-05-17',
]);
expect(gainFirst.metrics).toMatchObject({ holidayWeekValue: 0, nonWorkingDayGain: 9 });
});
it('regroups existing placements as a complete week without leaving higher-priority at-risk leave unused', () => {
const loss: OptimizationPlanningRule = {
id: 'loss',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
};
const { plan } = calculate({
...concurrent,
balances: [{ ...concurrent.balances[0], expiryDate: '2024-05-17' }],
rules: [loss, holiday, gain],
});
expect(plan.metrics).toMatchObject({
unplacedAtRisk: 0,
holidayWeekValue: 5,
nonWorkingDayGain: 6,
});
expect(plan.placements.map((placement) => placement.date)).toEqual([
'2024-05-06',
'2024-05-07',
'2024-05-10',
]);
expect(plan.placements.every((placement) => !placement.supporting)).toBe(true);
});
it('preserves an earlier preference that minimizes supporting placements', () => {
const { plan } = calculate({
...concurrent,
rules: [
{
id: 'economy',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
},
holiday,
],
});
expect(plan.placements).toEqual([]);
});
it('uses edited holiday conditions and aggregation without dispatching by a preset identity', () => {
const counted: OptimizationPlanningRule = {
...holiday,
id: 'my-complete-weeks',
configuration: { ...holiday.configuration, aggregation: 'count' },
};
const { plan, artifacts } = calculate({ ...concurrent, rules: [counted] });
expect(plan.metrics.holidayWeekValue).toBe(5);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation');
expect(artifacts.explanation.placements[0].objectiveVector.objectives[1]).toMatchObject({
id: 'optimization:my-complete-weeks',
value: 1,
});
const allWeeks = calculate({
...concurrent,
rules: [{ ...counted, configuration: { ...counted.configuration, conditions: [] } }],
}).plan;
expect(allWeeks.placements).toEqual([]);
});
it('attributes a coordinated holiday addition and its explanation to the active rule', () => {
const { plan, artifacts } = calculate(
{
horizon: { start: '2026-11-09', end: '2026-11-14' },
balances: [
{
id: 'deferred-cp',
type: 'cp',
quantity: 2,
availableFrom: '2026-11-01',
expiryDate: '2027-01-01',
},
],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules: [holiday],
},
'2026-11-01',
);
expect(plan.placements).toEqual([
expect.objectContaining({ date: '2026-11-12', ruleIds: ['holiday'], supporting: true }),
expect.objectContaining({ date: '2026-11-13', ruleIds: ['holiday'], supporting: true }),
]);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation');
for (const placement of artifacts.explanation.placements) {
expect(placement.firstDecisiveObjective).toBe('optimization:holiday');
expect(placement.contributions).toContainEqual(
expect.objectContaining({ kind: 'rule', ruleId: 'holiday' }),
);
}
});
it.each<OptimizationConfiguration>([
{ ...gain.configuration, aggregation: 'maximum' },
{ ...gain.configuration, aggregation: 'count', direction: 'minimize' },
])(
'bridges fixed periods for the personal configuration $aggregation/$direction',
(configuration) => {
const { plan, artifacts } = calculate({
horizon: { start: '2024-01-05', end: '2024-01-13' },
balances: [
{
id: 'rtt',
type: 'rtt',
quantity: 4,
availableFrom: '2024-01-05',
expiryDate: '2025-01-01',
},
],
declaredDays: ['2024-01-05', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [
{
...gain,
id: 'personal-maximum',
configuration,
},
],
});
expect(plan.placements.map((placement) => placement.date)).toEqual([
'2024-01-08',
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
expect(plan.timeOffPeriods).toHaveLength(1);
expect(plan.timeOffPeriods[0].nonWorkingDates).toEqual([
'2024-01-06',
'2024-01-07',
'2024-01-13',
'2024-01-14',
]);
expect(plan.metrics.nonWorkingDayGain).toBe(4);
expect(
plan.placements.every(({ ruleIds }) => ruleIds.includes('personal-maximum')),
).toBe(true);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation');
for (const placement of artifacts.explanation.placements)
expect(placement.contributions).toContainEqual(
expect.objectContaining({ kind: 'rule', ruleId: 'personal-maximum' }),
);
},
);
it.each([false, true])(
'finishes a rejected bridge with an irrelevant typed rule: %s',
(withSamedi) => {
const { plan } = calculate({
horizon: { start: '2024-01-05', end: '2024-02-06' },
balances: (['cp', 'rtt', 'other'] as const).map((type) => ({
id: type,
type,
quantity: 20,
availableFrom: '2024-01-05',
expiryDate: '2025-01-01',
})),
declaredDays: ['2024-01-05', '2024-02-05'].map((date) => ({ date, type: 'other' })),
rules: [
{
id: 'economy',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
},
{
...gain,
configuration: {
...gain.configuration,
aggregation: 'count',
direction: 'minimize',
},
},
...(withSamedi ? [samedi] : []),
],
deadlineMs: 2_000,
});
expect(plan.placements).toEqual([]);
expect(plan.timeOffPeriods).toHaveLength(2);
},
);
it('does not label a period-count bridge supporting when it adds no domain calendar benefit', () => {
const { plan } = calculate({
horizon: { start: '2024-01-02', end: '2024-01-31' },
balances: [{ ...concurrent.balances[0], quantity: 5, availableFrom: '2024-01-02' }],
declaredDays: ['2024-01-05', '2024-01-15'].map((date) => ({ date, type: 'other' })),
rules: [
{
...gain,
configuration: {
...gain.configuration,
aggregation: 'count',
direction: 'minimize',
},
},
],
});
expect(plan.timeOffPeriods).toHaveLength(1);
expect(plan.placements).toHaveLength(5);
expect(plan.placements.every((placement) => !placement.supporting)).toBe(true);
expect(plan.metrics.nonWorkingDayGain).toBe(4);
});
it('can place the holiday preference before or after Samedi Malin', () => {
const request: PlanningRequest = {
horizon: { start: '2021-11-01', end: '2021-12-29' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 4,
availableFrom: '2021-11-01',
expiryDate: '2022-01-01',
},
],
rules: [],
};
const first = calculate({ ...request, rules: [holiday, samedi] }, '2021-10-01').plan;
const second = calculate({ ...request, rules: [samedi, holiday] }, '2021-10-01').plan;
expect(first.placements.map((placement) => placement.date)).toEqual([
'2021-11-08',
'2021-11-09',
'2021-11-10',
'2021-11-12',
]);
expect(first.metrics.holidayWeekValue).toBe(2);
expect(first.preferences[0].satisfied).toBe(0);
expect(second.placements.map((placement) => placement.date)).toEqual([
'2021-12-23',
'2021-12-24',
]);
expect(second.metrics.holidayWeekValue).toBe(0);
expect(second.preferences[0].satisfied).toBe(1);
expect(second.remainingBalances.find((balance) => balance.earned)).toMatchObject({
id: 'earned:samedi:2021-12-25',
quantity: 1,
});
});
it.each(['obligation', 'preference'] as const)(
'preserves a higher %s when completing a week would take its last balance',
(force) => {
const { plan } = calculate({
...concurrent,
rules: [
{
id: 'monday',
type: 'time_window',
force,
startDate: '2024-04-29',
endDate: '2024-04-29',
placeDays: 1,
placeType: 'cp',
},
holiday,
],
});
expect(plan.placements).toMatchObject([{ date: '2024-04-29', ruleIds: ['monday'] }]);
expect(plan.metrics.holidayWeekValue).toBe(0);
},
);
it('adds no residual loss-prevention or calendar placements when their rules are disabled', () => {
const request: PlanningRequest = {
horizon: { start: '2024-04-29', end: '2024-05-04' },
balances: [{ ...concurrent.balances[0], quantity: 5, expiryDate: '2024-05-03' }],
rules: [holiday],
};
const absent = calculate(request).plan;
const disabled = calculate({
...request,
rules: [
holiday,
{
id: 'loss',
type: 'optimization',
enabled: false,
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
},
{ ...gain, enabled: false },
],
}).plan;
expect(disabled).toEqual(absent);
expect(absent.metrics.unplacedAtRisk).toBe(1);
expect(absent.metrics.holidayWeekValue).toBe(3);
expect(absent.placements).toHaveLength(4);
expect(
calculate({ ...request, rules: [{ ...holiday, enabled: false }] }).plan.placements,
).toEqual([]);
});
it.each([
{ start: '2024-05-06', end: '2024-05-11' },
{ start: '2024-05-10', end: '2024-05-11' },
])(
'uses declarations outside the horizon to complete a genuine holiday week ($start)',
(horizon) => {
const { plan } = calculate({
horizon,
balances: [{ ...concurrent.balances[0], quantity: 1, availableFrom: '2024-05-06' }],
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
rules: [holiday],
});
expect(plan.placements).toMatchObject([{ date: '2024-05-10', supporting: true }]);
expect(plan.metrics).toMatchObject({ holidayWeekValue: 5, nonWorkingDayGain: 6 });
},
);
it('cannot fill missing weekdays outside the horizon or with unavailable balances', () => {
const request: PlanningRequest = {
horizon: { start: '2024-05-10', end: '2024-05-11' },
balances: [{ ...concurrent.balances[0], quantity: 1 }],
rules: [holiday],
};
expect(calculate(request).plan.placements).toEqual([]);
const unavailable = calculate({
...request,
balances: [{ ...request.balances[0], availableFrom: '2024-05-11' }],
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
}).plan;
expect(unavailable.placements).toEqual([]);
expect(unavailable.metrics.holidayWeekValue).toBe(0);
});
it('measures only distinct natural dates and leaves ordinary declared weeks ordinary', () => {
const christmas = calculate({
horizon: { start: '2021-12-24', end: '2021-12-25' },
balances: [],
declaredDays: [{ date: '2021-12-24', type: 'other' }],
rules: [holiday, gain],
}).plan;
expect(christmas.metrics).toMatchObject({ holidayWeekValue: 0, nonWorkingDayGain: 2 });
expect(christmas.timeOffPeriods[0].nonWorkingDates).toEqual(['2021-12-25', '2021-12-26']);
const ordinary = calculate({
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [],
declaredDays: ['08', '09', '10', '11', '12'].map((day) => ({
date: `2024-01-${day}`,
type: 'other',
})),
rules: [holiday],
}).plan;
expect(ordinary.metrics.holidayWeekValue).toBe(0);
});
it('uses the same first calendar objective in the plan explanation, trace and date comparison', () => {
for (const rules of [
[holiday, gain],
[gain, holiday],
]) {
const request = { ...concurrent, rules };
const { plan, artifacts } = calculate(request);
const explanation = artifacts.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
const first = `optimization:${rules[0].id}`;
expect(explanation.placements[0].firstDecisiveObjective).toBe(first);
expect(
explanation.placements[0].objectiveVector.objectives.map(
(objective) => objective.id,
),
).toEqual([
'obligation-validity',
first,
`optimization:${rules[1].id}`,
'balance-expiry',
'placement-date',
]);
const comparisons = artifacts.trace?.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.candidates)
.filter((candidate) => candidate.status === 'chosen' && candidate.comparison);
expect(
comparisons?.some(
(candidate) => candidate.comparison?.firstDecisiveObjective === first,
),
).toBe(true);
const placement = rules[0].id === 'holiday' ? plan.placements[1] : plan.placements[0];
const alternative = rules[0].id === 'holiday' ? '2024-04-30' : '2024-05-02';
const comparison = analyzeCounterfactualInternal(
request,
plan,
placement,
artifacts.finalState,
{ removeDate: placement.date, compareOriginalState: true },
);
expect(
comparison.results.find((result) => result.date === alternative)?.comparison
?.firstDecisiveObjective,
).toBe(first);
}
});
});
@@ -1,3 +1,4 @@
import { createOptimizationPresetRules } from './rule-service';
import { vi } from 'vitest';
import {
createPlanningCalculation,
@@ -14,6 +15,8 @@ const request = {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('user cancellation', () => {
const workers: CancellableWorker[] = [];
const currentWorker = () => workers[workers.length - 1];
@@ -51,7 +54,7 @@ describe('user cancellation', () => {
const submitted = {
horizon: { start: '2024-01-02', end: '2024-01-09' },
balances: [],
rules: [],
rules: [presets[2]],
declaredDays: [{ date: '2024-01-05', type: 'other' as const }],
};
let interrupted = false;
@@ -60,7 +63,7 @@ describe('user cancellation', () => {
{
...submitted,
progress: (stage: string) => {
if (stage === 'non-working-day-gain') interrupted = true;
if (stage === 'optimization:default-non-working-day-gain') interrupted = true;
},
},
undefined,
@@ -115,14 +118,17 @@ describe('user cancellation', () => {
{ date: '2024-01-11', type: 'rtt' as const },
],
rules: [
{ id: 'exact-cp', force: 'obligation', placeDays: 2, placeType: 'cp' },
{ id: 'preferred-rtt', force: 'preference', placeDays: 1, placeType: 'rtt' },
].map((rule) => ({
...rule,
type: 'time_window',
startDate: '2024-01-08',
endDate: '2024-01-12',
})),
presets[1],
...[
{ id: 'exact-cp', force: 'obligation', placeDays: 2, placeType: 'cp' },
{ id: 'preferred-rtt', force: 'preference', placeDays: 1, placeType: 'rtt' },
].map((rule) => ({
...rule,
type: 'time_window',
startDate: '2024-01-08',
endDate: '2024-01-12',
})),
],
};
let interrupted = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
@@ -130,7 +136,7 @@ describe('user cancellation', () => {
{
...submitted,
progress: (stage: string) => {
if (stage === 'holiday-week-value') interrupted = true;
if (stage === 'optimization:default-holiday-week-value') interrupted = true;
},
},
undefined,
@@ -201,18 +207,22 @@ describe('user cancellation', () => {
horizon: request.horizon,
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
rules: [
{
id: 'january',
type: 'time_window',
force: 'preference',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 2,
placeType: 'cp',
},
presets[0],
...[
{
id: 'january',
type: 'time_window',
force: 'preference',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 2,
placeType: 'cp',
},
],
...presets.slice(1),
],
progress: (stage: string) => {
if (stage === 'longest-work-sequence') cancelled = true;
if (stage === 'optimization:default-longest-work-sequence') cancelled = true;
},
},
undefined,
@@ -287,7 +297,7 @@ describe('user cancellation', () => {
const submitted = {
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
rules: [],
rules: presets,
};
const cancelled = await core.calculatePlanningAsyncInternal(
{
@@ -295,7 +305,7 @@ describe('user cancellation', () => {
traceProgress: (_: number, update: PlanningTraceUpdate) =>
updates.push(structuredClone(update)),
progress: (stage: string) => {
if (stage === 'non-working-day-gain') interrupted = true;
if (stage === 'optimization:default-non-working-day-gain') interrupted = true;
},
},
undefined,
+152 -117
View File
@@ -1,3 +1,4 @@
import { createOptimizationPresetRules } from './rule-service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPlanningCalculation, applyReplayDeltas } from './leave-planner.service';
@@ -15,6 +16,8 @@ afterEach(() => vi.unstubAllGlobals());
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('planner explanation and trace seam', () => {
it('replays a reported occurrence without borrowing its future balance from the final plan', async () => {
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
@@ -180,7 +183,7 @@ describe('planner explanation and trace seam', () => {
},
);
it('removes an entitlement from the original replay when a real quality move shortens its generator into today', async () => {
it('replays an already-reported entitlement without inventing a transient earned balance under explicit priorities', async () => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
@@ -201,17 +204,21 @@ describe('planner explanation and trace seam', () => {
{ date: '2024-05-03', type: 'cp' },
],
rules: [
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placeDays: 2,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'other',
},
presets[0],
...[
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placeDays: 2,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'other',
},
],
...presets.slice(1),
],
},
undefined,
@@ -226,11 +233,16 @@ describe('planner explanation and trace seam', () => {
added: false,
});
const decisions = artifacts.trace.phases.flatMap(({ decisions }) => decisions);
expect(decisions.flatMap(({ deltas }) => deltas)).toContainEqual({
kind: 'balance-removed',
balanceId: 'earned:producer:2024-05-01',
remainingBefore: 1,
});
const deltas = decisions.flatMap(({ deltas }) => deltas);
expect(deltas).toContainEqual(
expect.objectContaining({ kind: 'balance-changed', balanceId: 'cp' }),
);
expect(deltas).not.toContainEqual(
expect.objectContaining({
kind: 'balance-changed',
balanceId: 'earned:producer:2024-05-01',
}),
);
const initial = artifacts.trace.initialState;
const replay = {
balances: initial.balances.map((balance) => ({ ...balance })),
@@ -355,14 +367,16 @@ describe('planner explanation and trace seam', () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 2, '2024-01-07')],
rules: [],
rules: [presets[0]],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const atRisk = trace.phases.find((phase) => phase.name === 'at-risk');
const atRisk = trace.phases.find(
(phase) => phase.name === 'optimization:default-unplaced-at-risk',
);
const jan2 = atRisk?.decisions.find((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-02',
@@ -374,22 +388,17 @@ describe('planner explanation and trace seam', () => {
),
);
expect(jan2?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
expect(jan2?.candidates[0]).toMatchObject({
expect(jan2?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
technicalComparison: [
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
{
name: 'preference-date-conflict',
name: `optimization:${presets[0].id}`,
direction: 'minimize',
before: 0,
after: 0,
},
{
name: 'placement-date',
direction: 'minimize',
before: '2024-01-02',
after: '2024-01-03',
before: 2,
after: 1,
},
],
firstDecisiveComparator: 'placement-date',
firstDecisiveComparator: `optimization:${presets[0].id}`,
});
expect(jan2?.deltas).toContainEqual(
expect.objectContaining({
@@ -399,22 +408,17 @@ describe('planner explanation and trace seam', () => {
}),
);
expect(jan3?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
expect(jan3?.candidates[0]).toMatchObject({
expect(jan3?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
technicalComparison: [
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
{
name: 'preference-date-conflict',
name: `optimization:${presets[0].id}`,
direction: 'minimize',
before: 0,
before: 1,
after: 0,
},
{
name: 'placement-date',
direction: 'minimize',
before: '2024-01-03',
after: '2024-01-04',
},
],
firstDecisiveComparator: 'placement-date',
firstDecisiveComparator: `optimization:${presets[0].id}`,
});
expect(jan3?.deltas).toContainEqual(
expect.objectContaining({
@@ -541,29 +545,39 @@ describe('planner explanation and trace seam', () => {
?.decisions.flatMap((decision) => decision.candidates)
.find((item) => item.status === 'chosen' && item.date === '2024-01-03');
expect(candidate).toMatchObject({
technicalComparison: [
{
name: 'shared-placement-type',
direction: 'maximize',
before: 0,
after: 0,
},
{
name: 'protected-preference-type',
direction: 'maximize',
before: 1,
after: 1,
},
{
name: 'balance-expiry',
direction: 'minimize',
before: '2025-01-05',
after: '2025-01-06',
},
],
firstDecisiveComparator: 'balance-expiry',
firstDecisiveComparator: 'preference:rtt-preference',
placement: expect.objectContaining({ balanceId: 'rtt-first' }),
});
const compared = trace.phases
.find((phase) => phase.name === 'preference:any-preference')
?.decisions.flatMap((decision) => decision.candidates);
expect(compared).toContainEqual(
expect.objectContaining({
status: 'evaluated-rejected',
placement: expect.objectContaining({ balanceId: 'rtt-second' }),
technicalComparison: expect.arrayContaining([
{
name: 'preference:any-preference',
direction: 'maximize',
before: 1,
after: 1,
},
{
name: 'preference:rtt-preference',
direction: 'maximize',
before: 1,
after: 1,
},
{
name: 'balance-expiry',
direction: 'minimize',
before: '2025-01-05',
after: '2025-01-06',
},
]),
firstDecisiveComparator: 'balance-expiry',
}),
);
});
it('records actual IfThen leaf comparisons and selected branch identity', async () => {
@@ -593,8 +607,8 @@ describe('planner explanation and trace seam', () => {
.flatMap((decision) =>
decision.candidates
.filter((candidate) =>
candidate.technicalComparison?.some((item) =>
item.name.startsWith('complete-occurrences:if-then-preference'),
candidate.technicalComparison?.some(
(item) => item.name === 'preference:if-then-preference',
),
)
.map((candidate) => ({ candidate, decision })),
@@ -605,8 +619,8 @@ describe('planner explanation and trace seam', () => {
.find((phase) => phase.name === 'preference:if-then-preference')
?.decisions.flatMap((decision) => decision.candidates)
.filter((candidate) =>
candidate.technicalComparison?.some((item) =>
item.name.startsWith('complete-occurrences:if-then-preference'),
candidate.technicalComparison?.some(
(item) => item.name === 'preference:if-then-preference',
),
) ?? [];
expect(preferenceLeaves.length).toBeGreaterThan(1);
@@ -620,16 +634,14 @@ describe('planner explanation and trace seam', () => {
expect(chosen?.candidate.technicalComparison).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'complete-occurrences:if-then-preference',
name: 'preference:if-then-preference',
direction: 'maximize',
}),
expect.objectContaining({
name: 'future-pattern-conflict-score:if-then-preference',
direction: 'minimize',
}),
]),
);
expect(chosen?.decision.branch?.deltas.length).toBeGreaterThan(0);
expect(
(chosen?.decision.branch?.deltas ?? chosen?.decision.deltas ?? []).length,
).toBeGreaterThan(0);
expect(
chosen?.candidate.dates?.every((date) =>
outcome.placements.some((p) => p.date === date),
@@ -749,46 +761,52 @@ describe('planner explanation and trace seam', () => {
horizon: { start: '2024-04-25', end: '2024-05-08' },
balances: [
balance('cp1', 'cp', 1, '2025-12-31'),
balance('cp2', 'cp', 2, '2024-05-07'),
{ ...balance('cp2', 'cp', 2, '2024-05-07'), availableFrom: '2024-04-26' },
balance('rtt', 'rtt', 0, '2026-01-01'),
],
rules: [
{
id: 'anchor',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-25',
endDate: '2024-04-25',
placeDays: 1,
placeType: 'cp',
},
{
id: 'producer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'consumer',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'rtt',
},
presets[0],
...[
{
id: 'anchor',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-25',
endDate: '2024-04-25',
placeDays: 1,
placeType: 'cp',
},
{
id: 'producer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'consumer',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'rtt',
},
],
...presets.slice(1),
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
const atRisk = outcome.placements.find(
(placement) => placement.balanceId === 'cp2' && !placement.ruleIds.length,
(placement) =>
placement.balanceId === 'cp2' &&
placement.ruleIds.every((id) => id.startsWith('default-')),
);
expect(atRisk?.date).toBeDefined();
if (!atRisk) return;
@@ -1006,7 +1024,11 @@ describe('planner explanation and trace seam', () => {
expect((await calculation.explanation)?.kind).toBe('ConflictExplanation');
const trace = await calculation.trace;
expect(trace?.phases.some((phase) => phase.name === 'obligations')).toBe(true);
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
expect(
trace?.phases.some(
(phase) => phase.name === 'optimization:default-holiday-week-value',
),
).toBe(false);
if (index === 1)
expect(
trace?.phases
@@ -1076,9 +1098,10 @@ describe('planner explanation and trace seam', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 2, '2024-01-31')],
rules: [],
rules: presets,
progress: (stage: string) => {
if (stage === 'holiday-week-value') throw new Error('interrupted after checkpoint');
if (stage === 'optimization:default-holiday-week-value')
throw new Error('interrupted after checkpoint');
},
};
const outcome = core.calculatePlanningInternal(request, (plan, state) => {
@@ -1128,8 +1151,8 @@ describe('planner explanation and trace seam', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-15' },
balances: [balance('cp', 'cp', 3, '2024-01-31')],
rules: [],
deadlineMs: 1_000,
rules: presets,
deadlineMs: 100_000,
};
const plain = core.calculatePlanningInternal(request);
clock = 0;
@@ -1156,6 +1179,7 @@ describe('planner explanation and trace seam', () => {
expect(artifacts?.trace?.status).toBe('ready');
expect(artifacts?.timings?.planningWorkMs).toBeLessThan(request.deadlineMs);
expect(artifacts?.timings?.traceTransferMs).toBeGreaterThanOrEqual(callbackCount * 10_000);
expect(artifacts?.timings?.traceTransferMs).toBeGreaterThan(request.deadlineMs);
const decisions = artifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? [];
const candidateUpdates = operations.filter((operation) => operation.kind === 'candidate');
expect(candidateUpdates.some((operation) => operation.mode === 'replace')).toBe(true);
@@ -1180,7 +1204,7 @@ describe('planner explanation and trace seam', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 2, '2024-01-07')],
rules: [],
rules: [presets[0]],
};
let plainArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const plain = core.calculatePlanningInternal(request, undefined, (value) => {
@@ -1210,6 +1234,7 @@ describe('planner explanation and trace seam', () => {
candidate,
})),
);
expect(candidates.length).toBeGreaterThan(0);
const candidateOperations = operations.filter(
(operation): operation is Extract<typeof operation, { kind: 'candidate' }> =>
operation.kind === 'candidate',
@@ -1232,7 +1257,7 @@ describe('planner explanation and trace seam', () => {
const early = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-01-31')],
rules: [],
rules: presets,
deadlineMs: 0,
});
const earlyOutcome = await early.outcome;
@@ -1247,16 +1272,18 @@ describe('planner explanation and trace seam', () => {
expect(earlyTrace?.phases.find((phase) => phase.name === 'obligations')?.status).toBe(
'interrupted',
);
expect(earlyTrace?.phases.find((phase) => phase.name === 'at-risk')?.status).toBe(
'not-run',
);
expect(
earlyTrace?.phases.find(
(phase) => phase.name === 'optimization:default-unplaced-at-risk',
)?.status,
).toBe('not-run');
const quality = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 1, '2024-01-31')],
rules: [],
rules: presets,
progress: (stage) => {
if (stage === 'holiday-week-value') {
if (stage === 'optimization:default-holiday-week-value') {
throw new Error('deliberate interruption in quality phase');
}
},
@@ -1266,13 +1293,21 @@ describe('planner explanation and trace seam', () => {
expect(qualityOutcome.kind).toBe('PlanningIncomplete');
if (qualityOutcome.kind !== 'PlanningIncomplete') return;
expect(qualityOutcome.reasons[0].code).toBe('planner-error');
expect(qualityOutcome.completedStages).toEqual(['validation', 'obligations', 'at-risk']);
expect(qualityOutcome.completedStages).toEqual([
'validation',
'obligations',
'optimization:default-unplaced-at-risk',
]);
expect(qualityTrace?.completedStages).toEqual(qualityOutcome.completedStages);
expect(
qualityTrace?.phases.find((phase) => phase.name === 'holiday-week-value')?.status,
qualityTrace?.phases.find(
(phase) => phase.name === 'optimization:default-holiday-week-value',
)?.status,
).toBe('interrupted');
expect(
qualityTrace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status,
qualityTrace?.phases.find(
(phase) => phase.name === 'optimization:default-non-working-day-gain',
)?.status,
).toBe('not-run');
});
});
@@ -0,0 +1,186 @@
import { createOptimizationPresetRules } from './rule-service';
import { describe, expect, it } from 'vitest';
import { calculatePlanningInternal, type PlanningArtifacts } from './leave-planner.core';
import {
evaluateOptimization,
OPTIMIZATION_PRESETS,
type OptimizationObservations,
} from './planning-objectives';
const presets = createOptimizationPresetRules();
describe('planning objective observations', () => {
it.each([
{ start: '2024-05-06', end: '2024-05-13' },
{ start: '2024-05-10', end: '2024-05-11' },
])(
'evaluates the same declared holiday week and supporting placement at horizon boundaries',
(horizon) => {
const result = calculatePlanningInternal({
horizon,
balances: [
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2024-05-06',
expiryDate: '2025-05-06',
},
],
rules: presets,
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
});
expect(result.kind).toBe('Plan');
if (result.kind !== 'Plan') return;
expect(result.placements).toEqual([
{
date: '2024-05-10',
balanceId: 'cp',
type: 'cp',
ruleIds: ['default-holiday-week-value'],
supporting: true,
},
]);
const observations: OptimizationObservations = {
leaveBalances: [{ unplacedQuantity: 0, atRisk: false }],
publicHolidayWeeks: [{ value: 5, complete: true }],
timeOffPeriods: result.timeOffPeriods,
workSequences: [
{ length: 0, internal: false },
{ length: 0, internal: false },
],
leavePlacements: result.placements,
};
expect(
Object.values(OPTIMIZATION_PRESETS).map((configuration) =>
evaluateOptimization(configuration, observations),
),
).toEqual([0, 5, 6, 0, 0, 1]);
expect(result.metrics).toMatchObject({
unplacedAtRisk: 0,
holidayWeekValue: 5,
nonWorkingDayGain: 6,
longestWorkSequence: 0,
shortSequenceDeficit: 0,
supportingPlacements: 1,
});
},
);
it('uses internal working-day deficits while retaining both horizon edges in the maximum', () => {
const result = calculatePlanningInternal({
horizon: { start: '2024-01-02', end: '2024-01-18' },
balances: [],
rules: [],
declaredDays: ['2024-01-05', '2024-01-09'].map((date) => ({ date, type: 'other' })),
});
expect(result.kind).toBe('Plan');
if (result.kind !== 'Plan') return;
expect(result.metrics).toMatchObject({
longestWorkSequence: 6,
shortSequenceDeficit: 4,
nonWorkingDayGain: 2,
});
const observations: OptimizationObservations = {
leaveBalances: [],
publicHolidayWeeks: [],
timeOffPeriods: result.timeOffPeriods,
workSequences: [
{ length: 3, internal: false },
{ length: 1, internal: true },
{ length: 6, internal: false },
],
leavePlacements: result.placements,
};
expect(evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations)).toBe(
4,
);
expect(
evaluateOptimization(
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays: 3 },
observations,
),
).toBe(2);
});
it.each([
['2024-01-01', '2024-01-06', ['2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05'], 1],
['2018-04-30', '2018-05-05', ['2018-04-30', '2018-05-02', '2018-05-03', '2018-05-04'], 2],
['2024-04-29', '2024-05-04', ['2024-04-29', '2024-04-30', '2024-05-02', '2024-05-03'], 3],
['2024-08-12', '2024-08-17', ['2024-08-12', '2024-08-13', '2024-08-14', '2024-08-16'], 2],
['2024-10-28', '2024-11-02', ['2024-10-28', '2024-10-29', '2024-10-30', '2024-10-31'], 1],
] as const)(
'retains the weekday holiday weight in the completed week starting %s',
(start, end, dates, value) => {
const result = calculatePlanningInternal({
horizon: { start, end },
balances: [],
rules: [],
declaredDays: dates.map((date) => ({ date, type: 'other' })),
});
expect(result.kind).toBe('Plan');
if (result.kind === 'Plan') expect(result.metrics.holidayWeekValue).toBe(value);
},
);
it('keeps the historical objective order in the explanation of a jointly valid plan', () => {
let artifacts: PlanningArtifacts | undefined;
const result = calculatePlanningInternal(
{
horizon: { start: '2024-01-02', end: '2024-01-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2024-01-02',
expiryDate: '2024-01-17',
},
],
rules: [
presets[0],
...[
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-05',
endDate: '2024-01-05',
placeDays: 1,
placeType: 'cp',
},
{
id: 'personal',
type: 'time_window',
force: 'preference',
startDate: '2024-01-09',
endDate: '2024-01-09',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
},
undefined,
(value) => (artifacts = value),
);
expect(result.kind).toBe('Plan');
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
const placement = artifacts.explanation.placements[0];
expect(placement.objectiveVector.objectives.map((objective) => objective.id)).toEqual([
'obligation-validity',
'optimization:default-unplaced-at-risk',
'preference:personal',
'optimization:default-holiday-week-value',
'optimization:default-non-working-day-gain',
'optimization:default-longest-work-sequence',
'optimization:default-short-sequence-deficit',
'optimization:default-supporting-placements',
'balance-expiry',
'placement-date',
]);
expect(placement.firstDecisiveObjective).toBe('obligation-validity');
});
});
@@ -1,10 +1,116 @@
import { createOptimizationPresetRules } from './rule-service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPlanningCalculation } from './leave-planner.service';
import {
calculatePlanningInternal,
type PlanningArtifacts,
type PlanningTraceCandidate,
type PlanningTraceUpdate,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
beforeEach(() => vi.stubGlobal('window', undefined));
afterEach(() => vi.unstubAllGlobals());
const presets = createOptimizationPresetRules();
describe('quality calculation trace', () => {
it('keeps emitted comparison metadata unchanged when candidate snapshots are replaced', () => {
const updates: PlanningTraceUpdate[] = [];
const captured: string[] = [];
let artifacts: PlanningArtifacts = {};
let clock = 0;
const clockSpy = vi.spyOn(performance, 'now').mockImplementation(() => (clock += 30));
try {
const outcome = calculatePlanningInternal(
{
calculationDate: '2026-11-01',
horizon: { start: '2026-11-03', end: '2026-11-14' },
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2027-01-01' }],
declaredDays: [
{ date: '2026-11-09', type: 'other' },
{ date: '2026-11-10', type: 'other' },
],
rules: [
{
id: 'holiday',
name: 'Holiday weeks',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
},
...[3, 5].map((thresholdDays) => ({
id: `deficit-${thresholdDays}`,
name: `Deficit ${thresholdDays} days`,
type: 'optimization',
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
conditions: ['non-empty'],
thresholdDays,
},
})),
],
traceProgress: (_elapsed: number, update: PlanningTraceUpdate) => {
updates.push(update);
captured.push(JSON.stringify(update));
},
},
undefined,
(value) => (artifacts = value),
);
expect(outcome.kind).toBe('Plan');
expect(artifacts.trace?.status).toBe('ready');
expect(updates.map((update) => JSON.stringify(update))).toEqual(captured);
const emitted = new Map<string, PlanningTraceCandidate>();
let replacements = 0;
for (const update of updates)
for (const operation of update.operations) {
if (operation.kind !== 'candidate') continue;
if (operation.mode === 'replace') replacements++;
emitted.set(
`${operation.phaseId}/${operation.decisionId}/${operation.index}`,
operation.candidate,
);
}
expect(replacements).toBeGreaterThan(0);
expect(emitted.size).toBeGreaterThan(0);
for (const phase of artifacts.trace!.phases)
for (const decision of phase.decisions)
decision.candidates.forEach((candidate, index) =>
expect(emitted.get(`${phase.id}/${decision.id}/${index}`)).toEqual(
candidate,
),
);
const comparison = [...emitted.values()].find(
(candidate) => candidate.comparison,
)?.comparison;
expect(comparison?.after.objectives).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'optimization:deficit-3',
label: 'Deficit 3 days',
direction: 'minimize',
}),
expect.objectContaining({
id: 'optimization:deficit-5',
label: 'Deficit 5 days',
direction: 'minimize',
}),
]),
);
expect(
[...emitted.values()].some(({ comparison }) => {
const values = comparison?.after.objectives;
return (
values?.find(({ id }) => id === 'optimization:deficit-3')?.value === 0 &&
values?.find(({ id }) => id === 'optimization:deficit-5')?.value === 1
);
}),
).toBe(true);
} finally {
clockSpy.mockRestore();
}
});
it('separates source effects from the actual incumbent comparison', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
@@ -17,71 +123,47 @@ describe('quality calculation trace', () => {
expiryDate: '2024-01-31',
},
],
rules: [],
rules: presets,
});
const outcome = await calculation.outcome;
expect((await calculation.outcome).kind).toBe('Plan');
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
expect(trace?.status).toBe('ready');
if (outcome.kind !== 'Plan' || !trace) return;
const phase = trace.phases.find((item) => item.name === 'non-working-day-gain');
const jan3Decision = phase?.decisions.find((decision) =>
decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-03'),
const phase = trace?.phases.find(({ name }) => name === `optimization:${presets[0].id}`);
const decision = phase?.decisions.find(({ candidates }) =>
candidates.some(({ status, date }) => status === 'chosen' && date === '2024-01-02'),
);
const jan2Decision = phase?.decisions.find((decision) =>
decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-02'),
const rejected = decision?.candidates.find(
({ date, comparison }) => date === '2024-01-05' && comparison,
);
const jan5 = jan3Decision?.candidates.find(
(candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-05',
);
const jan8 = jan3Decision?.candidates.find(
(candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-08',
);
expect(jan2Decision).toBeDefined();
expect(jan3Decision).toBeDefined();
expect(jan3Decision).not.toBe(jan2Decision);
expect(jan3Decision?.dates).toEqual(['2024-01-03', '2024-01-05']);
expect(jan5?.status).toBe('chosen');
expect(jan8?.status).toBe('evaluated-rejected');
expect(jan3Decision?.deltas).toContainEqual(
expect(rejected?.status).toBe('evaluated-rejected');
const sourceValue = (state: 'before' | 'after', id: string) =>
rejected?.comparison?.[state].objectives.find((objective) => objective.id === id)
?.value;
expect(sourceValue('before', `optimization:${presets[0].id}`)).toBe(2);
expect(sourceValue('after', `optimization:${presets[0].id}`)).toBe(1);
expect(sourceValue('before', `optimization:${presets[2].id}`)).toBe(0);
expect(sourceValue('after', `optimization:${presets[2].id}`)).toBe(2);
expect(rejected?.comparison?.firstDecisiveObjective).toBe(`optimization:${presets[0].id}`);
// The January 2 candidate already gains three natural non-working days.
// January 5 improves its source state, but loses to that current best candidate.
expect(rejected?.technicalComparison).toContainEqual({
name: `optimization:${presets[2].id}`,
direction: 'maximize',
before: 3,
after: 2,
});
expect(
rejected?.technicalComparison?.some(
({ name }) => name === `optimization:${presets[3].id}`,
),
).toBe(false);
expect(rejected?.firstDecisiveComparator).toBe(`optimization:${presets[2].id}`);
expect(rejected?.reason?.message).toContain('did not improve the current best result');
expect(decision?.deltas).toContainEqual(
expect.objectContaining({
kind: 'placement-moved',
fromDate: '2024-01-03',
toDate: '2024-01-05',
kind: 'placement-added',
date: '2024-01-02',
}),
);
const sourceValue = (
candidate: typeof jan5,
state: 'before' | 'after',
objective: string,
) => candidate?.comparison?.[state].objectives.find((item) => item.id === objective)?.value;
expect(sourceValue(jan5, 'before', 'non-working-day-gain')).toBe(3);
expect(sourceValue(jan5, 'after', 'non-working-day-gain')).toBe(5);
expect(sourceValue(jan5, 'before', 'longest-work-sequence')).toBe(20);
expect(sourceValue(jan5, 'after', 'longest-work-sequence')).toBe(18);
expect(sourceValue(jan8, 'before', 'non-working-day-gain')).toBe(3);
expect(sourceValue(jan8, 'after', 'non-working-day-gain')).toBe(5);
expect(sourceValue(jan8, 'before', 'longest-work-sequence')).toBe(20);
expect(sourceValue(jan8, 'after', 'longest-work-sequence')).toBe(17);
expect(jan8?.comparison?.firstDecisiveObjective).toBe('non-working-day-gain');
expect(jan8?.technicalComparison).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'non-working-day-gain',
direction: 'maximize',
before: 5,
after: 5,
}),
]),
);
expect(
jan8?.technicalComparison?.some((item) => item.name === 'longest-work-sequence'),
).toBe(false);
expect(jan8?.firstDecisiveComparator).toBeUndefined();
expect(jan8?.reason?.message).toContain('did not improve the current best result');
});
});
@@ -0,0 +1,823 @@
import { vi } from 'vitest';
import {
calculatePlanningAsyncInternal,
calculatePlanningInternal,
type OptimizationPlanningRule,
type PlanOutcome,
type PlanningArtifacts,
type PlanningRequest,
type PlanningTraceUpdate,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
import { applyReplayDeltas, createPlanningCalculation } from './leave-planner.service';
const economy: OptimizationPlanningRule = {
id: 'economy',
name: 'Économiser les jours d’appoint',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
};
const gain: OptimizationPlanningRule = {
id: 'gain',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
};
const requested: PlanningRequest['rules'][number] = {
id: 'requested',
type: 'time_window',
startDate: '2024-01-05',
endDate: '2024-01-05',
placeDays: 1,
placeType: 'cp',
};
const weekend: PlanningRequest = {
horizon: { start: '2024-01-05', end: '2024-01-10' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
rules: [],
};
const bundles: {
name: string;
rule: OptimizationPlanningRule;
request: PlanningRequest;
dates: string[];
}[] = [
{
name: 'complete public-holiday week',
rule: {
id: 'holiday',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
},
request: {
horizon: { start: '2026-11-09', end: '2026-11-14' },
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2027-01-01' }],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules: [],
},
dates: ['2026-11-12', '2026-11-13'],
},
{
name: 'non-working-day gain',
rule: gain,
request: weekend,
dates: ['2024-01-05'],
},
{
name: 'maximum non-working gain of a joined period',
rule: {
...gain,
configuration: { ...gain.configuration, aggregation: 'maximum' },
},
request: {
horizon: { start: '2024-01-05', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2025-01-01' }],
declaredDays: ['2024-01-05', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [],
},
dates: ['2024-01-08', '2024-01-09', '2024-01-10', '2024-01-11'],
},
{
name: 'complete short internal work sequence',
rule: {
id: 'deficit',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit,
},
request: {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [],
},
dates: ['2024-01-09', '2024-01-10', '2024-01-11'],
},
{
name: 'tied longest work sequences',
rule: {
id: 'longest',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.longestWorkSequence,
},
request: {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-02-01' }],
declaredDays: [{ date: '2024-01-10', type: 'other' }],
rules: [],
},
dates: ['2024-01-08', '2024-01-11'],
},
];
function plan(request: PlanningRequest): PlanOutcome {
const outcome = calculatePlanningInternal(request);
if (outcome.kind !== 'Plan') throw new Error(`Expected a plan, got ${outcome.kind}`);
return outcome;
}
describe('supporting placement economy and provenance', () => {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
it.each(bundles)(
'compares the whole $name bundle against economy in either order',
({ rule, request, dates }) => {
const improve = plan({ ...request, rules: [rule, economy] });
const preserve = plan({ ...request, rules: [economy, rule] });
expect(improve.placements.map(({ date }) => date)).toEqual(dates);
expect(improve.placements.every(({ supporting }) => supporting)).toBe(true);
expect(improve.metrics.supportingPlacements).toBe(dates.length);
expect(improve.remainingBalances).toEqual([]);
expect(preserve.placements).toEqual([]);
expect(preserve.metrics.supportingPlacements).toBe(0);
expect(preserve.remainingBalances[0]).toMatchObject({
id: 'cp',
quantity: request.balances[0].quantity,
classification: 'deferred',
reason: { code: 'preserved' },
});
},
);
it.each(bundles)(
'makes disabled economy identical to absence for the $name bundle',
({ rule, request }) => {
const absent = plan({ ...request, rules: [rule] });
for (const rules of [
[{ ...economy, enabled: false }, rule],
[rule, { ...economy, enabled: false }],
])
expect(plan({ ...request, rules })).toEqual(absent);
},
);
it('does not add placements merely because economy is disabled or removed', () => {
for (const rules of [[], [economy], [{ ...economy, enabled: false }]]) {
const outcome = plan({ ...weekend, rules });
expect(outcome.placements).toEqual([]);
expect(outcome.remainingBalances[0].quantity).toBe(1);
}
});
it('credits an earlier supporting placement to a later explicit window without counting an extra day', () => {
const outcome = plan({
...weekend,
rules: [gain, economy, requested],
});
expect(outcome.placements).toEqual([
expect.objectContaining({
date: '2024-01-05',
balanceId: 'cp',
ruleIds: ['gain', 'requested'],
supporting: false,
}),
]);
expect(outcome.metrics).toMatchObject({ nonWorkingDayGain: 2, supportingPlacements: 0 });
expect(outcome.preferences[0].satisfied).toBe(1);
});
it('exempts only the requested window quantity, including fixed declared days', () => {
const outcome = plan({
...weekend,
horizon: { start: '2024-01-05', end: '2024-01-20' },
balances: [{ ...weekend.balances[0], quantity: 3 }],
declaredDays: [{ date: '2024-01-09', type: 'cp' }],
rules: [gain, economy, { ...requested, endDate: '2024-01-19', placeDays: 2 }],
});
expect(outcome.placements.map(({ date, supporting }) => ({ date, supporting }))).toEqual([
{ date: '2024-01-05', supporting: false },
{ date: '2024-01-12', supporting: true },
{ date: '2024-01-19', supporting: true },
]);
expect(outcome.metrics.supportingPlacements).toBe(2);
expect(outcome.timeWindowContributions?.[0]).toMatchObject({
requested: 2,
declaredDays: [{ date: '2024-01-09', type: 'cp' }],
placementDates: ['2024-01-05'],
});
});
it.each([false, true])(
'bounds explicit calendar credit before accepting further window improvements (declared: %s)',
(declared) => {
const windowRule = { ...requested, endDate: '2024-01-19' };
const request: PlanningRequest = {
...weekend,
horizon: { start: '2024-01-05', end: '2024-01-20' },
balances: [{ ...weekend.balances[0], quantity: 4 }],
declaredDays: declared ? [{ date: '2024-01-09', type: 'cp' }] : [],
rules: [economy, gain, windowRule],
};
const preserve = plan(request);
expect(preserve.placements).toEqual(
declared
? []
: [
expect.objectContaining({
date: '2024-01-05',
ruleIds: ['requested'],
supporting: false,
}),
],
);
expect(preserve.metrics.supportingPlacements).toBe(0);
expect(preserve.preferences[0].satisfied).toBe(1);
expect(preserve.remainingBalances[0]).toMatchObject({
id: 'cp',
quantity: declared ? 4 : 3,
});
const improve = plan({ ...request, rules: [gain, economy, windowRule] });
expect(improve.placements.map(({ date }) => date)).toEqual([
'2024-01-05',
'2024-01-12',
'2024-01-19',
]);
expect(
improve.placements.filter(({ ruleIds }) => ruleIds.includes('requested')),
).toHaveLength(declared ? 0 : 1);
expect(improve.metrics.supportingPlacements).toBe(declared ? 3 : 2);
expect(
plan({ ...request, rules: [{ ...economy, enabled: false }, gain, windowRule] }),
).toEqual(plan({ ...request, rules: [gain, windowRule] }));
},
);
it('restores supporting classification when a moved placement loses its explicit calendar request', () => {
const outcome = plan({
...weekend,
horizon: { start: '2024-01-05', end: '2024-01-20' },
balances: [{ ...weekend.balances[0], quantity: 4 }],
rules: [
gain,
{ ...requested, startDate: '2024-01-08', endDate: '2024-01-09' },
bundles[4].rule,
economy,
],
});
expect(outcome.metrics).toMatchObject({
nonWorkingDayGain: 6,
longestWorkSequence: 3,
supportingPlacements: 3,
});
expect(outcome.placements.filter(({ supporting }) => !supporting)).toEqual([
expect.objectContaining({
date: '2024-01-08',
ruleIds: ['gain', 'requested'],
}),
]);
});
it.each(['preference', 'obligation'] as const)(
'keeps explicitly requested deferred days outside economy (%s)',
(force) => {
const outcome = plan({ ...weekend, rules: [economy, { ...requested, force }, gain] });
expect(outcome.placements).toEqual([
expect.objectContaining({
date: '2024-01-05',
ruleIds: ['requested'],
supporting: false,
}),
]);
expect(outcome.metrics).toMatchObject({
nonWorkingDayGain: 2,
supportingPlacements: 0,
});
},
);
it('credits the complete explicit IF_THEN pattern without treating its deferred day as support', () => {
const outcome = plan({
horizon: { start: '2024-05-06', end: '2024-05-11' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
rules: [
gain,
economy,
{
id: 'thursday',
type: 'if_then',
dayOfWeek: 4,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 0,
},
],
});
expect(outcome.placements).toEqual([
expect.objectContaining({
date: '2024-05-10',
ruleIds: ['gain', 'thursday'],
supporting: false,
}),
]);
expect(outcome.metrics.supportingPlacements).toBe(0);
expect(outcome.preferences[0].satisfied).toBe(1);
});
it.each(['preference', 'obligation'] as const)(
'limits IF_THEN credit to its declared producer pattern when another complete pattern improves rest (%s)',
(force) => {
const request: PlanningRequest = {
horizon: { start: '2024-05-06', end: '2024-05-11' },
balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2025-01-01' }],
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
rules: [
economy,
gain,
{
id: 'producer',
type: 'if_then',
force,
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'rtt',
},
],
};
const preserve = plan(request);
expect(preserve.placements).toEqual([]);
expect(preserve.metrics.supportingPlacements).toBe(0);
expect(preserve.remainingBalances).toEqual([
expect.objectContaining({ id: 'cp', quantity: 4 }),
expect.objectContaining({
id: 'earned:producer:2024-05-08',
quantity: 1,
availableFrom: '2024-05-10',
}),
]);
expect(preserve.ifThenContributions?.[0]).toMatchObject({
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
placementDates: [],
earnedEntitlement: { availableFrom: '2024-05-10', added: true },
});
const improve = plan({ ...request, rules: [gain, economy, request.rules[2]] });
expect(improve.placements).toEqual([
expect.objectContaining({
date: '2024-05-06',
ruleIds: ['gain'],
supporting: true,
}),
expect.objectContaining({
date: '2024-05-10',
ruleIds: ['gain'],
supporting: true,
}),
]);
expect(improve.metrics).toMatchObject({
nonWorkingDayGain: 6,
supportingPlacements: 2,
});
expect(improve.remainingBalances).toEqual([
expect.objectContaining({ id: 'cp', quantity: 2 }),
expect.objectContaining({
id: 'earned:producer:2024-05-08',
quantity: 1,
availableFrom: '2024-05-13',
}),
]);
expect(improve.ifThenContributions?.[0]).toMatchObject({
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
placementDates: [],
earnedEntitlement: { availableFrom: '2024-05-13', added: true },
});
expect(
plan({
...request,
rules: [{ ...economy, enabled: false }, gain, request.rules[2]],
}),
).toEqual(plan({ ...request, rules: [gain, request.rules[2]] }));
},
);
it('uses expiry then dates when active rules tie, without restoring a removed gain objective', () => {
const request: PlanningRequest = {
horizon: { start: '2024-01-09', end: '2024-01-13' },
balances: [
{ id: 'later', type: 'cp', quantity: 1, expiryDate: '2024-03-01' },
{ id: 'earlier', type: 'cp', quantity: 1, expiryDate: '2024-02-01' },
],
rules: [economy, { ...requested, startDate: '2024-01-09', endDate: '2024-01-12' }],
};
const outcome = plan(request);
expect(outcome.placements).toEqual([
expect.objectContaining({
date: '2024-01-09',
balanceId: 'earlier',
supporting: false,
}),
]);
expect(outcome.metrics.nonWorkingDayGain).toBe(0);
expect(
plan({ ...request, rules: [{ ...gain, enabled: false }, ...request.rules] }),
).toEqual(outcome);
});
it('reclassifies a deferred support addition when an eligible earlier-expiring balance replaces it', () => {
let artifacts: PlanningArtifacts | undefined;
const outcome = calculatePlanningInternal(
{
horizon: { start: '2024-05-06', end: '2024-05-11' },
balances: [
{ id: 'deferred', type: 'cp', quantity: 3, expiryDate: '2025-01-01' },
{
id: 'at-risk',
type: 'rtt',
quantity: 1,
availableFrom: '2024-05-07',
expiryDate: '2024-05-07',
},
],
rules: [bundles[0].rule, economy],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
expect(
outcome.placements.map(({ date, balanceId, supporting }) => ({
date,
balanceId,
supporting,
})),
).toEqual([
{ date: '2024-05-06', balanceId: 'deferred', supporting: true },
{ date: '2024-05-07', balanceId: 'at-risk', supporting: false },
{ date: '2024-05-10', balanceId: 'deferred', supporting: true },
]);
expect(outcome.metrics.supportingPlacements).toBe(2);
expect(outcome.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 1 });
expect(
artifacts?.trace?.phases.flatMap(({ decisions }) =>
decisions.flatMap(({ deltas }) => deltas),
),
).toContainEqual(
expect.objectContaining({
kind: 'placement-updated',
previousPlacement: expect.objectContaining({
balanceId: 'deferred',
supporting: true,
}),
placement: expect.objectContaining({ balanceId: 'at-risk', supporting: false }),
}),
);
});
it('compares new deferred support when at-risk holiday placements relocate into an explicit window', async () => {
const holiday = bundles[0].rule;
const early: PlanningRequest['rules'][number] = {
id: 'early',
type: 'time_window',
startDate: '2026-11-02',
endDate: '2026-11-03',
placeDays: 2,
placeType: 'cp',
};
const request: PlanningRequest = {
horizon: { start: '2026-11-02', end: '2026-11-14' },
balances: [
{ id: 'at-risk', type: 'cp', quantity: 2, expiryDate: '2026-11-13' },
{
id: 'deferred',
type: 'cp',
quantity: 2,
availableFrom: '2026-11-12',
expiryDate: '2027-01-01',
},
],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules: [],
};
const economyFirst = plan({ ...request, rules: [holiday, economy, early] });
let artifacts: PlanningArtifacts | undefined;
const updates: { update: PlanningTraceUpdate; snapshot: string }[] = [];
const windowFirst = calculatePlanningInternal(
{
...request,
rules: [holiday, early, economy],
traceProgress: (_time: number, update: PlanningTraceUpdate) =>
updates.push({ update, snapshot: JSON.stringify(update) }),
},
(checkpoint, state) => expect(state?.placements).toEqual(checkpoint.placements),
(value) => (artifacts = value),
);
if (windowFirst.kind !== 'Plan') throw new Error('Expected a plan');
expect(economyFirst.placements).toEqual([
expect.objectContaining({
date: '2026-11-12',
balanceId: 'at-risk',
supporting: false,
}),
expect.objectContaining({
date: '2026-11-13',
balanceId: 'at-risk',
supporting: false,
}),
]);
expect(economyFirst.metrics.supportingPlacements).toBe(0);
expect(economyFirst.preferences[0].satisfied).toBe(0);
expect(economyFirst.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 2 });
expect(windowFirst.placements).toEqual([
expect.objectContaining({
date: '2026-11-02',
balanceId: 'at-risk',
ruleIds: ['early'],
supporting: false,
}),
expect.objectContaining({
date: '2026-11-03',
balanceId: 'at-risk',
ruleIds: ['early'],
supporting: false,
}),
expect.objectContaining({
date: '2026-11-12',
balanceId: 'deferred',
ruleIds: ['holiday'],
supporting: true,
}),
expect.objectContaining({
date: '2026-11-13',
balanceId: 'deferred',
ruleIds: ['holiday'],
supporting: true,
}),
]);
expect(windowFirst.metrics.supportingPlacements).toBe(2);
expect(windowFirst.preferences[0].satisfied).toBe(2);
expect(windowFirst.metrics.holidayWeekValue).toBe(3);
if (
artifacts?.explanation?.kind !== 'PlanExplanation' ||
!artifacts.finalState ||
!artifacts.trace
)
throw new Error('Expected matching explanation and trace artifacts');
expect(artifacts.finalState.placements).toEqual(windowFirst.placements);
expect(artifacts.explanation.placements.map(({ placement }) => placement)).toEqual(
windowFirst.placements,
);
for (const placement of artifacts.explanation.placements) {
const contribution = expect.objectContaining({ kind: 'rule', ruleId: 'holiday' });
if (placement.supporting) {
expect(placement.ruleIds).toEqual(['holiday']);
expect(placement.contributions).toContainEqual(contribution);
expect(placement.firstDecisiveObjective).toBe('optimization:holiday');
} else {
expect(placement.ruleIds).toEqual(['early']);
expect(placement.contributions).not.toContainEqual(contribution);
}
}
const replay = {
balances: artifacts.trace.initialState.balances.map((balance) => ({ ...balance })),
placements: new Map(
artifacts.trace.initialState.placements.map((placement) => [
placement.date,
placement,
]),
),
earnedActivations: [...artifacts.trace.initialState.earnedActivations],
};
for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions))
applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
expect(
[...replay.placements.values()].sort((a, b) => a.date.localeCompare(b.date)),
).toEqual(windowFirst.placements);
expect(
artifacts.trace.phases
.filter(({ name }) => name === 'preference:early')
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates)),
).toContainEqual(
expect.objectContaining({
status: 'chosen',
placement: expect.objectContaining({ date: '2026-11-03', ruleIds: ['early'] }),
}),
);
expect(updates.length).toBeGreaterThan(0);
for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
let cancelled = false;
let cancelledArtifacts: PlanningArtifacts | undefined;
let published: PlanOutcome | undefined;
const partial = await calculatePlanningAsyncInternal(
{
...request,
rules: [holiday, early, economy],
progress: (stage: string) => {
if (stage === 'optimization:economy') cancelled = true;
},
traceProgress: (_time: number, update: PlanningTraceUpdate) =>
updates.push({ update, snapshot: JSON.stringify(update) }),
},
(checkpoint, state) => {
expect(state?.placements).toEqual(checkpoint.placements);
if (checkpoint.lastCompletedStage === 'preference:early') published = checkpoint;
},
(value) => (cancelledArtifacts = value),
undefined,
undefined,
() => cancelled,
);
if (
partial.kind !== 'PlanningIncomplete' ||
!partial.incumbent ||
cancelledArtifacts?.explanation?.kind !== 'PlanExplanation'
)
throw new Error('Expected an explained cancelled incumbent');
expect(partial.reasons[0].code).toBe('user-cancelled');
expect(partial.incumbent.placements).toEqual(windowFirst.placements);
expect(partial.incumbent.placements).toEqual(published?.placements);
expect(partial.incumbent.preferences).toMatchObject([{ ruleId: 'early', satisfied: 2 }]);
expect(cancelledArtifacts.finalState?.placements).toEqual(partial.incumbent.placements);
expect(cancelledArtifacts.explanation.placements.map(({ placement }) => placement)).toEqual(
partial.incumbent.placements,
);
for (const placement of cancelledArtifacts.explanation.placements.slice(0, 2)) {
expect(placement.ruleIds).toEqual(['early']);
expect(placement.contributions).toContainEqual(
expect.objectContaining({ kind: 'rule', ruleId: 'early' }),
);
}
for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
});
it('keeps established maximum credit when a later earned period masks its local contribution', () => {
let artifacts: PlanningArtifacts | undefined;
const outcome = calculatePlanningInternal(
{
horizon: { start: '2024-05-01', end: '2024-05-11' },
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-05-03' }],
rules: [
{ ...gain, configuration: { ...gain.configuration, aggregation: 'maximum' } },
{
id: 'producer',
type: 'if_then',
dayOfWeek: 3,
placement: 'after',
placeDays: 1,
placeType: 'cp',
rewardDays: 2,
rewardType: 'rtt',
},
],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || artifacts?.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explained plan');
expect(outcome.placements).toMatchObject([
{ date: '2024-05-02', ruleIds: ['gain', 'producer'], supporting: false },
{ date: '2024-05-03', ruleIds: ['gain'], supporting: false },
{ date: '2024-05-10', ruleIds: ['gain'], supporting: true },
]);
for (const placement of artifacts.explanation.placements.slice(0, 2)) {
expect(placement.comparison.delta).toContainEqual(
expect.objectContaining({ id: 'optimization:gain', value: 0 }),
);
expect(placement.contributions).toContainEqual(
expect.objectContaining({ kind: 'rule', ruleId: 'gain' }),
);
}
});
it('counts reverse deferred reassignment for an edited optimization without explicit calendar credit', () => {
const request = bundles[0].request;
const forgoAtRisk: OptimizationPlanningRule = {
id: 'forgo',
type: 'optimization',
configuration: { ...OPTIMIZATION_PRESETS.unplacedAtRisk, direction: 'maximize' },
};
const outcome = plan({
...request,
balances: [
{ ...request.balances[0], id: 'at-risk', expiryDate: '2026-11-13' },
{ ...request.balances[0], id: 'deferred' },
],
rules: [bundles[0].rule, forgoAtRisk, economy],
});
expect(
outcome.placements.map(({ balanceId, supporting }) => ({ balanceId, supporting })),
).toEqual([
{ balanceId: 'deferred', supporting: true },
{ balanceId: 'deferred', supporting: true },
]);
expect(outcome.metrics).toMatchObject({
holidayWeekValue: 3,
unplacedAtRisk: 2,
supportingPlacements: 2,
});
});
it('records the calendar credit and classification change in the original calendar-rule phase', () => {
let artifacts: PlanningArtifacts | undefined;
calculatePlanningInternal(
{ ...weekend, rules: [gain, economy, requested] },
undefined,
(value) => (artifacts = value),
);
const changes = artifacts?.trace?.phases
.find(({ name }) => name === 'preference:requested')
?.decisions.flatMap(({ deltas }) => deltas);
expect(changes).toContainEqual(
expect.objectContaining({
kind: 'placement-updated',
previousPlacement: expect.objectContaining({ supporting: true }),
placement: expect.objectContaining({
ruleIds: ['gain', 'requested'],
supporting: false,
}),
}),
);
});
it('keeps earned support chronological and preserves the captured economy rule in explanations and counterfactuals', async () => {
vi.stubGlobal('window', undefined);
try {
const capturedEconomy = structuredClone(economy);
const request: PlanningRequest = {
horizon: { start: '2024-04-29', end: '2024-05-08' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
rules: [
gain,
capturedEconomy,
{
id: 'producer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'rtt',
},
],
};
const calculation = createPlanningCalculation(request);
capturedEconomy.configuration.conditions = [];
capturedEconomy.name = 'Changed later';
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
expect(outcome.placements).toEqual([
expect.objectContaining({
date: '2024-05-02',
ruleIds: ['producer'],
supporting: false,
}),
expect.objectContaining({
date: '2024-05-06',
balanceId: 'earned:producer:2024-05-01',
supporting: true,
}),
]);
expect(outcome.metrics).toMatchObject({
nonWorkingDayGain: 3,
supportingPlacements: 1,
});
expect(calculation.snapshot.rules[1]).toMatchObject({
name: 'Économiser les jours d’appoint',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
});
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
const supporting = explanation.placements.find(({ supporting }) => supporting)!;
expect(supporting.firstDecisiveObjective).toBe('optimization:gain');
expect(supporting.objectiveVector.objectives.map(({ id }) => id)).toEqual([
'obligation-validity',
'optimization:gain',
'optimization:economy',
'balance-expiry',
'placement-date',
]);
expect(supporting.contributions).toContainEqual(
expect.objectContaining({ kind: 'supporting' }),
);
const trace = await calculation.trace;
expect(trace?.phases.map(({ name }) => name)).toEqual([
'validation',
'obligations',
'optimization:gain',
'optimization:economy',
'expiry',
'dates',
]);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[1]);
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
expect(analysis.results.find(({ date }) => date === '2024-05-03')).toMatchObject({
status: 'ineligible',
reason: { code: 'unavailable' },
});
expect(
analysis.results.find(({ date }) => date === '2024-05-06')?.comparison?.after
.objectives,
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 1 }));
expect(
analysis.results.find(({ date }) => date === '2024-05-07')?.comparison?.after
.objectives,
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 1 }));
expect(outcome.placements[1]).toMatchObject({ date: '2024-05-06', supporting: true });
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -0,0 +1,465 @@
import { vi } from 'vitest';
import {
calculatePlanningInternal,
type OptimizationPlanningRule,
type PlanOutcome,
type PlanningRequest,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
import { createPlanningCalculation } from './leave-planner.service';
const deficit: OptimizationPlanningRule = {
id: 'deficit',
name: 'Internal work-sequence deficit',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit,
};
const longest: OptimizationPlanningRule = {
id: 'longest',
name: 'Longest work sequence',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.longestWorkSequence,
};
const gap: PlanningRequest = {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [deficit],
};
function plan(request: PlanningRequest): PlanOutcome {
const outcome = calculatePlanningInternal(request);
if (outcome.kind !== 'Plan') throw new Error(`Expected a plan, got ${outcome.kind}`);
return outcome;
}
describe('composed work-sequence preferences', () => {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
it('fills a complete internal sequence despite the worse deficit of partial additions', () => {
const outcome = plan(gap);
expect(outcome.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
expect(outcome.placements.every(({ supporting }) => supporting)).toBe(true);
expect(outcome.declaredDays).toEqual(gap.declaredDays);
expect(outcome.metrics.shortSequenceDeficit).toBe(0);
expect(outcome.remainingBalances).toEqual([]);
});
it('splits tied longest sequences together when neither single addition improves the maximum', () => {
const outcome = plan({
...gap,
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-02-01' }],
declaredDays: [{ date: '2024-01-10', type: 'other' }],
rules: [longest],
});
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-08', '2024-01-11']);
expect(outcome.metrics.longestWorkSequence).toBe(1);
});
it('leaves a three-day internal sequence intact at threshold three instead of filling it at five', () => {
const outcome = plan({
...gap,
rules: [
{
...deficit,
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
thresholdDays: 3,
},
},
],
});
expect(outcome.placements).toEqual([]);
expect(outcome.remainingBalances[0].quantity).toBe(3);
expect(outcome.metrics.shortSequenceDeficit).toBe(2);
});
it('counts selected internal sequences even when their measured deficit is zero', () => {
const outcome = plan({
...gap,
rules: [
{
...deficit,
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
thresholdDays: 3,
aggregation: 'count',
},
},
],
});
expect(outcome.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
});
it('evaluates two parameterized configurations separately and follows their order', () => {
const maximizeThree: OptimizationPlanningRule = {
...deficit,
id: 'three',
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
thresholdDays: 3,
direction: 'maximize',
},
};
const fiveFirst = plan({ ...gap, rules: [deficit, maximizeThree] });
const threeFirst = plan({ ...gap, rules: [maximizeThree, deficit] });
expect(fiveFirst.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
expect(threeFirst.placements.map(({ date }) => date)).toEqual(['2024-01-10']);
expect(threeFirst.metrics.shortSequenceDeficit).toBe(8);
});
it('obeys the order between the longest-sequence preference and a personal window', () => {
const personal = {
id: 'personal',
type: 'time_window' as const,
startDate: '2024-01-08',
endDate: '2024-01-08',
placeDays: 1,
placeType: 'cp',
};
const request: PlanningRequest = {
horizon: { start: '2024-01-08', end: '2024-01-20' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-02-01' }],
rules: [],
};
const longestFirst = plan({ ...request, rules: [longest, personal] });
const personalFirst = plan({ ...request, rules: [personal, longest] });
expect(longestFirst.placements.map(({ date }) => date)).toEqual(['2024-01-12']);
expect(longestFirst.metrics.longestWorkSequence).toBe(5);
expect(personalFirst.placements.map(({ date }) => date)).toEqual(['2024-01-08']);
expect(personalFirst.metrics.longestWorkSequence).toBe(9);
});
it('keeps obligations and earlier preferences when a complete gap would consume their balance', () => {
const personal = {
id: 'personal',
type: 'time_window' as const,
startDate: '2024-01-15',
endDate: '2024-01-15',
placeDays: 1,
placeType: 'cp',
};
const request = { ...gap, horizon: { start: '2024-01-08', end: '2024-01-16' } };
expect(
plan({ ...request, rules: [deficit, personal] }).placements.map(({ date }) => date),
).toEqual(['2024-01-09', '2024-01-10', '2024-01-11']);
for (const force of ['preference', 'obligation'] as const) {
const outcome = plan({ ...request, rules: [{ ...personal, force }, deficit] });
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-15']);
expect(outcome.metrics.shortSequenceDeficit).toBe(2);
}
});
it('uses only eligible balance dates when completing the entire gap', () => {
const outcome = plan({
...gap,
balances: [
{ id: 'early', type: 'rtt', quantity: 1, expiryDate: '2024-01-09' },
{
id: 'late',
type: 'cp',
quantity: 2,
availableFrom: '2024-01-10',
expiryDate: '2024-02-01',
},
],
});
expect(outcome.placements.map(({ date, balanceId }) => ({ date, balanceId }))).toEqual([
{ date: '2024-01-09', balanceId: 'early' },
{ date: '2024-01-10', balanceId: 'late' },
{ date: '2024-01-11', balanceId: 'late' },
]);
expect(outcome.placements.map(({ supporting }) => supporting)).toEqual([false, true, true]);
const unavailable = plan({
...gap,
balances: [
{
id: 'late',
type: 'cp',
quantity: 3,
availableFrom: '2024-01-10',
expiryDate: '2024-02-01',
},
],
});
expect(unavailable.placements).toEqual([]);
});
it('adds no neutral placement when the budget cannot split all tied maxima', () => {
const outcome = plan({
...gap,
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-02-01' }],
declaredDays: [{ date: '2024-01-10', type: 'other' }],
rules: [longest],
});
expect(outcome.placements).toEqual([]);
expect(outcome.metrics.longestWorkSequence).toBe(2);
});
it('splits tied sequences at eligible dates when their centers are unavailable', () => {
const outcome = plan({
horizon: { start: '2024-01-08', end: '2024-01-19' },
declaredDays: [{ date: '2024-01-12', type: 'other' }],
balances: [
{ id: 'early', type: 'cp', quantity: 1, expiryDate: '2024-01-08' },
{
id: 'late',
type: 'cp',
quantity: 1,
availableFrom: '2024-01-18',
expiryDate: '2024-02-01',
},
],
rules: [longest],
});
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-08', '2024-01-18']);
expect(outcome.metrics.longestWorkSequence).toBe(3);
});
it('coordinates scarce balances when individually eligible centers cannot be funded together', () => {
const outcome = plan({
horizon: { start: '2024-01-08', end: '2024-01-19' },
declaredDays: [{ date: '2024-01-12', type: 'other' }],
balances: [
{ id: 'early', type: 'cp', quantity: 1, expiryDate: '2024-01-08' },
{ id: 'flexible', type: 'cp', quantity: 1, expiryDate: '2024-02-01' },
],
rules: [longest],
});
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-08', '2024-01-15']);
expect(outcome.metrics.longestWorkSequence).toBe(3);
});
it('eliminates tied maximum deficits as one complete gap bundle', () => {
const outcome = plan({
horizon: { start: '2024-01-08', end: '2024-01-17' },
declaredDays: ['2024-01-08', '2024-01-11', '2024-01-16'].map((date) => ({
date,
type: 'other',
})),
balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2024-02-01' }],
rules: [
{
...deficit,
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
aggregation: 'maximum',
},
},
],
});
expect(outcome.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-12',
'2024-01-15',
]);
expect(outcome.metrics.shortSequenceDeficit).toBe(0);
});
it('includes both horizon edges in the maximum while excluding them from the deficit', () => {
const outcome = plan({
horizon: { start: '2024-01-08', end: '2024-01-25' },
balances: [],
declaredDays: ['2024-01-11', '2024-01-15', '2024-01-22'].map((date) => ({
date,
type: 'other',
})),
rules: [longest, deficit],
});
expect(outcome.metrics).toMatchObject({ longestWorkSequence: 4, shortSequenceDeficit: 5 });
});
it.each([
{ horizon: { start: '2024-05-06', end: '2024-05-15' }, dates: [], longest: 5 },
{
horizon: { start: '2024-01-08', end: '2024-01-11' },
dates: ['2024-01-08', '2024-01-09', '2024-01-10'],
longest: 0,
},
{ horizon: { start: '2024-01-06', end: '2024-01-08' }, dates: [], longest: 0 },
{
horizon: { start: '2024-01-08', end: '2024-01-13' },
dates: ['2024-01-09', '2024-01-10'],
longest: 2,
},
])(
'keeps holiday/weekend continuity and zero for empty sequences: $horizon.start',
({ horizon, dates, longest: expected }) => {
const outcome = plan({
horizon,
balances: [],
declaredDays: dates.map((date) => ({ date, type: 'other' })),
rules: [longest, deficit],
});
expect(outcome.metrics.longestWorkSequence).toBe(expected);
expect(outcome.metrics.shortSequenceDeficit).toBe(0);
},
);
it('disabling work-sequence rules removes their influence even with deferred balances', () => {
const absent = plan({ ...gap, rules: [] });
const disabled = plan({
...gap,
rules: [
{ ...deficit, enabled: false },
{ ...longest, enabled: false },
],
});
expect(disabled).toEqual(absent);
const personal = {
id: 'personal',
type: 'time_window' as const,
startDate: '2024-01-09',
endDate: '2024-01-09',
placeDays: 1,
placeType: 'cp',
};
const requested = plan({ ...gap, rules: [{ ...deficit, enabled: false }, personal] });
expect(requested.placements.map(({ date }) => date)).toEqual(['2024-01-09']);
expect(requested.metrics.shortSequenceDeficit).toBe(3);
});
it('finishes a rejected tied-sequence bundle without retrying equivalent balance allocations', () => {
const outcome = plan({
deadlineMs: 1500,
horizon: { start: '2024-01-08', end: '2024-02-10' },
declaredDays: [
'2024-01-08',
'2024-01-10',
'2024-01-12',
'2024-01-16',
'2024-01-18',
'2024-01-22',
'2024-01-24',
'2024-01-26',
'2024-01-30',
'2024-02-01',
'2024-02-05',
'2024-02-07',
'2024-02-09',
].map((date) => ({ date, type: 'other' })),
balances: ['cp', 'rtt', 'other'].map((type) => ({
id: type,
type,
quantity: 12,
expiryDate: '2024-03-01',
})),
rules: [
{
id: 'support',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
},
longest,
{
id: 'typed',
type: 'time_window',
startDate: '2024-01-09',
endDate: '2024-01-09',
placeType: 'cp',
placeDays: 1,
},
],
});
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-09']);
expect(outcome.metrics.longestWorkSequence).toBe(1);
expect(outcome.metrics.supportingPlacements).toBe(0);
});
it.each([0, -1, 1.5, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])(
'rejects an invalid preference threshold %s at the planning boundary',
(thresholdDays) => {
const outcome = calculatePlanningInternal({
...gap,
rules: [
{
...deficit,
configuration: {
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
thresholdDays,
},
},
],
});
expect(outcome).toMatchObject({
kind: 'InvalidInput',
reasons: [
expect.objectContaining({
code: 'invalid-optimization-configuration',
ruleIds: ['deficit'],
}),
],
});
},
);
it('captures distinct threshold values in explanations, atomic trace and counterfactual comparisons', async () => {
vi.stubGlobal('window', undefined);
try {
const five = structuredClone(deficit);
const three: OptimizationPlanningRule = {
...deficit,
id: 'three',
configuration: { ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays: 3 },
};
const calculation = createPlanningCalculation({ ...gap, rules: [three, five] });
if (five.configuration.measure !== 'work-sequence-deficit')
throw new Error('Expected deficit');
five.configuration.thresholdDays = 1;
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
expect(outcome.placements).toHaveLength(3);
expect(
calculation.snapshot.rules.map((rule) =>
'configuration' in rule ? rule.configuration : undefined,
),
).toMatchObject([{ thresholdDays: 3 }, { thresholdDays: 5 }]);
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
expect(
explanation.placements[0].comparison.before.objectives
.filter(({ id }) => id.startsWith('optimization:'))
.map(({ value }) => value),
).toEqual([2, 4]);
const trace = await calculation.trace;
const chosen = trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.find(
(candidate) => candidate.status === 'chosen' && candidate.dates?.length === 3,
);
expect(
chosen?.comparison?.before.objectives
.filter(({ id }) => id.startsWith('optimization:'))
.map(({ value }) => value),
).toEqual([0, 2]);
expect(chosen?.comparison?.firstDecisiveObjective).toBe('optimization:deficit');
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (counterfactual.kind !== 'Counterfactual')
throw new Error('Expected a counterfactual');
expect(
counterfactual.baseline.objectives
.filter(({ id }) => id.startsWith('optimization:'))
.map(({ id, value }) => ({ id, value })),
).toEqual([
{ id: 'optimization:three', value: 2 },
{ id: 'optimization:deficit', value: 4 },
]);
} finally {
vi.unstubAllGlobals();
}
});
});
File diff suppressed because it is too large. Load diff
+340 -222
View File
@@ -1,3 +1,4 @@
import { createOptimizationPresetRules } from './rule-service';
import { vi } from 'vitest';
import {
applyReplayDeltas,
@@ -15,6 +16,18 @@ import type {
PlanningTraceUpdate,
} from './leave-planner.service';
import type { CounterfactualBaselineOptions } from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
const supportingEconomy = {
id: 'economy',
type: 'optimization' as const,
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
};
const nonWorkingGain = {
id: 'gain',
type: 'optimization' as const,
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
};
const horizon = { start: '2024-01-01', end: '2025-01-01' };
const balance = (
@@ -48,6 +61,8 @@ async function runInNonBrowser(request: Parameters<typeof planLeave>[0]) {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('calculated work-sequence and time-off-period statistics', () => {
it('keeps fractional work means and merges consecutive declared days across a weekend', async () => {
const result = isPlan(
@@ -1292,7 +1307,7 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: { start: '2024-01-02', end: '2024-01-06' },
balances: [balance('remaining-cp', 'cp', 4, '2024-01-05')],
rules: [],
rules: [presets[0]],
declaredDays,
}),
);
@@ -1358,7 +1373,7 @@ describe('planLeave', () => {
balances: [
{ ...balance('cp', 'cp', 1, '2024-05-13'), availableFrom: '2024-05-13' },
],
rules: [],
rules: [presets[0]],
declaredDays,
}),
);
@@ -1438,7 +1453,7 @@ describe('planLeave', () => {
balances: [
{ ...balance('cp', 'cp', 2, '2024-01-04'), availableFrom: '2024-01-04' },
],
rules: [],
rules: [presets[0]],
declaredDays,
}),
);
@@ -1466,7 +1481,7 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: selectedHorizon,
balances: [],
rules: [],
rules: [presets[0]],
declaredDays: declaredDays.filter(({ date }) => date !== '2024-01-03'),
}),
);
@@ -1521,7 +1536,7 @@ describe('planLeave', () => {
const result = isPlan(
await runInNonBrowser({
horizon: { start, end },
rules: [],
rules: [presets[0]],
declaredDays,
balances: mixed
? [{ ...balance('cp', 'cp', 1, '2024-05-10'), availableFrom: '2024-05-10' }]
@@ -1602,7 +1617,7 @@ describe('planLeave', () => {
const request: PlanningRequest = {
horizon: { start: '2026-11-01', end: '2026-12-01' },
balances: [balance('cp', 'cp', 2, '2026-11-30')],
rules: [],
rules: presets,
declaredDays: [
{ date: '2026-11-09', type: 'other' },
{ date: '2026-11-10', type: 'rtt' },
@@ -1619,15 +1634,19 @@ describe('planLeave', () => {
await runInNonBrowser({
...request,
rules: [
{
id: 'preferred-friday',
type: 'time_window',
force: 'preference',
startDate: '2026-11-20',
endDate: '2026-11-20',
placeDays: 1,
placeType: 'cp',
},
presets[0],
...[
{
id: 'preferred-friday',
type: 'time_window',
force: 'preference',
startDate: '2026-11-20',
endDate: '2026-11-20',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
}),
);
@@ -1927,7 +1946,7 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon,
balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 1)],
rules,
rules: [presets[0], ...rules],
}),
);
@@ -2015,15 +2034,18 @@ describe('planLeave', () => {
},
],
rules: [
{
id: 'rtt-obligation',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-03',
placeDays: 1,
placeType: 'rtt',
},
presets[0],
...[
{
id: 'rtt-obligation',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-03',
placeDays: 1,
placeType: 'rtt',
},
],
],
}),
);
@@ -2256,20 +2278,28 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: { start: '2024-01-05', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2025-01-01')],
rules: [],
rules: [nonWorkingGain, supportingEconomy],
}),
);
const supporting = result.placements.find((placement) => placement.supporting);
expect(supporting?.ruleIds).toEqual([]);
expect(supporting?.ruleIds).toEqual(['gain']);
expect(supporting?.balanceId).toBe('cp');
});
it('does not trade a short-sequence deficit for supporting placement gain', async () => {
it('does not trade an earlier short-sequence deficit for supporting placement gain', async () => {
const result = isPlan(
await runInNonBrowser({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp-four', 'cp', 4, '2025-01-01')],
rules: [],
rules: [
{
id: 'deficit',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit,
},
nonWorkingGain,
supportingEconomy,
],
}),
);
expect(result.metrics.shortSequenceDeficit).toBe(0);
@@ -2280,7 +2310,15 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: { start: '2024-01-01', end: '2024-01-29' },
balances: [balance('cp-six', 'cp', 6, '2025-01-01')],
rules: [],
rules: [
nonWorkingGain,
{
id: 'longest',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.longestWorkSequence,
},
supportingEconomy,
],
}),
);
expect(result.metrics.supportingPlacements).toBe(
@@ -2295,19 +2333,24 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: { start: '2024-02-01', end: '2024-03-01' },
balances: [balance('cp', 'cp', 2, '2024-02-29')],
rules: [],
rules: presets,
}),
);
const fourDayPlan = isPlan(
await runInNonBrowser({
horizon: { start: '2024-02-01', end: '2024-03-01' },
balances: [balance('cp', 'cp', 4, '2024-02-29')],
rules: [],
rules: presets,
}),
);
expect(twoDayPlan.metrics).toMatchObject({
nonWorkingDayGain: 4,
longestWorkSequence: 9,
shortSequenceDeficit: 0,
});
expect(twoDayPlan.placements.map((placement) => placement.date)).toEqual([
'2024-02-09',
'2024-02-19',
'2024-02-02',
'2024-02-16',
]);
expect(fourDayPlan.placements.map((placement) => placement.date)).toEqual([
'2024-02-02',
@@ -2335,7 +2378,7 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: holidayHorizon,
balances: [balance('cp', 'cp', 4, expiryDate)],
rules: [],
rules: presets,
}),
);
@@ -2344,7 +2387,7 @@ describe('planLeave', () => {
expect(result.timeOffPeriods[0].duration).toBe(9);
});
// Async calculation yields to the test timer; allow the planner's ten-second budget.
// This full configured year is measured separately; allow instrumentation overhead without a planning deadline.
it('uses the 11 November and 14 July weeks in the representative default plan', async () => {
const result = isPlan(
await runInNonBrowser({
@@ -2364,28 +2407,32 @@ describe('planLeave', () => {
},
],
rules: [
{
id: 'default-samedi-malin',
name: 'Samedi Malin',
type: 'if_then',
force: 'preference',
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other',
},
{
id: 'default-christmas',
name: 'Christmas holidays',
type: 'time_window',
force: 'preference',
startDate: '2026-12-20',
endDate: '2027-01-03',
placeDays: 5,
placeType: 'any',
},
presets[0],
...[
{
id: 'default-samedi-malin',
name: 'Samedi Malin',
type: 'if_then',
force: 'preference',
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other',
},
{
id: 'default-christmas',
name: 'Christmas holidays',
type: 'time_window',
force: 'preference',
startDate: '2026-12-20',
endDate: '2027-01-03',
placeDays: 5,
placeType: 'any',
},
],
...presets.slice(1),
],
}),
);
@@ -2402,7 +2449,7 @@ describe('planLeave', () => {
'2027-07-16',
]),
);
}, 15_000);
}, 120_000);
it('moves a satisfied time-window preference to complete a holiday week', async () => {
const result = isPlan(
@@ -2410,15 +2457,19 @@ describe('planLeave', () => {
horizon: { start: '2027-07-01', end: '2027-08-01' },
balances: [balance('cp', 'cp', 4, '2027-07-31')],
rules: [
{
id: 'july',
type: 'time_window',
force: 'preference',
startDate: '2027-07-01',
endDate: '2027-07-31',
placeDays: 4,
placeType: 'any',
},
presets[0],
...[
{
id: 'july',
type: 'time_window',
force: 'preference',
startDate: '2027-07-01',
endDate: '2027-07-31',
placeDays: 4,
placeType: 'any',
},
],
...presets.slice(1),
],
}),
);
@@ -2437,7 +2488,14 @@ describe('planLeave', () => {
await runInNonBrowser({
horizon: { start: '2027-07-01', end: '2027-08-01' },
balances: [balance('cp', 'cp', 4, '2028-07-31')],
rules: [],
rules: [
{
id: 'holiday',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
},
supportingEconomy,
],
}),
);
@@ -2456,15 +2514,19 @@ describe('planLeave', () => {
horizon: { start: '2024-01-08', end: '2024-01-15' },
balances: [balance('cp', 'cp', 1)],
rules: [
{
id: 'window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-09',
endDate: '2024-01-12',
placeDays: 1,
placeType: 'cp',
},
presets[0],
...[
{
id: 'window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-09',
endDate: '2024-01-12',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
}),
);
@@ -2749,15 +2811,19 @@ describe('planLeave', () => {
},
],
rules: [
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'cp',
},
presets[0],
...[
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
};
const expected = isPlan(await runInNonBrowser(request));
@@ -2767,7 +2833,8 @@ describe('planLeave', () => {
const outcome = await runInNonBrowser({
...request,
progress: (stage) => {
if (stage === 'at-risk') queueMicrotask(() => (elapsed = 20_000));
if (stage === 'optimization:default-unplaced-at-risk')
queueMicrotask(() => (elapsed = 20_000));
},
});
expect(elapsed).toBeGreaterThan(10_000);
@@ -2953,8 +3020,8 @@ describe('planLeave transport', () => {
if (expected.kind !== 'Plan' || !checkpointState) return;
checkpoint = {
...expected,
completedStages: ['validation', 'obligations', 'at-risk'],
lastCompletedStage: 'at-risk',
completedStages: ['validation', 'obligations', 'optimization:default-unplaced-at-risk'],
lastCompletedStage: 'optimization:default-unplaced-at-risk',
};
const initialState: PlanningTraceState = {
horizon: request.horizon,
@@ -3271,7 +3338,7 @@ describe('planning calculation context', () => {
const calculation = createPlanningCalculation(submitted);
submitted.declaredDays![0].date = '2024-01-11';
submitted.declaredDays![0].type = 'rtt';
submitted.rules[0].placeDays = 4;
if ('placeDays' in submitted.rules[0]) submitted.rules[0].placeDays = 4;
if ('endDate' in submitted.rules[0]) submitted.rules[0].endDate = '2024-01-19';
submitted.horizon.end = '2024-01-11';
submitted.labels = { exact: 'Edited later' };
@@ -3378,7 +3445,7 @@ describe('planning calculation context', () => {
balances: [
{ ...balance('cp', 'cp', 1, '2024-12-30'), availableFrom: '2024-12-30' },
],
rules: [],
rules: [presets[0]],
declaredDays,
});
const outcome = isPlan(await calculation.outcome);
@@ -3407,7 +3474,7 @@ describe('planning calculation context', () => {
const submitted: PlanningRequest = {
horizon: { start: '2026-11-01', end: '2026-12-01' },
balances: [balance('cp', 'cp', 2, '2026-11-30')],
rules: [],
rules: presets,
declaredDays: [
{ date: '2026-11-09', type: 'other' },
{ date: '2026-11-10', type: 'rtt' },
@@ -3427,7 +3494,9 @@ describe('planning calculation context', () => {
for (const placement of explanation.placements) {
expect(placement.timeOffPeriod).toEqual(outcome.timeOffPeriods[0]);
expect(placement.finalMetrics).toEqual(outcome.metrics);
expect(placement.firstDecisiveObjective).toBe('unplaced-at-risk');
expect(placement.firstDecisiveObjective).toBe(
'optimization:default-unplaced-at-risk',
);
}
const trace = await calculation.trace;
expect(trace?.initialState.declaredDays).toEqual(original);
@@ -3446,11 +3515,13 @@ describe('planning calculation context', () => {
(candidate) =>
candidate.comparison?.after.objectives.some(
(objective) =>
objective.id === 'holiday-week-value' && objective.value === 3,
objective.id === 'optimization:default-holiday-week-value' &&
objective.value === 3,
) &&
candidate.comparison.after.objectives.some(
(objective) =>
objective.id === 'non-working-day-gain' && objective.value === 5,
objective.id === 'optimization:default-non-working-day-gain' &&
objective.value === 5,
),
),
).toBe(true);
@@ -3463,10 +3534,16 @@ describe('planning calculation context', () => {
const alternative = analysis.results.find(({ date }) => date === '2026-11-20');
expect(alternative?.status).toBe('eligible');
expect(alternative?.comparison?.after.objectives).toContainEqual(
expect.objectContaining({ id: 'holiday-week-value', value: 0 }),
expect.objectContaining({
id: 'optimization:default-holiday-week-value',
value: 0,
}),
);
expect(alternative?.comparison?.after.objectives).toContainEqual(
expect.objectContaining({ id: 'non-working-day-gain', value: 7 }),
expect.objectContaining({
id: 'optimization:default-non-working-day-gain',
value: 7,
}),
);
expect(outcome).toEqual(before);
} finally {
@@ -3481,7 +3558,7 @@ describe('planning calculation context', () => {
const submitted: PlanningRequest = {
horizon: { start: '2024-01-02', end: '2024-02-01' },
balances: [balance('cp', 'cp', 4, '2024-01-05')],
rules: [],
rules: [presets[0]],
declaredDays: [
{ date: '2024-01-02', type: 'cp' },
{ date: '2024-01-03', type: 'rtt' },
@@ -3828,7 +3905,7 @@ describe('planning calculation context', () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-12-31')],
rules: [],
rules: [presets[3]],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
@@ -3886,7 +3963,7 @@ describe('planning calculation context', () => {
if (!firstOccurrence || !secondOccurrence) return;
const alternateSecondDate =
secondOccurrence.date === '2024-05-07' ? '2024-05-09' : '2024-05-07';
secondOccurrence.date === '2024-05-07' ? '2024-05-10' : '2024-05-07';
const analysis = await calculation.analyzeCounterfactual(firstOccurrence);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
@@ -3976,7 +4053,7 @@ describe('planning calculation context', () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-05', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2025-01-01')],
rules: [],
rules: [nonWorkingGain, supportingEconomy],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
@@ -3991,7 +4068,7 @@ describe('planning calculation context', () => {
const restored = analysis.results.find((result) => result.date === selected.date);
expect(
restored?.comparison?.after.objectives.find(
(objective) => objective.id === 'supporting-placements',
(objective) => objective.id === 'optimization:economy',
)?.value,
).toBe(1);
} finally {
@@ -4097,7 +4174,7 @@ describe('planning calculation context', () => {
const older = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
rules: [presets[3]],
});
const olderOutcome = await older.outcome;
expect(olderOutcome.kind).toBe('Plan');
@@ -4116,7 +4193,7 @@ describe('planning calculation context', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
rules: [presets[3]],
};
const expected = isPlan(await runInNonBrowser(request));
const selected = expected.placements[0];
@@ -4213,7 +4290,7 @@ describe('planning calculation context', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
rules: [presets[3]],
};
const expected = isPlan(await runInNonBrowser(request));
const trace = {
@@ -4343,15 +4420,18 @@ describe('planning calculation context', () => {
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 2, '2024-01-31')],
rules: [
{
id: 'wide',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 1,
placeType: 'cp',
},
presets[0],
...[
{
id: 'wide',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 1,
placeType: 'cp',
},
],
],
});
const outcome = await calculation.outcome;
@@ -4404,7 +4484,7 @@ describe('planning calculation context', () => {
availableFrom: '2024-01-04',
},
],
rules: [],
rules: [presets[0]],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
@@ -4466,7 +4546,7 @@ describe('planning calculation context', () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
rules: [presets[3]],
};
const plan = isPlan(await runInNonBrowser(request));
const firstPlacement = plan.placements[0];
@@ -4745,81 +4825,98 @@ describe('planning calculation context', () => {
});
it('does not inherit unrelated rule-invalid history into a trace decision', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-01-31')],
rules: [
{
id: 'early-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-04',
placeDays: 1,
placeType: 'cp',
},
{
id: 'late-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-03',
endDate: '2024-01-05',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const decision = trace.phases
.flatMap((phase) => phase.decisions)
.find((candidate) =>
candidate.deltas.some((delta) => delta.kind === 'placement-added'),
);
const placement = decision?.deltas.find(
(delta) => delta.kind === 'placement-added',
)?.placement;
expect(decision).toBeDefined();
expect(placement).toBeDefined();
if (!decision || !placement) return;
const candidates = trace.phases.flatMap((phase) =>
phase.decisions.flatMap((item) => item.candidates),
);
const decisionDates = new Set(
decision.candidates.flatMap(
(candidate) => candidate.dates ?? (candidate.date ? [candidate.date] : []),
const core = await import('./leave-planner.core');
const request: PlanningRequest = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-01-10')],
rules: [presets[0]],
};
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
request,
undefined,
(value) => (artifacts = value),
);
expect(outcome.kind).toBe('Plan');
const trace = artifacts.trace && {
...artifacts.trace,
phases: [...artifacts.trace.phases],
};
artifacts = { ...artifacts, trace };
if (outcome.kind !== 'Plan' || !trace)
throw new Error('Expected an actual plan and trace.');
const decision = trace.phases
.flatMap((phase) => phase.decisions)
.find((item) => item.deltas.some((delta) => delta.kind === 'placement-added'));
const placement = decision?.deltas.find(
(delta) => delta.kind === 'placement-added',
)?.placement;
if (!decision || !placement) throw new Error('Expected an actual placement decision.');
// An unrelated decision's rejection must not become the selected decision's history.
const unrelatedDate = '2024-01-04';
decision.candidates = decision.candidates.filter(
(candidate) =>
!(candidate.dates ?? (candidate.date ? [candidate.date] : [])).includes(
unrelatedDate,
),
);
const unrelatedIneligible = candidates.find(
(candidate) =>
candidate.status === 'ineligible' &&
candidate.reason?.code === 'rule-invalid' &&
candidate.date &&
!decisionDates.has(candidate.date),
);
expect(unrelatedIneligible).toBeDefined();
if (!unrelatedIneligible?.date) return;
);
trace.phases.push({
id: 'unrelated-phase',
name: 'unrelated',
completed: true,
status: 'completed',
decisions: [
{
id: 'unrelated-decision',
action: 'reject',
dates: [unrelatedDate],
deltas: [],
candidates: [
{
status: 'ineligible',
date: unrelatedDate,
reason: {
code: 'rule-invalid',
message: 'Another decision rejected this date.',
},
},
],
},
],
});
class HistoricalTraceWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(): void {
queueMicrotask(() => {
this.onmessage?.({ data: { type: 'result', outcome } } as MessageEvent);
this.onmessage?.({ data: { type: 'artifacts', artifacts } } as MessageEvent);
});
}
terminate(): void {}
}
const browserWindow = globalThis.window;
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', HistoricalTraceWorker);
try {
const calculation = createPlanningCalculation(request);
await calculation.trace;
vi.stubGlobal('window', undefined);
const analysis = await calculation.analyzeCounterfactual({
decisionId: decision.id,
placement,
});
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(
analysis.results.find((result) => result.date === unrelatedIneligible.date)
?.historicalStatus,
).toBe('not-examined');
expect(analysis.results.find((result) => result.date === unrelatedDate)).toMatchObject({
historicalStatus: 'not-examined',
historicalReason: undefined,
});
expect(
analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus,
).toBe('ineligible');
} finally {
vi.unstubAllGlobals();
vi.stubGlobal('window', browserWindow);
}
});
@@ -4831,7 +4928,7 @@ describe('planning calculation context', () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 4, '2024-01-31')],
rules: [],
rules: presets,
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
@@ -4854,12 +4951,12 @@ describe('planning calculation context', () => {
expect(trace.phases.map((phase) => phase.name)).toEqual(
expect.arrayContaining([
'obligations',
'at-risk',
'holiday-week-value',
'non-working-day-gain',
'longest-work-sequence',
'short-sequence-deficit',
'supporting-placements',
'optimization:default-unplaced-at-risk',
'optimization:default-holiday-week-value',
'optimization:default-non-working-day-gain',
'optimization:default-longest-work-sequence',
'optimization:default-short-sequence-deficit',
'optimization:default-supporting-placements',
'expiry',
'dates',
]),
@@ -4881,7 +4978,7 @@ describe('planning calculation context', () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-04-01' },
balances: [balance('cp', 'cp', 25, '2024-03-31')],
rules: [],
rules: presets,
});
expect((await calculation.outcome).kind).toBe('Plan');
const trace = await calculation.trace;
@@ -4907,16 +5004,18 @@ describe('planning calculation context', () => {
);
expect(chosenComparisons.length).toBeGreaterThan(0);
for (const { decision, candidate: chosen } of chosenComparisons) {
const changedDates = chosen.dates ?? (chosen.date ? [chosen.date] : []);
expect(
decision.deltas.some(
(delta) =>
(delta.kind === 'placement-moved' &&
delta.toDate === chosen.date &&
delta.balanceId === chosen.placement?.balanceId) ||
changedDates.includes(delta.toDate ?? '')) ||
(delta.kind === 'placement-added' &&
delta.date === chosen.date &&
delta.balanceId === chosen.placement?.balanceId) ||
(delta.kind === 'placement-removed' && delta.date === chosen.date),
changedDates.includes(delta.date ?? '')) ||
(delta.kind === 'placement-removed' &&
changedDates.includes(delta.date ?? '')) ||
(delta.kind === 'placement-updated' &&
changedDates.includes(delta.date ?? '')),
),
).toBe(true);
}
@@ -4942,7 +5041,7 @@ describe('planning calculation context', () => {
}
});
it('records every eligible balance when a preference keeps the first assignment', async () => {
it('evaluates every eligible balance and retains the earlier expiry for an equal preference', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
@@ -4980,10 +5079,12 @@ describe('planning calculation context', () => {
status: candidate.status,
reason: candidate.reason?.code,
})) ?? [];
expect(candidates.slice(0, 2)).toEqual([
{ balanceId: 'earlier', status: 'chosen', reason: undefined },
{ balanceId: 'later', status: 'not-examined', reason: 'first-compatible-balance' },
]);
expect(candidates).toEqual(
expect.arrayContaining([
{ balanceId: 'earlier', status: 'chosen', reason: undefined },
{ balanceId: 'later', status: 'evaluated-rejected', reason: undefined },
]),
);
} finally {
vi.stubGlobal('window', browserWindow);
}
@@ -5026,7 +5127,11 @@ describe('planning calculation context', () => {
{ id: 'cp-only', label: 'Paid leave only' },
]);
const trace = await conflictCalculation.trace;
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
expect(
trace?.phases.some(
(phase) => phase.name === 'optimization:default-holiday-week-value',
),
).toBe(false);
} finally {
vi.stubGlobal('window', browserWindow);
}
@@ -5056,28 +5161,41 @@ describe('planning calculation context', () => {
it('explains an incomplete incumbent without reusing the expired planning deadline', async () => {
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const outcome = core.calculatePlanningInternal(
{
horizon,
balances: Array.from({ length: 12 }, (_, index) =>
balance(`balance-${index}`, index % 2 ? 'rtt' : 'cp', 12),
),
rules: [],
deadlineMs: 20,
},
undefined,
(value) => (artifacts = value),
);
expect(outcome.kind).toBe('PlanningIncomplete');
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.incumbent).toBeDefined();
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
expect(artifacts.explanation.incomplete?.reason.code).toBe('deadline');
expect(artifacts.explanation.placements).toHaveLength(
outcome.incumbent?.placements.length ?? 0,
);
let clock = 0;
let expired = false;
const clockSpy = vi.spyOn(performance, 'now').mockImplementation(() => {
if (expired) clock += 100;
return clock;
});
try {
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const outcome = core.calculatePlanningInternal(
{
horizon,
balances: Array.from({ length: 12 }, (_, index) =>
balance(`balance-${index}`, index % 2 ? 'rtt' : 'cp', 12),
),
rules: presets,
deadlineMs: 20,
progress: (stage: string) => {
if (stage === `optimization:${presets[0].id}`) expired = true;
},
},
undefined,
(value) => (artifacts = value),
);
expect(outcome.kind).toBe('PlanningIncomplete');
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.incumbent).toBeDefined();
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
expect(artifacts.explanation.incomplete?.reason.code).toBe('deadline');
expect(artifacts.explanation.placements).toHaveLength(
outcome.incumbent?.placements.length ?? 0,
);
} finally {
clockSpy.mockRestore();
}
});
it('publishes the worker outcome before delayed explanation artifacts', async () => {
@@ -80,6 +80,7 @@ export type {
TimeOffPeriod,
TimeWindowContribution,
TimeWindowPlanningRule,
OptimizationPlanningRule,
TraceCounterfactualSelection,
TraceCandidateStatus,
} from './leave-planner.core';
@@ -117,6 +118,7 @@ interface UntrustedRecord {
placement?: unknown;
rewardDays?: unknown;
rewardType?: unknown;
configuration?: unknown;
}
interface TransportSnapshot {
@@ -238,6 +240,12 @@ function snapshotRequest(request: unknown): TransportSnapshot {
enabled: value?.enabled,
force: value?.force,
};
if (
typeof value?.type === 'string' &&
value.type.toLowerCase() === 'optimization'
) {
return { ...base, configuration: cloneUnknown(value.configuration) };
}
if (typeof value?.type === 'string' && value.type.toLowerCase() === 'time_window') {
return {
...base,
+160 -24
View File
@@ -15,11 +15,14 @@ import { CalendarYear } from '../components/calendar-year/calendar-year';
import { CalendarService, SelectableDayType } from './calendar-service';
import { CalendarSettingsService } from './calendar-settings-service';
import { UserInputService } from './user-input-service';
import { RuleService, RuleType } from './rule-service';
import { RuleService, RuleType, createOptimizationPresetRules } from './rule-service';
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('real planning worker cancellation', () => {
let directory: string;
let bundle: string;
@@ -43,7 +46,7 @@ const originalNow = performance.now.bind(performance);
if (workerData.simulateLongCalculation)
Object.defineProperty(performance, 'now', { value: () => originalNow() + elapsed });
globalThis.self = { postMessage: (message) => {
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'at-risk')
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'optimization:default-unplaced-at-risk')
setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0);
parentPort.postMessage(message);
} };
@@ -109,6 +112,123 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it('preserves a complete optimization configuration and active order in the real worker snapshot', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.unplacedAtRisk,
);
configuration.aggregation = 'maximum';
const submitted = structuredClone(configuration);
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-04' }],
rules: [
{
id: 'personal-loss',
name: 'Captured loss rule',
type: 'optimization',
force: 'preference',
configuration,
},
],
});
configuration.conditions = [];
configuration.direction = 'maximize';
configuration.aggregation = 'count';
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
expect(outcome.placements).toHaveLength(1);
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(calculation.snapshot.rules[0]).toMatchObject({
name: 'Captured loss rule',
configuration: submitted,
});
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected worker explanation');
expect(
explanation.placements[0].objectiveVector.objectives.map((objective) => objective.id),
).toEqual([
'obligation-validity',
'optimization:personal-loss',
'balance-expiry',
'placement-date',
]);
const trace = await calculation.trace;
expect(trace?.phases.map((phase) => phase.name)).toEqual([
'validation',
'obligations',
'optimization:personal-loss',
'expiry',
'dates',
]);
const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (comparison.kind !== 'Counterfactual') throw new Error('Expected worker comparison');
expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual([
'obligation-validity',
'optimization:personal-loss',
'balance-expiry',
'placement-date',
]);
});
it('keeps the submitted work-sequence threshold in the real worker, trace and counterfactuals', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.shortSequenceDeficit,
);
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [
{
id: 'deficit',
name: 'Captured five-day preference',
type: 'optimization',
configuration,
},
],
});
configuration.thresholdDays = 3;
configuration.conditions = [];
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
expect(outcome.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
expect(calculation.snapshot.rules[0]).toMatchObject({
configuration: { thresholdDays: 5, conditions: ['internal', 'non-empty'] },
});
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
expect(
explanation.placements[0].comparison.before.objectives.find(
({ id }) => id === 'optimization:deficit',
)?.value,
).toBe(4);
const trace = await calculation.trace;
const chosen = trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.find((candidate) => candidate.status === 'chosen' && candidate.dates?.length === 3);
expect(chosen?.comparison?.firstDecisiveObjective).toBe('optimization:deficit');
expect(
chosen?.comparison?.before.objectives.find(({ id }) => id === 'optimization:deficit')
?.value,
).toBe(2);
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (counterfactual.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
expect(
counterfactual.baseline.objectives.find(({ id }) => id === 'optimization:deficit')
?.value,
).toBe(4);
expect(
counterfactual.results
.find(({ date }) => date === '2024-01-09')
?.comparison?.after.objectives.find(({ id }) => id === 'optimization:deficit')
?.value,
).toBe(0);
});
it.each([
[false, false],
[true, false],
@@ -120,7 +240,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
localStorage.clear();
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
vi.setSystemTime(new Date('2024-05-01T21:59:59Z'));
await TestBed.configureTestingModule({
imports: [CalendarYear],
@@ -131,6 +251,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
for (const date of ['2024-04-29', '2024-04-30']) input.toggleDeclaredDay(date, 'cp');
input.toggleDeclaredDay('2024-05-06', 'other');
const rules = TestBed.inject(RuleService);
rules.addRule(presets[0]);
rules.addRule({
id: 'producer',
name: 'CP autour du férié',
@@ -155,6 +276,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
placeDays: 2,
placeType: SelectableDayType.OTHER,
});
for (const preset of presets.slice(1)) rules.addRule(preset);
const calendar = TestBed.inject(CalendarService);
const original = calendar.createPlanningCalculation.bind(calendar);
let calculation!: PlanningCalculationContext;
@@ -168,7 +290,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
...request,
progress: (stage) => {
request.progress?.(stage);
if (cancelled && stage === 'non-working-day-gain') {
if (
cancelled &&
stage === 'optimization:default-non-working-day-gain'
) {
fixture.detectChanges();
fixture.nativeElement
.querySelector('button.cancel-calculation')
@@ -249,7 +374,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expect(dialog.textContent?.includes('Planning partiel')).toBe(cancelled);
const originalDeclarations = structuredClone(calculation.snapshot.declaredDays);
input.toggleDeclaredDay('2024-04-30', 'rtt');
rules.updateRule({ ...rules.rules[0], name: 'Règle modifiée après calcul' });
rules.updateRule({
...rules.rules.find(({ id }) => id === 'producer')!,
name: 'Règle modifiée après calcul',
});
rules.deleteRule('producer');
input.updateBalances(() => []);
if (worker) {
@@ -355,7 +483,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
balances: [
{ id: 'cp', type: 'cp' as const, quantity: 4, expiryDate: '2024-01-05' },
],
rules: [],
rules: presets,
declaredDays: [
{ date: '2024-01-02', type: 'cp' as const },
{ date: '2024-01-03', type: 'rtt' as const },
@@ -370,7 +498,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
{
...submitted,
progress: (stage: string) => {
if (stage === 'non-working-day-gain') interruption = true;
if (stage === 'optimization:default-non-working-day-gain')
interruption = true;
},
},
undefined,
@@ -383,7 +512,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
const calculation = createPlanningCalculation({
...submitted,
progress: (stage) => {
if (cancelled && stage === 'non-working-day-gain') calculation.cancel();
if (cancelled && stage === 'optimization:default-non-working-day-gain')
calculation.cancel();
},
});
const outcome = await calculation.outcome;
@@ -546,26 +676,30 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
rules: [
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp',
},
presets[0],
...[
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
progress: (stage) => {
if (stage === 'non-working-day-gain') calculation.cancel();
if (stage === 'optimization:default-non-working-day-gain') calculation.cancel();
},
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('PlanningIncomplete');
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.completedStages).toContain('holiday-week-value');
expect(outcome.completedStages).not.toContain('non-working-day-gain');
expect(outcome.completedStages).toContain('optimization:default-holiday-week-value');
expect(outcome.completedStages).not.toContain('optimization:default-non-working-day-gain');
const incumbent = outcome.incumbent!;
expect(incumbent.placements).toHaveLength(3);
expect(incumbent.placements).toContainEqual({
@@ -585,9 +719,11 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
const trace = await calculation.trace;
expect(trace?.status).toBe('incomplete');
expect(trace?.error?.code).toBe('user-cancelled');
expect(trace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status).toBe(
'interrupted',
);
expect(
trace?.phases.find(
(phase) => phase.name === 'optimization:default-non-working-day-gain',
)?.status,
).toBe('interrupted');
const frozenOutcome = structuredClone(outcome);
calculation.cancel();
expect(await calculation.outcome).toEqual(frozenOutcome);
@@ -605,7 +741,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expiryDate: '2026-12-24',
},
],
rules: [],
rules: presets,
};
const { calculatePlanningInternal } = await import('./leave-planner.core');
const expected = calculatePlanningInternal(submitted);
@@ -0,0 +1,480 @@
import { describe, expect, it } from 'vitest';
import {
calculatePlanningInternal,
type PlanningArtifacts,
type PlanningRequest,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
import { createOptimizationPresetRules } from './rule-service';
const presets = createOptimizationPresetRules();
const composedRequest: PlanningRequest & { calculationDate: string } = {
calculationDate: '2024-04-01',
horizon: { start: '2024-05-06', end: '2024-05-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2024-05-06',
expiryDate: '2024-05-17',
},
],
rules: presets,
};
describe('integrated optimization and earned leave', () => {
it.each(presets)(
'makes disabled $name identical to its absence in the complete configuration',
(disabled) => {
const absent = calculatePlanningInternal({
...composedRequest,
rules: presets.filter(({ id }) => id !== disabled.id),
});
const inactive = calculatePlanningInternal({
...composedRequest,
rules: presets.map((rule) =>
rule.id === disabled.id ? { ...rule, enabled: false } : rule,
),
});
expect(absent.kind).toBe('Plan');
expect(inactive).toEqual(absent);
},
);
it('runs the complete configuration once after the sole obligation state and records exact reused evaluations', () => {
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
composedRequest,
undefined,
(value) => (artifacts = value),
);
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [{ date: '2024-05-06' }, { date: '2024-05-07' }, { date: '2024-05-10' }],
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
});
const trace = artifacts.trace;
expect(trace?.status).toBe('ready');
expect(
trace?.phases
.find(({ name }) => name === 'obligations')
?.decisions.flatMap(({ candidates }) => candidates)
.some(({ comparison }) => comparison),
).toBe(false);
expect(
trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.some(({ reason }) => reason?.code === 'unchanged-rule-state'),
).toBe(true);
expect(
trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.some(
({ status, reason }) =>
status === 'not-examined' && reason?.code === 'unchanged-generic-state',
),
).toBe(true);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation.');
expect(
artifacts.explanation.placements[0].objectiveVector.objectives.map(({ id }) => id),
).toEqual([
'obligation-validity',
'optimization:default-unplaced-at-risk',
'optimization:default-holiday-week-value',
'optimization:default-non-working-day-gain',
'optimization:default-longest-work-sequence',
'optimization:default-short-sequence-deficit',
'optimization:default-supporting-placements',
'balance-expiry',
'placement-date',
]);
});
it('preserves the first equivalent funder while evaluating typed window alternatives', () => {
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
{
...composedRequest,
balances: ['first-cp', 'second-cp', 'rtt'].map((id) => ({
id,
type: id === 'rtt' ? 'rtt' : 'cp',
quantity: 1,
availableFrom: '2024-05-06',
expiryDate: '2024-05-17',
})),
rules: [
presets[0],
{
id: 'typed-window',
type: 'time_window',
startDate: '2024-05-07',
endDate: '2024-05-07',
placeDays: 1,
placeType: 'rtt',
},
...presets.slice(1),
],
},
undefined,
(value) => (artifacts = value),
);
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [
{ date: '2024-05-06', balanceId: 'second-cp' },
{ date: '2024-05-07', balanceId: 'rtt' },
{ date: '2024-05-10', balanceId: 'first-cp' },
],
preferences: [{ ruleId: 'typed-window', satisfied: 1 }],
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
});
const candidates = artifacts.trace?.phases
.find(({ name }) => name === `optimization:${presets[0].id}`)
?.decisions.flatMap(({ candidates }) => candidates);
const skipped = candidates?.filter(
({ reason }) => reason?.code === 'equivalent-date-funding',
);
expect(skipped?.length).toBeGreaterThan(0);
expect(
skipped?.every(({ status, comparison }) => status === 'not-examined' && !comparison),
).toBe(true);
expect(
skipped?.some(
({ date, placement }) => date === '2024-05-07' && placement?.balanceId === 'rtt',
),
).toBe(false);
expect(
candidates?.some(
({ date, placement, comparison }) =>
date === '2024-05-07' && placement?.balanceId === 'rtt' && comparison,
),
).toBe(true);
});
it.each(['maximum', 'unsafe-total'] as const)(
'keeps distinct funding evaluations when equivalence cannot be proved (%s)',
(control) => {
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
{
calculationDate: '2024-01-01',
horizon: { start: '2024-01-02', end: '2024-01-03' },
balances: [
{
id: 'first',
type: 'cp',
quantity: control === 'maximum' ? 1 : Number.MAX_SAFE_INTEGER,
expiryDate: '2024-01-02',
},
{
id: 'second',
type: 'rtt',
quantity: control === 'maximum' ? 2 : 1,
expiryDate: '2024-01-02',
},
],
rules: [
presets[0],
...(control === 'maximum'
? [
{
id: 'largest-balance',
type: 'optimization',
configuration: {
...OPTIMIZATION_PRESETS.unplacedAtRisk,
aggregation: 'maximum',
},
},
]
: []),
],
},
undefined,
(value) => (artifacts = value),
);
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [{ balanceId: control === 'maximum' ? 'second' : 'first' }],
});
const candidates = artifacts.trace?.phases
.find(({ name }) => name === `optimization:${presets[0].id}`)
?.decisions.flatMap(({ candidates }) => candidates);
expect(
candidates?.some(({ reason }) => reason?.code === 'equivalent-date-funding'),
).toBe(false);
for (const balanceId of ['first', 'second'])
expect(
candidates?.some(
({ placement, comparison }) =>
placement?.balanceId === balanceId && comparison,
),
).toBe(true);
},
);
it('finishes a renamed first quantity rule at zero and still evaluates later priorities and final ties', () => {
const loss = { ...presets[0], id: 'personal-quantity', name: 'Personal quantity' };
const run = (rules: PlanningRequest['rules']) => {
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
{ ...composedRequest, rules },
undefined,
(value) => (artifacts = value),
);
return { outcome, trace: artifacts.trace };
};
const window = {
id: 'already-satisfied',
type: 'time_window',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'cp',
};
const first = run([loss, window, ...presets.slice(1)]);
expect(first.outcome).toMatchObject({
kind: 'Plan',
placements: [{ date: '2024-05-06' }, { date: '2024-05-07' }, { date: '2024-05-10' }],
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
});
const stopped = first.trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.filter(({ reason }) => reason?.code === 'quantity-prefix-lower-bound');
expect(stopped?.length).toBeGreaterThan(0);
expect(
stopped?.every(
({ status, reason }) =>
status === 'not-examined' && reason?.ruleIds?.includes(loss.id),
),
).toBe(true);
expect(
first.trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.some(
({ status, reason }) =>
status === 'not-examined' &&
reason?.code === 'satisfied-window-prefix-bound' &&
reason.ruleIds?.includes(window.id),
),
).toBe(true);
const expiry = first.trace?.phases.find(({ name }) => name === 'expiry');
expect(expiry?.completed).toBe(true);
expect(
expiry?.decisions.some(({ candidates }) =>
candidates.some(({ comparison }) => comparison),
),
).toBe(true);
const reordered = run([presets[1], loss, ...presets.slice(2)]);
expect(reordered.outcome.kind).toBe('Plan');
expect(
reordered.trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.some(({ reason }) => reason?.code === 'quantity-prefix-lower-bound'),
).toBe(false);
});
it.each(['sum', 'count', 'maximum'] as const)(
'finishes a configured maximize holiday prefix at its evaluator bound (%s)',
(aggregation) => {
const holiday = {
...presets[1],
id: 'personal-holiday',
configuration: { ...OPTIMIZATION_PRESETS.holidayWeekValue, aggregation },
};
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
{ ...composedRequest, rules: [presets[0], holiday, ...presets.slice(2)] },
undefined,
(value) => (artifacts = value),
);
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [
{ date: '2024-05-06' },
{ date: '2024-05-07' },
{ date: '2024-05-10' },
],
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
});
expect(
artifacts.trace?.phases
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
.some(
({ status, reason }) =>
status === 'not-examined' &&
reason?.code === 'satisfied-holiday-prefix-bound' &&
reason.ruleIds?.includes(holiday.id),
),
).toBe(true);
const final = artifacts.trace?.phases.find(({ name }) => name === 'expiry');
expect(final?.completed).toBe(true);
expect(
final?.decisions.some(({ candidates }) =>
candidates.some(({ comparison }) => comparison),
),
).toBe(true);
},
);
it('commits a strict holiday bundle before deferring old generic alternatives and preserves higher rules', () => {
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
{
calculationDate: '2026-11-01',
horizon: { start: '2026-11-02', end: '2026-11-14' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2026-11-30' }],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules: [
{
id: 'mandatory',
type: 'time_window',
force: 'obligation',
startDate: '2026-11-02',
endDate: '2026-11-02',
placeDays: 1,
placeType: 'cp',
},
presets[0],
{
id: 'earlier-window',
type: 'time_window',
startDate: '2026-11-02',
endDate: '2026-11-02',
placeDays: 1,
placeType: 'cp',
},
...presets.slice(1),
],
},
undefined,
(value) => (artifacts = value),
);
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [{ date: '2026-11-02' }, { date: '2026-11-12' }, { date: '2026-11-13' }],
preferences: [{ ruleId: 'earlier-window', satisfied: 1 }],
metrics: { unplacedAtRisk: 0, holidayWeekValue: 3 },
});
const holiday = artifacts.trace?.phases.find(
({ name }) => name === `optimization:${presets[1].id}`,
);
const decisions = holiday?.decisions.flatMap(({ candidates }) => candidates);
expect(
decisions?.some(
({ status, reason }) =>
status === 'not-examined' && reason?.code === 'strict-prefix-bundle-progress',
),
).toBe(true);
expect(decisions?.some(({ status, comparison }) => status === 'chosen' && comparison)).toBe(
true,
);
const final = artifacts.trace?.phases.find(({ name }) => name === 'expiry');
expect(final?.completed).toBe(true);
expect(
final?.decisions.some(({ candidates }) =>
candidates.some(({ comparison }) => comparison),
),
).toBe(true);
});
it.each([false, true])(
'earns an already-complete preference occurrence before comparisons (minimize every balance: %s)',
(minimizeEveryBalance) => {
const request: PlanningRequest & { calculationDate: string } = {
calculationDate: '2026-11-01',
horizon: { start: '2026-11-09', end: '2026-11-14' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2027-01-01' }],
rules: [
{
id: 'mandatory',
type: 'time_window',
force: 'obligation',
startDate: '2026-11-12',
endDate: '2026-11-12',
placeDays: 1,
placeType: 'cp',
},
...(minimizeEveryBalance
? [
{
id: 'all-balances',
type: 'optimization' as const,
configuration: {
...OPTIMIZATION_PRESETS.unplacedAtRisk,
conditions: [],
},
},
]
: []),
{
id: 'producer',
type: 'if_then',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'rtt',
},
],
};
let artifacts: PlanningArtifacts = {};
const outcome = calculatePlanningInternal(
request,
undefined,
(value) => (artifacts = value),
);
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [
{ date: '2026-11-12', type: 'cp', ruleIds: ['mandatory', 'producer'] },
],
preferences: [{ ruleId: 'producer', satisfied: 1 }],
remainingBalances: [
{
id: 'earned:producer:2026-11-11',
type: 'rtt',
quantity: 1,
availableFrom: '2026-11-13',
expiryDate: '2027-11-13',
sourceRuleId: 'producer',
},
],
});
expect(artifacts.finalState?.earnedActivations).toEqual([
{
id: 'earned:producer:2026-11-11',
ruleId: 'producer',
occurrence: '2026-11-11',
dates: ['2026-11-12'],
},
]);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation.');
expect(artifacts.explanation.placements[0].contributions).toContainEqual(
expect.objectContaining({ kind: 'rule', ruleId: 'producer' }),
);
expect(artifacts.explanation.ifThenContributions).toContainEqual(
expect.objectContaining({
ruleId: 'producer',
earnedEntitlement: {
id: 'earned:producer:2026-11-11',
type: 'rtt',
quantity: 1,
availableFrom: '2026-11-13',
expiryDate: '2027-11-13',
added: true,
},
}),
);
if (minimizeEveryBalance)
expect(
artifacts.explanation.placements[0].objectiveVector.objectives,
).toContainEqual(
expect.objectContaining({ id: 'optimization:all-balances', value: 1 }),
);
},
);
});
+508
View File
@@ -0,0 +1,508 @@
import { vi } from 'vitest';
import { calculatePlanningInternal } from './leave-planner.core';
import { createPlanningCalculation } from './leave-planner.service';
import type { PlanningRequest } from './leave-planner.service';
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
const loss = {
id: 'loss',
name: 'Prevent losses',
type: 'optimization' as const,
force: 'preference' as const,
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
};
const personal = {
id: 'personal',
name: 'Personal CP day',
type: 'time_window' as const,
force: 'preference' as const,
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp' as const,
};
const concurrent: PlanningRequest = {
horizon: { start: '2024-01-02', end: '2024-01-03' },
balances: [
{ id: 'risk-rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
{ id: 'deferred-cp', type: 'cp', quantity: 1, expiryDate: '2024-02-01' },
],
rules: [],
};
describe('explicit active planning objectives', () => {
beforeEach(() => {
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
vi.stubGlobal('window', undefined);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('rejects a sparse selector list consistently at direct and calculation boundaries', async () => {
const request = {
...concurrent,
rules: [
{ ...loss, configuration: { ...loss.configuration, conditions: new Array(1) } },
],
};
const direct = calculatePlanningInternal(request);
const calculation = await createPlanningCalculation(request).outcome;
for (const result of [direct, calculation])
expect(result).toMatchObject({
kind: 'InvalidInput',
reasons: [{ code: 'invalid-optimization-configuration', ruleIds: ['loss'] }],
});
});
it('adds no leave without active rules and retains declared days', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [{ id: 'rtt', type: 'rtt', quantity: 2, expiryDate: '2024-01-04' }],
rules: [],
declaredDays: [{ date: '2024-01-03', type: 'other' }],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
expect(outcome.placements).toEqual([]);
expect(outcome.declaredDays).toEqual([{ date: '2024-01-03', type: 'other' }]);
expect(outcome.remainingBalances[0].quantity).toBe(2);
expect(outcome.remainingBalances[0]).toMatchObject({
classification: 'at-risk',
reason: { code: 'active-priorities' },
});
});
it.each([
['z-early', 'a-later'],
['a-early', 'z-later'],
])(
'compares equal expirations before dates without preferring balance identity %s',
(earlyId, laterId) => {
const result = calculatePlanningInternal({
horizon: { start: '2024-01-09', end: '2024-01-13' },
balances: [
{
id: earlyId,
type: 'cp',
quantity: 1,
availableFrom: '2024-01-09',
expiryDate: '2024-02-01',
},
{
id: laterId,
type: 'cp',
quantity: 1,
availableFrom: '2024-01-10',
expiryDate: '2024-02-01',
},
],
rules: [{ ...personal, startDate: '2024-01-09', endDate: '2024-01-12' }],
});
expect(result).toMatchObject({
kind: 'Plan',
placements: [{ date: '2024-01-09', balanceId: earlyId }],
});
},
);
it('follows the displayed order when loss prevention competes with a personal preference', async () => {
const first = await createPlanningCalculation({ ...concurrent, rules: [loss, personal] })
.outcome;
const second = await createPlanningCalculation({ ...concurrent, rules: [personal, loss] })
.outcome;
if (first.kind !== 'Plan' || second.kind !== 'Plan')
throw new Error('Expected valid plans');
expect(first.placements.map((placement) => placement.balanceId)).toEqual(['risk-rtt']);
expect(first.metrics.unplacedAtRisk).toBe(0);
expect(first.preferences[0].satisfied).toBe(0);
expect(second.placements.map((placement) => placement.balanceId)).toEqual(['deferred-cp']);
expect(second.metrics.unplacedAtRisk).toBe(1);
expect(second.preferences[0].satisfied).toBe(1);
});
it('keeps typed obligations valid when an optimization would prefer another balance', async () => {
const outcome = await createPlanningCalculation({
...concurrent,
rules: [{ ...personal, force: 'obligation' }, loss],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.placements[0]).toMatchObject({
type: 'cp',
balanceId: 'deferred-cp',
ruleIds: ['personal'],
});
expect(outcome.metrics.unplacedAtRisk).toBe(1);
});
it('preserves scarce expiry dates before a lower personal preference', async () => {
const outcome = await createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-04' },
balances: [
{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' },
{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
],
rules: [loss, personal],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(outcome.placements.map(({ date, type }) => ({ date, type }))).toEqual([
{ date: '2024-01-02', type: 'rtt' },
{ date: '2024-01-03', type: 'cp' },
]);
expect(outcome.preferences[0].satisfied).toBe(0);
});
it('can relocate a short chain before adding a balance with a scarce date', async () => {
const outcome = await createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [
{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' },
{
id: 'flexible',
type: 'rtt',
quantity: 1,
availableFrom: '2024-01-03',
expiryDate: '2024-01-04',
},
{ id: 'scarce', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
],
rules: [loss, personal],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(outcome.placements.map(({ date, balanceId }) => ({ date, balanceId }))).toEqual([
{ date: '2024-01-02', balanceId: 'scarce' },
{ date: '2024-01-03', balanceId: 'cp' },
{ date: '2024-01-04', balanceId: 'flexible' },
]);
});
it.each(['2024-01-23', '2024-01-24'])(
'finishes oversubscribed balances without an eligible free endpoint through %s',
async (end) => {
const outcome = await createPlanningCalculation({
deadlineMs: 500,
horizon: { start: '2024-01-02', end },
balances: [
{ id: 'cp', type: 'cp', quantity: 7, expiryDate: '2024-01-22' },
{ id: 'rtt', type: 'rtt', quantity: 8, expiryDate: '2024-01-22' },
{ id: 'other', type: 'other', quantity: 1, expiryDate: '2024-01-22' },
],
rules: [loss],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a completed valid plan');
expect(outcome.placements).toHaveLength(15);
expect(outcome.metrics.unplacedAtRisk).toBe(1);
},
);
it('relocates repeated balance tokens across mandatory windows as a complete chain', async () => {
const windows = [
['cp-first', 'cp', '2024-01-02', '2024-01-03'],
['rtt-middle', 'rtt', '2024-01-03', '2024-01-04'],
['cp-last', 'cp', '2024-01-04', '2024-01-05'],
].map(([id, placeType, startDate, endDate]) => ({
id,
type: 'time_window' as const,
force: 'obligation' as const,
placeType,
placeDays: 1,
startDate,
endDate,
}));
const outcome = await createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-06' },
balances: [
{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-01-05' },
{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-05' },
{ id: 'scarce', type: 'other', quantity: 1, expiryDate: '2024-01-02' },
],
rules: [...windows, loss],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(outcome.placements.map(({ date, type }) => ({ date, type }))).toEqual([
{ date: '2024-01-02', type: 'other' },
{ date: '2024-01-03', type: 'cp' },
{ date: '2024-01-04', type: 'rtt' },
{ date: '2024-01-05', type: 'cp' },
]);
});
it('relocates same-balance tokens with different mandatory provenance', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [
{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-01-04' },
{ id: 'scarce', type: 'other', quantity: 1, expiryDate: '2024-01-02' },
],
rules: [
{ ...personal, id: 'first', force: 'obligation', endDate: '2024-01-03' },
{
...personal,
id: 'second',
force: 'obligation',
startDate: '2024-01-03',
endDate: '2024-01-04',
},
loss,
],
});
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
const trace = await calculation.trace;
expect(
trace?.phases
.flatMap(({ decisions }) => decisions)
.flatMap(({ candidates }) => candidates)
.some(
({ sourceDate, dates }) =>
sourceDate === '2024-01-02' &&
dates?.join(',') === '2024-01-02,2024-01-03,2024-01-04',
),
).toBe(true);
});
it('satisfies a lower IF_THEN pattern by reassigning its complete typed bundle', async () => {
const outcome = await createPlanningCalculation({
horizon: { start: '2027-12-20', end: '2027-12-28' },
balances: [
{ id: 'A-cp', type: 'cp', quantity: 2, expiryDate: '2027-12-27' },
{ id: 'B-rtt', type: 'rtt', quantity: 4, expiryDate: '2027-12-27' },
],
rules: [
loss,
{
id: 'samedi',
name: 'Samedi Malin',
type: 'if_then',
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other',
},
],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(outcome.preferences[0].satisfied).toBe(1);
expect(
outcome.placements.filter(({ type }) => type === 'cp').map(({ date }) => date),
).toEqual(['2027-12-23', '2027-12-24']);
});
it('does not classify a period extension without a calendar benefit as supporting', async () => {
const base: PlanningRequest = {
horizon: { start: '2026-12-28', end: '2027-01-09' },
balances: [
{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2027-01-08' },
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2027-01-05',
expiryDate: '2027-02-01',
},
],
rules: [
{
...personal,
force: 'obligation',
startDate: '2027-01-04',
endDate: '2027-01-04',
placeType: 'rtt',
},
],
};
const before = await createPlanningCalculation(base).outcome;
const after = await createPlanningCalculation({
...base,
rules: [
...base.rules,
{ ...loss, configuration: { ...loss.configuration, conditions: [] } },
],
}).outcome;
if (before.kind !== 'Plan' || after.kind !== 'Plan')
throw new Error('Expected valid plans');
expect(after.placements.find(({ balanceId }) => balanceId === 'cp')).toMatchObject({
date: '2027-01-05',
supporting: false,
});
for (const key of [
'holidayWeekValue',
'nonWorkingDayGain',
'longestWorkSequence',
'shortSequenceDeficit',
] as const)
expect(after.metrics[key]).toBe(before.metrics[key]);
expect(after.metrics.supportingPlacements).toBe(0);
});
it('keeps joint window obligations and active priority when large previews are sampled', async () => {
const obligations = [
['2024-01-02', '2024-01-15'],
['2024-01-16', '2024-01-29'],
['2024-02-01', '2024-02-14'],
].map(([startDate, endDate], index) => ({
id: `mandatory-${index}`,
type: 'time_window' as const,
force: 'obligation' as const,
startDate,
endDate,
placeDays: 2,
placeType: 'any' as const,
}));
const request: PlanningRequest = {
horizon: { start: '2024-01-02', end: '2024-02-16' },
balances: [
{ id: 'cp', type: 'cp', quantity: 5, expiryDate: '2024-02-15' },
{ id: 'scarce', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
{ id: 'deferred', type: 'cp', quantity: 1, expiryDate: '2024-05-01' },
],
rules: [...obligations, loss, personal],
};
const calculation = createPlanningCalculation(request);
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(outcome.preferences[0].satisfied).toBe(0);
for (const rule of obligations)
expect(
outcome.placements.filter(({ ruleIds }) => ruleIds.includes(rule.id)),
).toHaveLength(2);
const trace = await calculation.trace;
expect(
trace?.phases
.flatMap(({ decisions }) => decisions)
.flatMap(({ candidates }) => candidates)
.some(
({ status, reason }) =>
status === 'not-examined' &&
reason?.code === 'window-obligation-preview-sampling',
),
).toBe(true);
const reversed = await createPlanningCalculation({
...request,
rules: [...obligations, personal, loss],
}).outcome;
if (reversed.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(reversed.preferences[0].satisfied).toBe(1);
expect(reversed.metrics.unplacedAtRisk).toBe(1);
}, 20_000);
it('allows a lower typed preference to choose the balance when loss quantities tie', async () => {
const outcome = await createPlanningCalculation({
...concurrent,
balances: concurrent.balances.map((balance) => ({
...balance,
expiryDate: '2024-01-02',
})),
rules: [loss, personal],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.placements[0].type).toBe('cp');
expect(outcome.metrics.unplacedAtRisk).toBe(1);
expect(outcome.preferences[0].satisfied).toBe(1);
});
it('makes a disabled optimization equivalent to its absence, including all-disabled requests', async () => {
for (const otherRules of [[], [personal]]) {
const absent = await createPlanningCalculation({ ...concurrent, rules: otherRules })
.outcome;
const disabled = await createPlanningCalculation({
...concurrent,
rules: [{ ...loss, enabled: false }, ...otherRules],
}).outcome;
expect(disabled).toEqual(absent);
}
});
it.each([
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, aggregation: 'average' },
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: ['complete'] },
null,
])(
'rejects incompatible or malformed active configurations without an obligation conflict: %s',
async (configuration) => {
const outcome = calculatePlanningInternal({
...concurrent,
rules: [{ ...loss, configuration }],
});
expect(outcome.kind).toBe('InvalidInput');
if (outcome.kind !== 'InvalidInput') return;
expect(outcome.reasons[0].ruleIds).toEqual(['loss']);
expect(outcome.reasons[0].code).toMatch(
/^(invalid|unsupported)-optimization-configuration$/,
);
},
);
it('rejects obligation mode for an optimization and ignores a malformed disabled rule', async () => {
const invalid = calculatePlanningInternal({
...concurrent,
rules: [{ ...loss, force: 'obligation' }],
});
expect(invalid).toMatchObject({
kind: 'InvalidInput',
reasons: [expect.objectContaining({ code: 'rule-force', ruleIds: ['loss'] })],
});
const disabled = calculatePlanningInternal({
...concurrent,
rules: [{ ...loss, enabled: false, configuration: null }],
});
expect(disabled).toMatchObject({ kind: 'Plan', placements: [] });
});
it('keeps direct snapshots and every explanation/comparison in the submitted active order', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.unplacedAtRisk,
);
const rule = { ...loss, configuration };
const calculation = createPlanningCalculation({ ...concurrent, rules: [rule, personal] });
configuration.conditions = [];
configuration.aggregation = 'count';
configuration.direction = 'maximize';
rule.name = 'Edited later';
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
expect(outcome.placements[0].balanceId).toBe('risk-rtt');
expect(calculation.snapshot.rules[0]).toMatchObject({
name: 'Prevent losses',
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
expect(Object.isFrozen(calculation.snapshot.rules[0])).toBe(true);
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
const ids = [
'obligation-validity',
'optimization:loss',
'preference:personal',
'balance-expiry',
'placement-date',
];
expect(
explanation.placements[0].objectiveVector.objectives.map((objective) => objective.id),
).toEqual(ids);
expect(explanation.placements[0].objectiveVector.objectives[1].label).toBe(
'Prevent losses',
);
const trace = await calculation.trace;
expect(trace?.phases.map((phase) => phase.name)).toEqual([
'validation',
'obligations',
'optimization:loss',
'preference:personal',
'expiry',
'dates',
]);
const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (comparison.kind !== 'Counterfactual') throw new Error('Expected comparison');
expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual(ids);
});
});
@@ -5,11 +5,12 @@ import { RuleService, RuleType } from './rule-service';
import { UserInputService } from './user-input-service';
import { createPlanningCalculation } from './leave-planner.service';
import { PlanningDurationService } from './planning-duration.service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
describe('Planning duration estimate', () => {
beforeEach(() => {
localStorage.clear();
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
TestBed.inject(UserInputService).updateBalances(() => [
{
@@ -34,6 +35,22 @@ describe('Planning duration estimate', () => {
expect(TestBed.inject(RuleService).rules).toEqual([]);
});
it('estimates and calibrates optimization rules without a requested leave quantity', () => {
const rules = TestBed.inject(RuleService);
rules.addRule({
id: 'loss',
name: 'Loss',
type: RuleType.OPTIMIZATION,
enabled: true,
force: 'preference',
configuration: structuredClone(OPTIMIZATION_PRESETS.unplacedAtRisk),
});
const duration = TestBed.inject(PlanningDurationService);
expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true);
duration.recordCompletion(duration.request()!, 20_000);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
});
it('updates for dates, balances and active rules without requesting a calculation', () => {
const duration = TestBed.inject(PlanningDurationService);
const settings = TestBed.inject(CalendarSettingsService);
@@ -41,7 +41,9 @@ function initialDurationMs(request: PlanningRequest): number {
(dateTime(rule.startDate) >= end || dateTime(rule.endDate) < start)
)
continue;
ruleWeight += (rule.force === 'obligation' ? 0.5 : 0.15) * Math.max(1, rule.placeDays / 3);
ruleWeight +=
(rule.force === 'obligation' ? 0.5 : 0.15) *
Math.max(1, 'placeDays' in rule ? rule.placeDays / 3 : 1);
if ('rewardDays' in rule) placements += (Math.max(0, rule.rewardDays) * days) / 365.25;
}
// Rough prior from the browser references in docs/planning-duration.md, not a deadline.
@@ -0,0 +1,193 @@
import { describe, expect, it } from 'vitest';
import {
compareObjectiveLists,
compareObjectiveValues,
evaluateOptimization,
firstDifferentObjective,
OPTIMIZATION_PRESETS,
optimizationUnit,
validateOptimizationConfiguration,
type OptimizationObservations,
} from './planning-objectives';
const empty: OptimizationObservations = {
leaveBalances: [],
publicHolidayWeeks: [],
timeOffPeriods: [],
workSequences: [],
leavePlacements: [],
};
describe('composable planning objectives', () => {
it('evaluates each internal non-empty sequence with its own working-day threshold', () => {
const observations: OptimizationObservations = {
...empty,
workSequences: [
{ length: 1, internal: false },
{ length: 0, internal: true },
{ length: 1, internal: true },
{ length: 4, internal: true },
{ length: 6, internal: false },
],
};
expect(evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations)).toBe(
5,
);
expect(
evaluateOptimization(
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays: 3 },
observations,
),
).toBe(2);
expect(evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, observations)).toBe(
6,
);
});
it('evaluates all six recipes from the same concrete observations', () => {
const observations: OptimizationObservations = {
leaveBalances: [
{ unplacedQuantity: 3, atRisk: true },
{ unplacedQuantity: 8, atRisk: false },
],
publicHolidayWeeks: [
{ value: 3, complete: true },
{ value: 1, complete: false },
{ value: 4, complete: true },
],
timeOffPeriods: [
{ nonWorkingDates: ['2024-01-06', '2024-01-07'] },
{ nonWorkingDates: ['2024-05-08', '2024-05-09', '2024-05-09'] },
],
workSequences: [
{ length: 1, internal: false },
{ length: 0, internal: true },
{ length: 1, internal: true },
{ length: 4, internal: true },
{ length: 6, internal: false },
],
leavePlacements: [{ supporting: true }, { supporting: false }, { supporting: true }],
};
expect(
Object.values(OPTIMIZATION_PRESETS).map((configuration) =>
evaluateOptimization(configuration, observations),
),
).toEqual([3, 7, 4, 6, 5, 2]);
});
it.each(['sum', 'count', 'maximum'] as const)(
'defines empty %s as zero for every measure',
(aggregation) => {
for (const configuration of Object.values(OPTIMIZATION_PRESETS)) {
expect(evaluateOptimization({ ...configuration, aggregation }, empty)).toBe(0);
}
},
);
it('composes conditions, aggregation and direction independently of a rule name or identity', () => {
const configuration = {
...OPTIMIZATION_PRESETS.longestWorkSequence,
conditions: ['internal', 'non-empty'] as const,
aggregation: 'sum' as const,
direction: 'maximize' as const,
};
const observations: OptimizationObservations = {
...empty,
workSequences: [
{ length: 20, internal: false },
{ length: 0, internal: true },
{ length: 2, internal: true },
{ length: 4, internal: true },
],
};
const rules = [
{ id: 'provided', name: 'Original name', configuration },
{ id: 'personal', name: 'Renamed rule', configuration: structuredClone(configuration) },
];
expect(rules.map((rule) => evaluateOptimization(rule.configuration, observations))).toEqual(
[6, 6],
);
expect(evaluateOptimization({ ...configuration, aggregation: 'count' }, observations)).toBe(
2,
);
expect(
evaluateOptimization({ ...configuration, aggregation: 'maximum' }, observations),
).toBe(4);
expect(optimizationUnit(configuration)).toBe('working-days');
expect(optimizationUnit({ ...configuration, aggregation: 'count' })).toBe('count');
expect(optimizationUnit(OPTIMIZATION_PRESETS.nonWorkingDayGain)).toBe('calendar-days');
});
it.each([
0,
-1,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
undefined,
'3',
Number.MAX_SAFE_INTEGER + 1,
])('rejects the invalid working-day deficit threshold %s', (thresholdDays) => {
expect(
validateOptimizationConfiguration({
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
thresholdDays,
}),
).toMatchObject({ valid: false, code: 'invalid-optimization-configuration' });
});
it.each([
{ ...OPTIMIZATION_PRESETS.nonWorkingDayGain, elements: 'work-sequences' },
{ ...OPTIMIZATION_PRESETS.nonWorkingDayGain, conditions: ['at-risk'] },
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, aggregation: 'average' },
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, thresholdDays: 3 },
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdUnit: 'calendar-days' },
])('identifies an unsupported composition', (configuration) => {
expect(validateOptimizationConfiguration(configuration)).toMatchObject({
valid: false,
code: 'unsupported-optimization-configuration',
});
});
it.each([
null,
[],
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, conditions: ['internal', 'internal'] },
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, direction: 'up' },
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: new Array(1) },
])('rejects malformed configurations', (configuration) => {
expect(validateOptimizationConfiguration(configuration)).toMatchObject({
valid: false,
code: 'invalid-optimization-configuration',
});
});
it('never evaluates an unsupported configuration as a preset', () => {
expect(() =>
evaluateOptimization(
{ ...OPTIMIZATION_PRESETS.nonWorkingDayGain, elements: 'work-sequences' },
empty,
),
).toThrow('unsupported-optimization-configuration');
});
it('compares ordered objectives lexicographically with the configured directions', () => {
const before = [
{ id: 'first', direction: 'minimize' as const, value: 3 },
{ id: 'second', direction: 'maximize' as const, value: 100 },
{ id: 'date', direction: 'minimize' as const, value: '2024-01-10' },
];
const earlierPriority = [{ ...before[0], value: 2 }, { ...before[1], value: 0 }, before[2]];
expect(compareObjectiveLists(earlierPriority, before)).toBeLessThan(0);
expect(firstDifferentObjective(earlierPriority, before)).toBe(0);
const laterPriority = [before[0], { ...before[1], value: 101 }, before[2]];
expect(compareObjectiveLists(laterPriority, before)).toBeLessThan(0);
expect(firstDifferentObjective(laterPriority, before)).toBe(1);
const earlierDate = [before[0], before[1], { ...before[2], value: '2024-01-09' }];
expect(compareObjectiveLists(earlierDate, before)).toBeLessThan(0);
expect(firstDifferentObjective(earlierDate, before)).toBe(2);
expect(compareObjectiveLists(before, structuredClone(before))).toBe(0);
expect(firstDifferentObjective(before, structuredClone(before))).toBe(-1);
expect(compareObjectiveValues('maximize', 0, 0)).toBe(0);
});
});
+300
View File
@@ -0,0 +1,300 @@
export type OptimizationElements =
| 'leave-balances'
| 'public-holiday-weeks'
| 'time-off-periods'
| 'work-sequences'
| 'leave-placements';
export type OptimizationCondition =
| 'at-risk'
| 'complete'
| 'internal'
| 'non-empty'
| 'supporting';
export type OptimizationMeasure =
| 'unplaced-quantity'
| 'public-holiday-week-value'
| 'non-working-day-gain'
| 'work-sequence-length'
| 'work-sequence-deficit'
| 'placement-count';
export type OptimizationAggregation = 'sum' | 'count' | 'maximum';
export type OptimizationDirection = 'minimize' | 'maximize';
export type OptimizationUnit = 'working-days' | 'calendar-days' | 'count';
interface OptimizationConfigurationBase {
elements: OptimizationElements;
conditions: readonly OptimizationCondition[];
aggregation: OptimizationAggregation;
direction: OptimizationDirection;
}
export type OptimizationConfiguration = OptimizationConfigurationBase &
(
| { measure: 'work-sequence-deficit'; thresholdDays: number }
| { measure: Exclude<OptimizationMeasure, 'work-sequence-deficit'>; thresholdDays?: never }
);
/** Each row accepts any subset of its conditions and both comparison directions. */
export const OPTIMIZATION_CATALOG = [
{
elements: 'leave-balances',
measure: 'unplaced-quantity',
conditions: ['at-risk'],
aggregations: ['sum', 'count', 'maximum'],
unit: 'working-days',
},
{
elements: 'public-holiday-weeks',
measure: 'public-holiday-week-value',
conditions: ['complete'],
aggregations: ['sum', 'count', 'maximum'],
unit: 'calendar-days',
},
{
elements: 'time-off-periods',
measure: 'non-working-day-gain',
conditions: [],
aggregations: ['sum', 'count', 'maximum'],
unit: 'calendar-days',
},
{
elements: 'work-sequences',
measure: 'work-sequence-length',
conditions: ['internal', 'non-empty'],
aggregations: ['sum', 'count', 'maximum'],
unit: 'working-days',
},
{
elements: 'work-sequences',
measure: 'work-sequence-deficit',
conditions: ['internal', 'non-empty'],
aggregations: ['sum', 'count', 'maximum'],
unit: 'working-days',
},
{
elements: 'leave-placements',
measure: 'placement-count',
conditions: ['supporting'],
aggregations: ['sum', 'count', 'maximum'],
unit: 'count',
},
] as const;
export type OptimizationValidation =
| { valid: true; configuration: OptimizationConfiguration }
| {
valid: false;
code: 'invalid-optimization-configuration' | 'unsupported-optimization-configuration';
message: string;
};
export function validateOptimizationConfiguration(value: unknown): OptimizationValidation {
const invalid = (message: string): OptimizationValidation => ({
valid: false,
code: 'invalid-optimization-configuration',
message,
});
const unsupported = (message: string): OptimizationValidation => ({
valid: false,
code: 'unsupported-optimization-configuration',
message,
});
if (!value || typeof value !== 'object' || Array.isArray(value))
return invalid('An optimization configuration must be an object.');
const config = value as Record<string, unknown>;
const fields = [
'elements',
'conditions',
'measure',
'aggregation',
'direction',
'thresholdDays',
];
if (Object.keys(config).some((key) => !fields.includes(key)))
return unsupported('The optimization configuration contains an unsupported field.');
if (config['direction'] !== 'minimize' && config['direction'] !== 'maximize')
return invalid('The optimization direction must be minimize or maximize.');
if (
!Array.isArray(config['conditions']) ||
[...config['conditions']].some((condition) => typeof condition !== 'string') ||
new Set(config['conditions']).size !== config['conditions'].length
)
return invalid('Optimization conditions must be a list of distinct conditions.');
const row = OPTIMIZATION_CATALOG.find(
(item) => item.elements === config['elements'] && item.measure === config['measure'],
);
if (!row) return unsupported('The planning elements and measure are not compatible.');
const conditions: readonly string[] = row.conditions;
if (config['conditions'].some((condition) => !conditions.includes(condition)))
return unsupported('A condition is not supported for these planning elements.');
const aggregations: readonly string[] = row.aggregations;
if (typeof config['aggregation'] !== 'string' || !aggregations.includes(config['aggregation']))
return unsupported('The aggregation is not supported for this measure.');
if (config['measure'] === 'work-sequence-deficit') {
if (!Number.isSafeInteger(config['thresholdDays']) || Number(config['thresholdDays']) <= 0)
return invalid('The deficit threshold must be a positive integer in working days.');
} else if ('thresholdDays' in config)
return unsupported('Only the work-sequence deficit accepts a working-day threshold.');
return { valid: true, configuration: value as OptimizationConfiguration };
}
export function optimizationUnit(configuration: OptimizationConfiguration): OptimizationUnit {
return configuration.aggregation === 'count'
? 'count'
: OPTIMIZATION_CATALOG.find(
(row) =>
row.elements === configuration.elements &&
row.measure === configuration.measure,
)!.unit;
}
export const OPTIMIZATION_PRESETS = {
unplacedAtRisk: {
elements: 'leave-balances',
conditions: ['at-risk'],
measure: 'unplaced-quantity',
aggregation: 'sum',
direction: 'minimize',
},
holidayWeekValue: {
elements: 'public-holiday-weeks',
conditions: ['complete'],
measure: 'public-holiday-week-value',
aggregation: 'sum',
direction: 'maximize',
},
nonWorkingDayGain: {
elements: 'time-off-periods',
conditions: [],
measure: 'non-working-day-gain',
aggregation: 'sum',
direction: 'maximize',
},
longestWorkSequence: {
elements: 'work-sequences',
conditions: [],
measure: 'work-sequence-length',
aggregation: 'maximum',
direction: 'minimize',
},
shortSequenceDeficit: {
elements: 'work-sequences',
conditions: ['internal', 'non-empty'],
measure: 'work-sequence-deficit',
thresholdDays: 5,
aggregation: 'sum',
direction: 'minimize',
},
supportingPlacements: {
elements: 'leave-placements',
conditions: ['supporting'],
measure: 'placement-count',
aggregation: 'count',
direction: 'minimize',
},
} as const satisfies Record<string, OptimizationConfiguration>;
/** Concrete observations from one plan; they carry no rule identity or display name. */
export interface OptimizationObservations {
leaveBalances: readonly { unplacedQuantity: number; atRisk: boolean }[];
publicHolidayWeeks: readonly { value: number; complete: boolean }[];
timeOffPeriods: readonly { nonWorkingDates: readonly string[] }[];
workSequences: readonly { length: number; internal: boolean }[];
leavePlacements: readonly { supporting: boolean }[];
}
export function evaluateOptimization(
configuration: OptimizationConfiguration,
observations: OptimizationObservations,
): number {
const validation = validateOptimizationConfiguration(configuration);
if (!validation.valid) throw new Error(`${validation.code}: ${validation.message}`);
const selected = (condition: OptimizationCondition) =>
configuration.conditions.includes(condition);
let values: number[];
switch (configuration.elements) {
case 'leave-balances':
values = observations.leaveBalances
.filter((balance) => !selected('at-risk') || balance.atRisk)
.map((balance) => balance.unplacedQuantity);
break;
case 'public-holiday-weeks':
values = observations.publicHolidayWeeks
.filter((week) => !selected('complete') || week.complete)
.map((week) => week.value);
break;
case 'time-off-periods':
values = observations.timeOffPeriods.map(
(period) => new Set(period.nonWorkingDates).size,
);
break;
case 'work-sequences':
values = observations.workSequences
.filter(
(sequence) =>
(!selected('internal') || sequence.internal) &&
(!selected('non-empty') || sequence.length > 0),
)
.map((sequence) =>
configuration.measure === 'work-sequence-deficit'
? sequence.length > 0
? Math.max(0, configuration.thresholdDays - sequence.length)
: 0
: sequence.length,
);
break;
case 'leave-placements':
values = observations.leavePlacements
.filter((placement) => !selected('supporting') || placement.supporting)
.map(() => 1);
}
switch (configuration.aggregation) {
case 'count':
return values.length;
case 'sum':
return values.reduce((total, value) => total + value, 0);
case 'maximum':
return values.reduce((maximum, value) => Math.max(maximum, value), 0);
}
}
export interface ComparableObjective {
id: string;
direction: OptimizationDirection;
value: number | string;
}
/** Negative means the left candidate is preferred. Strings retain lexical tie-break ordering. */
export function compareObjectiveValues(
direction: OptimizationDirection,
left: number | string,
right: number | string,
): number {
if (left === right) return 0;
const difference =
typeof left === 'number' && typeof right === 'number'
? left - right
: left < right
? -1
: left > right
? 1
: 0;
return direction === 'minimize' ? difference : -difference;
}
export function firstDifferentObjective(
left: readonly ComparableObjective[],
right: readonly ComparableObjective[],
): number {
return left.findIndex((objective, index) => objective.value !== right[index]?.value);
}
export function compareObjectiveLists(
left: readonly ComparableObjective[],
right: readonly ComparableObjective[],
): number {
const index = firstDifferentObjective(left, right);
return index < 0
? 0
: compareObjectiveValues(left[index].direction, left[index].value, right[index].value);
}
+288
View File
@@ -0,0 +1,288 @@
import { TestBed } from '@angular/core/testing';
import { RuleService, RuleType } from './rule-service';
import { vi } from 'vitest';
import { CalendarSettingsService } from './calendar-settings-service';
import { PlanningDurationService } from './planning-duration.service';
import { UserInputService } from './user-input-service';
import { SelectableDayType } from './calendar-service';
import { createPlanningCalculation } from './leave-planner.service';
describe('saved rule migration', () => {
beforeEach(() => localStorage.clear());
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
TestBed.resetTestingModule();
localStorage.clear();
});
it('restores every legacy record unchanged between loss prevention and the other five presets', () => {
const legacy = [
{
id: 'personal-window',
name: 'My fixed window',
type: RuleType.TIME_WINDOW,
enabled: true,
force: 'obligation',
startDate: '2024-01-10',
endDate: '2024-01-12',
placeDays: 2,
placeType: 'cp',
},
{
id: 'personal-pattern',
name: 'My disabled pattern',
type: RuleType.IF_THEN,
enabled: false,
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'around',
rewardDays: 1,
rewardType: 'other',
},
];
localStorage.setItem('vacationRules', JSON.stringify(legacy));
const service = TestBed.inject(RuleService);
expect(service.rules).toHaveLength(8);
expect(service.rules.slice(1, 3).map(({ id }) => id)).toEqual([
'personal-window',
'personal-pattern',
]);
const saved = JSON.parse(localStorage.getItem('vacationRules')!);
expect(saved.version).toBe(1);
expect(saved.rules.slice(1, 3)).toEqual(legacy);
expect(service.rules[2].force).toBe('preference');
expect(saved.rules[2]).not.toHaveProperty('force');
});
it('gives fresh users the six active presets around the two existing initial rules', () => {
const service = TestBed.inject(RuleService);
expect(service.rules.map(({ id }) => id)).toEqual([
'default-unplaced-at-risk',
'default-samedi-malin',
'default-christmas',
'default-holiday-week-value',
'default-non-working-day-gain',
'default-longest-work-sequence',
'default-short-sequence-deficit',
'default-supporting-placements',
]);
const presets = service.rules.filter(({ type }) => type === RuleType.OPTIMIZATION);
expect(presets).toHaveLength(6);
expect(presets.every(({ enabled, force }) => enabled && force === 'preference')).toBe(true);
});
it('migrates an old empty list once and retains editing, order, disabled and deleted choices immediately', () => {
localStorage.setItem('vacationRules', '[]');
let service = TestBed.inject(RuleService);
expect(service.rules).toHaveLength(6);
const deficit = service.rules[4];
if (
deficit.type !== RuleType.OPTIMIZATION ||
deficit.configuration.measure !== 'work-sequence-deficit'
)
throw new Error('Expected the deficit preset');
service.updateRule({
...deficit,
name: 'My three-day deficit',
configuration: { ...deficit.configuration, thresholdDays: 3 },
});
service.reorderRules(4, 0);
service.toggleRule(deficit.id);
service.deleteRule('default-holiday-week-value');
const expected = service.rules;
TestBed.resetTestingModule();
service = TestBed.inject(RuleService);
expect(service.rules).toEqual(expected);
expect(service.rules[0]).toMatchObject({
name: 'My three-day deficit',
enabled: false,
configuration: { thresholdDays: 3 },
});
for (const rule of service.rules) service.deleteRule(rule.id);
TestBed.resetTestingModule();
expect(TestBed.inject(RuleService).rules).toEqual([]);
expect(JSON.parse(localStorage.getItem('vacationRules')!)).toEqual({
version: 1,
rules: [],
});
});
it('avoids colliding with existing custom identities without replacing their records', () => {
const custom = ['default-unplaced-at-risk', 'default-unplaced-at-risk-2'].map((id) => ({
id,
name: id,
type: RuleType.IF_THEN,
enabled: false,
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other',
}));
localStorage.setItem('vacationRules', JSON.stringify(custom));
const rules = TestBed.inject(RuleService).rules;
expect(rules[0].id).toBe('default-unplaced-at-risk-3');
expect(new Set(rules.map(({ id }) => id)).size).toBe(8);
expect(JSON.parse(localStorage.getItem('vacationRules')!).rules.slice(1, 3)).toEqual(
custom,
);
});
it('saves the migrated list and version in one atomic write and retries after a failed save', () => {
localStorage.setItem('vacationRules', '[]');
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota');
});
let service = TestBed.inject(RuleService);
expect(service.rules).toHaveLength(6);
expect(save).toHaveBeenCalledOnce();
expect(localStorage.getItem('vacationRules')).toBe('[]');
service.toggleRule(service.rules[0].id);
expect(localStorage.getItem('vacationRules')).toBe('[]');
save.mockRestore();
TestBed.resetTestingModule();
service = TestBed.inject(RuleService);
expect(service.rules).toHaveLength(6);
expect(service.rules[0].enabled).toBe(true);
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
});
it('keeps edits usable and saves the entire current list once storage recovers', () => {
localStorage.setItem('vacationRules', '[]');
const service = TestBed.inject(RuleService);
const prior = localStorage.getItem('vacationRules');
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota');
});
service.toggleRule(service.rules[0].id);
expect(service.rules[0].enabled).toBe(false);
expect(localStorage.getItem('vacationRules')).toBe(prior);
save.mockRestore();
service.deleteRule(service.rules[1].id);
const expected = service.rules;
TestBed.resetTestingModule();
expect(TestBed.inject(RuleService).rules).toEqual(expected);
});
it.each(['{"version":2,"rules":[]}', '{broken', 'null', '[null]'])(
'preserves unsupported saved data through subsequent edits: %s',
(saved) => {
localStorage.setItem('vacationRules', saved);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const save = vi.spyOn(Storage.prototype, 'setItem');
const service = TestBed.inject(RuleService);
service.toggleRule(service.rules[0].id);
expect(save).not.toHaveBeenCalled();
expect(localStorage.getItem('vacationRules')).toBe(saved);
},
);
it('prevents edits from overwriting unknown valid saved data after a read failure', () => {
const saved = '{"version":1,"rules":[]}';
localStorage.setItem('vacationRules', saved);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const read = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('denied');
});
const service = TestBed.inject(RuleService);
read.mockRestore();
const write = vi.spyOn(Storage.prototype, 'setItem');
service.toggleRule(service.rules[0].id);
expect(write).not.toHaveBeenCalled();
expect(localStorage.getItem('vacationRules')).toBe(saved);
});
it('works in memory when browser storage is unavailable', () => {
vi.stubGlobal('localStorage', undefined);
const service = TestBed.inject(RuleService);
expect(service.rules).toHaveLength(8);
service.toggleRule(service.rules[0].id);
expect(service.rules[0].enabled).toBe(false);
});
it.each(['disabled', 'deleted'])(
'keeps a migrated %s list inactive after reload while preserving declarations and at-risk quantities',
async (choice) => {
localStorage.setItem('vacationRules', '[]');
const service = TestBed.inject(RuleService);
for (const rule of service.rules) {
if (choice === 'disabled') service.toggleRule(rule.id);
else service.deleteRule(rule.id);
}
const saved = localStorage.getItem('vacationRules');
TestBed.resetTestingModule();
const restored = TestBed.inject(RuleService).rules;
expect(restored).toHaveLength(choice === 'disabled' ? 6 : 0);
expect(restored.every(({ enabled }) => !enabled)).toBe(true);
expect(localStorage.getItem('vacationRules')).toBe(saved);
vi.stubGlobal('window', undefined);
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-01-04' }],
declaredDays: [{ date: '2024-01-03', type: 'other' }],
rules: restored,
});
const outcome = await calculation.outcome;
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [],
declaredDays: [{ date: '2024-01-03', type: 'other' }],
remainingBalances: [
{
id: 'cp',
quantity: 2,
classification: 'at-risk',
reason: { code: 'active-priorities' },
},
],
});
expect((await calculation.trace)?.phases.map(({ name }) => name)).toEqual([
'validation',
'obligations',
'expiry',
'dates',
]);
expect(await calculation.explanation).toMatchObject({
kind: 'PlanExplanation',
placements: [],
});
},
);
it('migrates before the first duration request and actual calculation', async () => {
localStorage.setItem('vacationRules', '[]');
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-02', '2024-01-05');
TestBed.inject(UserInputService).updateBalances(() => [
{
id: 'cp',
type: SelectableDayType.CP,
numberOfDays: 1,
availableDate: new Date('2024-01-02'),
expiryDate: new Date('2024-01-05'),
},
]);
const request = TestBed.inject(PlanningDurationService).request()!;
expect(request.rules).toHaveLength(6);
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
vi.stubGlobal('window', undefined);
const calculation = createPlanningCalculation(request);
const outcome = await calculation.outcome;
expect(outcome).toMatchObject({
kind: 'Plan',
metrics: { unplacedAtRisk: 0 },
placements: [{ balanceId: 'cp' }],
});
expect((await calculation.trace)?.phases.map(({ name }) => name)).toEqual([
'validation',
'obligations',
...request.rules.map(({ id }) => `optimization:${id}`),
'expiry',
'dates',
]);
});
});
+60 -10
View File
@@ -1,6 +1,14 @@
import { TestBed } from '@angular/core/testing';
import { RuleService, RuleType, IfThenRule, TimeWindowRule } from './rule-service';
import {
RuleService,
RuleType,
IfThenRule,
TimeWindowRule,
OptimizationRule,
} from './rule-service';
import { SelectableDayType } from './calendar-service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
import { vi } from 'vitest';
describe('RuleService', () => {
let service: RuleService;
@@ -18,9 +26,10 @@ describe('RuleService', () => {
it('should initialize with default rules', () => {
const rules = service.rules;
expect(rules.length).toBe(2);
expect(rules[0].id).toBe('default-samedi-malin');
expect(rules[1].id).toBe('default-christmas');
expect(rules.length).toBe(8);
expect(rules[0].id).toBe('default-unplaced-at-risk');
expect(rules[1].id).toBe('default-samedi-malin');
expect(rules[2].id).toBe('default-christmas');
});
it('should parse dates correctly for TimeWindowRules', () => {
@@ -62,9 +71,12 @@ describe('RuleService', () => {
TestBed.configureTestingModule({});
service = TestBed.inject(RuleService);
expect(service.rules.map((rule) => rule.id)).toEqual(['legacy-first', 'legacy-second']);
expect(service.rules[0].force).toBe('preference');
const first = service.rules[0] as TimeWindowRule;
expect(service.rules.slice(1, 3).map((rule) => rule.id)).toEqual([
'legacy-first',
'legacy-second',
]);
expect(service.rules[1].force).toBe('preference');
const first = service.rules[1] as TimeWindowRule;
expect(first.startDate.toISOString().slice(0, 10)).toBe('2024-02-01');
});
@@ -84,13 +96,13 @@ describe('RuleService', () => {
service.addRule(newRule);
const rules = service.rules;
expect(rules.length).toBe(3);
expect(rules.length).toBe(9);
expect(rules.find((r) => r.id === 'test-rule')).toBeDefined();
});
it('should update an existing rule', () => {
const rules = service.rules;
const targetRule = { ...rules[0] } as IfThenRule;
const targetRule = { ...rules.find((rule) => rule.type === RuleType.IF_THEN)! };
targetRule.name = 'Updated Name';
service.updateRule(targetRule);
@@ -107,7 +119,7 @@ describe('RuleService', () => {
service.deleteRule(idToDelete);
const updatedRules = service.rules;
expect(updatedRules.length).toBe(1);
expect(updatedRules.length).toBe(7);
expect(updatedRules.find((r) => r.id === idToDelete)).toBeUndefined();
});
@@ -132,4 +144,42 @@ describe('RuleService', () => {
expect(rulesAfter[0].id).toBe(secondRuleId);
expect(rulesAfter[1].id).toBe(firstRuleId);
});
it('restores the full optimization identity, parameters, enabled state and position', () => {
vi.useFakeTimers();
try {
TestBed.tick();
const optimization: OptimizationRule = {
id: 'personal-loss',
name: 'My loss priority',
type: RuleType.OPTIMIZATION,
enabled: true,
force: 'preference',
configuration: structuredClone(OPTIMIZATION_PRESETS.unplacedAtRisk),
};
service.addRule(optimization);
optimization.configuration.conditions = [];
service.reorderRules(
service.rules.findIndex(({ id }) => id === optimization.id),
0,
);
service.toggleRule('personal-loss');
const saved = service.rules;
TestBed.tick();
vi.advanceTimersByTime(350);
TestBed.resetTestingModule();
service = TestBed.inject(RuleService);
expect(service.rules).toEqual(saved);
expect(service.rules[0]).toMatchObject({
id: 'personal-loss',
name: 'My loss priority',
enabled: false,
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
service.deleteRule('personal-loss');
expect(service.rules.some((rule) => rule.id === 'personal-loss')).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
+145 -44
View File
@@ -1,10 +1,11 @@
import { Injectable, WritableSignal, Injector, inject } from '@angular/core';
import { Injectable, WritableSignal, signal } from '@angular/core';
import { SelectableDayType } from './calendar-service';
import { storedSignal } from '../shared/utils/stored-signal';
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
export enum RuleType {
IF_THEN = 'if_then',
TIME_WINDOW = 'time_window',
OPTIMIZATION = 'optimization',
}
export type RuleForce = 'obligation' | 'preference';
@@ -14,7 +15,7 @@ export interface BaseRule {
name: string;
type: RuleType;
enabled: boolean;
/** Missing force is a legacy preference and is migrated on read. */
/** Missing force is read as a legacy preference without rewriting the saved entry. */
force?: RuleForce;
}
@@ -38,29 +39,88 @@ export interface TimeWindowRule extends BaseRule {
placeType: SelectableDayType;
}
export type VacationRule = IfThenRule | TimeWindowRule;
export interface OptimizationRule extends BaseRule {
type: RuleType.OPTIMIZATION;
force: 'preference';
configuration: OptimizationConfiguration;
}
interface SerializedRule {
id: string;
name: string;
type: RuleType;
enabled: boolean;
force?: RuleForce;
dayOfWeek?: number;
placeDays: number;
placeType: SelectableDayType;
placement?: PlacementStrategy;
rewardDays?: number;
rewardType?: SelectableDayType;
startDate?: string | Date;
endDate?: string | Date;
export type VacationRule = IfThenRule | TimeWindowRule | OptimizationRule;
type SerializedRule =
| IfThenRule
| (Omit<TimeWindowRule, 'startDate' | 'endDate'> & {
startDate: string | Date;
endDate: string | Date;
})
| (Omit<OptimizationRule, 'force'> & { force?: 'preference' });
export function createOptimizationPresetRules(
existingIds: readonly string[] = [],
): OptimizationRule[] {
const ids = new Set(existingIds);
return (
[
['unplaced-at-risk', 'Prévention des pertes', OPTIMIZATION_PRESETS.unplacedAtRisk],
['holiday-week-value', 'Semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue],
[
'non-working-day-gain',
'Gain de jours non ouvrés',
OPTIMIZATION_PRESETS.nonWorkingDayGain,
],
[
'longest-work-sequence',
'Séquence de travail maximale',
OPTIMIZATION_PRESETS.longestWorkSequence,
],
[
'short-sequence-deficit',
'Déficit des reprises',
OPTIMIZATION_PRESETS.shortSequenceDeficit,
],
[
'supporting-placements',
'Économie des jours d’appoint',
OPTIMIZATION_PRESETS.supportingPlacements,
],
] as const
).map<OptimizationRule>(([key, name, configuration]) => {
const base = `default-${key}`;
let id = base;
let suffix = 2;
while (ids.has(id)) id = `${base}-${suffix++}`;
ids.add(id);
return {
id,
name,
type: RuleType.OPTIMIZATION,
enabled: true,
force: 'preference',
configuration: structuredClone(configuration),
};
});
}
function isRuleList(value: unknown): value is SerializedRule[] {
return (
Array.isArray(value) &&
value.every(
(rule) =>
rule &&
typeof rule === 'object' &&
typeof rule.id === 'string' &&
typeof rule.name === 'string' &&
typeof rule.enabled === 'boolean' &&
Object.values(RuleType).includes(rule.type),
)
);
}
@Injectable({
providedIn: 'root',
})
export class RuleService {
private readonly injector = inject(Injector);
private canPersist = true;
private readonly defaultRules: VacationRule[] = [
{
@@ -92,11 +152,56 @@ export class RuleService {
private rawRulesSignal: WritableSignal<SerializedRule[]>;
constructor() {
this.rawRulesSignal = storedSignal<SerializedRule[]>(
'vacationRules',
this.defaultRules as unknown as SerializedRule[],
this.injector,
);
let rules = this.initialRules(this.defaultRules);
let migrate = true;
try {
const stored =
typeof localStorage === 'undefined' ? null : localStorage.getItem('vacationRules');
if (stored !== null) {
const parsed: unknown = JSON.parse(stored);
if (isRuleList(parsed)) rules = this.initialRules(parsed);
else if (
parsed &&
typeof parsed === 'object' &&
'version' in parsed &&
parsed.version === 1 &&
'rules' in parsed &&
isRuleList(parsed.rules)
) {
rules = parsed.rules;
migrate = false;
} else throw new Error('Unsupported saved rule format.');
}
} catch (error) {
// A failed read must not allow a later edit to replace unknown saved rules.
this.canPersist = false;
console.warn('Failed to restore planning rules from localStorage.', error);
}
this.rawRulesSignal = signal(rules);
if (migrate) this.saveRules();
}
private initialRules(existing: SerializedRule[]): SerializedRule[] {
const presets = createOptimizationPresetRules(existing.map(({ id }) => id));
return [presets[0], ...existing, ...presets.slice(1)];
}
private saveRules(): void {
if (!this.canPersist) return;
try {
if (typeof localStorage !== 'undefined')
localStorage.setItem(
'vacationRules',
JSON.stringify({ version: 1, rules: this.rawRulesSignal() }),
);
} catch (error) {
console.warn('Failed to save planning rules to localStorage.', error);
}
}
private updateRules(update: (rules: SerializedRule[]) => SerializedRule[]): void {
this.rawRulesSignal.update(update);
this.saveRules();
}
public get rules(): VacationRule[] {
@@ -106,46 +211,42 @@ export class RuleService {
return {
...rule,
force: rule.force ?? 'preference',
startDate: new Date(rule.startDate as string),
endDate: new Date(rule.endDate as string),
} as TimeWindowRule;
startDate: new Date(rule.startDate),
endDate: new Date(rule.endDate),
};
}
return { ...rule, force: rule.force ?? 'preference' } as IfThenRule;
if (rule.type === RuleType.OPTIMIZATION)
return structuredClone({ ...rule, force: rule.force ?? ('preference' as const) });
return structuredClone({ ...rule, force: rule.force ?? 'preference' });
});
}
public addRule(rule: VacationRule): void {
this.rawRulesSignal.update((rules) => [
...rules,
{ ...rule, force: rule.force ?? 'preference' } as unknown as SerializedRule,
]);
const saved = structuredClone(rule);
saved.force ??= 'preference';
this.updateRules((rules) => [...rules, saved]);
}
public updateRule(updatedRule: VacationRule): void {
this.rawRulesSignal.update((rules) =>
rules.map((rule) =>
rule.id === updatedRule.id
? ({
...updatedRule,
force: updatedRule.force ?? 'preference',
} as unknown as SerializedRule)
: rule,
),
const saved = structuredClone(updatedRule);
saved.force ??= 'preference';
this.updateRules((rules) =>
rules.map((rule) => (rule.id === updatedRule.id ? saved : rule)),
);
}
public deleteRule(id: string): void {
this.rawRulesSignal.update((rules) => rules.filter((rule) => rule.id !== id));
this.updateRules((rules) => rules.filter((rule) => rule.id !== id));
}
public toggleRule(id: string): void {
this.rawRulesSignal.update((rules) =>
this.updateRules((rules) =>
rules.map((rule) => (rule.id === id ? { ...rule, enabled: !rule.enabled } : rule)),
);
}
public reorderRules(previousIndex: number, currentIndex: number): void {
this.rawRulesSignal.update((rules) => {
this.updateRules((rules) => {
const updatedRules = [...rules];
const [movedRule] = updatedRules.splice(previousIndex, 1);
updatedRules.splice(currentIndex, 0, movedRule);
+54 -1
View File
@@ -3,6 +3,16 @@ import { Injectable, inject, Injector, computed } from '@angular/core';
import { storedSignal } from '../shared/utils/stored-signal';
import type { PlanOutcome, RemainingBalance } from './leave-planner.service';
import { addPlanningDays } from './planning-date';
import {
optimizationUnit,
validateOptimizationConfiguration,
type OptimizationConfiguration,
type OptimizationElements,
type OptimizationCondition,
type OptimizationMeasure,
type OptimizationAggregation,
type OptimizationDirection,
} from './planning-objectives';
export interface AppTranslations {
locale: string;
@@ -77,6 +87,25 @@ export interface AppTranslations {
invalidHorizon: string;
};
rules: {
optimization: {
newRule: string;
defaultName: string;
elementsLabel: string;
conditionsLabel: string;
measureLabel: string;
aggregationLabel: string;
directionLabel: string;
thresholdLabel: string;
thresholdSummary: string;
summary: string;
invalidConfiguration: string;
nameRequired: string;
elements: Record<OptimizationElements, string>;
conditions: Record<OptimizationCondition, string>;
measures: Record<OptimizationMeasure, string>;
aggregations: Record<OptimizationAggregation, string>;
directions: Record<OptimizationDirection, string>;
};
title: string;
subtitle: string;
active: string;
@@ -390,6 +419,29 @@ export class TranslationService {
}
}
public optimizationSummary(configuration: OptimizationConfiguration): string {
const tr = this.tr().rules.optimization;
if (!validateOptimizationConfiguration(configuration).valid) return tr.invalidConfiguration;
const unit = optimizationUnit(configuration);
const unitLabel =
unit === 'count'
? tr.aggregations.count
: this.tr().planning.statistics[
unit === 'working-days' ? 'workingDays' : 'calendarDays'
];
const conditions = configuration.conditions.map((condition) => tr.conditions[condition]);
if (configuration.measure === 'work-sequence-deficit')
conditions.push(
tr.thresholdSummary.replace('{count}', String(configuration.thresholdDays)),
);
return tr.summary
.replace('{direction}', tr.directions[configuration.direction])
.replace('{aggregation}', tr.aggregations[configuration.aggregation])
.replace('{measure}', `${tr.measures[configuration.measure]} (${unitLabel})`)
.replace('{elements}', tr.elements[configuration.elements])
.replace('{conditions}', conditions.length ? ` (${conditions.join(', ')})` : '');
}
public getLanguageName(langCode: string): string {
try {
// Using native Intl.DisplayNames to get name of language in its own language
@@ -421,7 +473,8 @@ export class TranslationService {
const reasons = this.tr().planning.details.remainingReasons;
if (!plan) return reasons['not-established'];
const code =
incomplete && ['preserved', 'not-established'].includes(balance.reason.code)
incomplete &&
['preserved', 'active-priorities', 'not-established'].includes(balance.reason.code)
? 'calculation-incomplete'
: balance.reason.code;
return (reasons[code] ?? reasons['not-established'])
+54 -2
View File
@@ -133,6 +133,51 @@
"after": "danach",
"around": "darum herum",
"auto": "automatisch"
},
"optimization": {
"newRule": "Neue Optimierungsregel",
"defaultName": "Verluste vermeiden",
"elementsLabel": "Planungselemente",
"conditionsLabel": "Bedingungen (alle müssen erfüllt sein)",
"measureLabel": "Messgröße",
"aggregationLabel": "Aggregation",
"directionLabel": "Richtung",
"thresholdLabel": "Präferenzschwelle in Arbeitstagen",
"thresholdSummary": "Präferenzschwelle: {count} Arbeitstage",
"summary": "{direction} {aggregation}: {measure} — {elements}{conditions}.",
"invalidConfiguration": "Wählen Sie passende Elemente, Bedingungen, Messgrößen und Aggregationen sowie bei Bedarf eine positive ganzzahlige Tagesschwelle.",
"nameRequired": "Geben Sie einen Regelnamen ein.",
"elements": {
"leave-balances": "Urlaubsguthaben",
"public-holiday-weeks": "Feiertagswochen",
"time-off-periods": "Arbeitsfreie Zeiträume",
"work-sequences": "Arbeitsfolgen",
"leave-placements": "Geplante Urlaubstage"
},
"conditions": {
"at-risk": "Vom Verfall bedroht",
"complete": "Vollständig",
"internal": "Intern",
"non-empty": "Nicht leer",
"supporting": "Unterstützend"
},
"measures": {
"unplaced-quantity": "Nicht eingeplante Menge",
"public-holiday-week-value": "Wert der Feiertagswochen",
"non-working-day-gain": "Gewinn arbeitsfreier Tage",
"work-sequence-length": "Länge der Arbeitsfolgen",
"work-sequence-deficit": "Defizit der Arbeitsfolgen",
"placement-count": "Anzahl der Platzierungen"
},
"aggregations": {
"sum": "Summe",
"count": "Anzahl",
"maximum": "Maximum"
},
"directions": {
"minimize": "Minimieren",
"maximize": "Maximieren"
}
}
},
"planning": {
@@ -276,7 +321,7 @@
"withoutDay": "Ohne diesen Tag",
"withDay": "Mit diesem Tag",
"noChange": "Diese Werte sind gleich. Regeln und die letzten Entscheidungskriterien (Verfall des Guthabens, dann Datum) können die Auswahl weiterhin beeinflussen.",
"priorityHelp": "Die Kriterien stehen in Prioritätsreihenfolge. Der erste Unterschied hat Vorrang vor den folgenden.",
"priorityHelp": "Verpflichtungen werden gemeinsam erfüllt. Aktive Präferenzen werden in Ihrer Regelreihenfolge verglichen. Nur diese Regeln führen zu neuen Urlaubstagen; früherer Verfall und dann frühere Daten entscheiden bei Gleichstand.",
"tryDate": "Anderes Datum testen",
"plannedDate": "Derzeit geplant für:",
"currentPlan": "Aktueller Plan",
@@ -327,6 +372,10 @@
"placement-date": {
"label": "Frühere Daten bevorzugen",
"help": "Letztes Kriterium, wenn alle anderen Kriterien gleich sind."
},
"optimization": {
"label": "{rule}: {summary}",
"help": "Diese Optimierungspräferenz bewertet ihre gespeicherte Konfiguration. Aktive Präferenzen werden in der angezeigten Reihenfolge verglichen; frühere haben Vorrang."
}
},
"stages": {
@@ -342,7 +391,9 @@
"unavailable": "Dieses Urlaubsguthaben kann an diesem Datum nicht verwendet werden.",
"expired": "Dieses Datum liegt nach dem Verfall des Urlaubsguthabens.",
"type-incompatible": "Diese Urlaubsart kann an diesem Datum nicht verwendet werden.",
"rule-invalid": "Dieses Datum würde eine Planungsregel verletzen."
"rule-invalid": "Dieses Datum würde eine Planungsregel verletzen.",
"invalid-optimization-configuration": "Wählen Sie passende Elemente, Bedingungen, Messgrößen und Aggregationen sowie bei Bedarf eine positive ganzzahlige Tagesschwelle.",
"unsupported-optimization-configuration": "Diese Regel kombiniert unvereinbare Planungselemente und Messgrößen."
},
"preferenceHelp": {
"time_window": "Urlaubstage im bevorzugten Zeitraum. Mehr ist besser.",
@@ -373,6 +424,7 @@
"no-working-date": "Kein Arbeitstag liegt sowohl im Planungszeitraum als auch zwischen Verfügbarkeits- und Ablaufdatum.",
"all-dates-occupied": "Alle Arbeitstage, an denen dieses Urlaubskonto nutzbar ist, sind bereits durch geplanten Urlaub oder gebuchte Tage belegt.",
"preserved": "Für später aufbewahrt: Das Urlaubskonto läuft nach dem Planungszeitraum ab und die Berechnung hat gemäß Ihren Prioritäten keine zusätzliche Platzierung ausgewählt.",
"active-priorities": "Die Berechnung hat dieses Urlaubskonto gemäß Ihren aktiven Regeln ungenutzt gelassen. Ändern Sie die Regeln oder ihre Reihenfolge, um einen anderen Plan anzufordern.",
"calculation-incomplete": "Die Berechnung wurde vor Abschluss der Planung unterbrochen. Die Nichtnutzung ist keine endgültige Entscheidung.",
"not-established": "Diese Berechnung hat keinen genauen Grund für die Nichtnutzung ermittelt."
}
+54 -2
View File
@@ -133,6 +133,51 @@
"after": "After",
"around": "Around",
"auto": "Auto (Best choice)"
},
"optimization": {
"newRule": "New Optimization Rule",
"defaultName": "Loss prevention",
"elementsLabel": "Planning elements",
"conditionsLabel": "Conditions (all must match)",
"measureLabel": "Measure",
"aggregationLabel": "Aggregation",
"directionLabel": "Direction",
"thresholdLabel": "Preference threshold in working days",
"thresholdSummary": "preference threshold: {count} working days",
"summary": "{direction} {aggregation}: {measure} — {elements}{conditions}.",
"invalidConfiguration": "Choose compatible planning elements, conditions, measure and aggregation, with a positive whole-day threshold when needed.",
"nameRequired": "Enter a rule name.",
"elements": {
"leave-balances": "Leave balances",
"public-holiday-weeks": "Public-holiday weeks",
"time-off-periods": "Time-off periods",
"work-sequences": "Work sequences",
"leave-placements": "Leave placements"
},
"conditions": {
"at-risk": "At risk of expiry",
"complete": "Complete",
"internal": "Internal",
"non-empty": "Non-empty",
"supporting": "Supporting"
},
"measures": {
"unplaced-quantity": "Unplaced quantity",
"public-holiday-week-value": "Public-holiday week value",
"non-working-day-gain": "Non-working-day gain",
"work-sequence-length": "Work-sequence length",
"work-sequence-deficit": "Work-sequence deficit",
"placement-count": "Placement count"
},
"aggregations": {
"sum": "Sum",
"count": "Count",
"maximum": "Maximum"
},
"directions": {
"minimize": "Minimize",
"maximize": "Maximize"
}
}
},
"planning": {
@@ -276,7 +321,7 @@
"withoutDay": "Without this day",
"withDay": "With this day",
"noChange": "These totals are unchanged. Rules and the final tie-breakers (balance expiry, then date) can still affect the choice.",
"priorityHelp": "Criteria are listed in priority order. The first difference takes precedence over later ones.",
"priorityHelp": "Obligations are satisfied together. Active preferences are compared in your rule order. Only those rules drive new placements; earlier expiry and then dates break equal results.",
"tryDate": "Try another date",
"plannedDate": "Currently planned for:",
"currentPlan": "Current plan",
@@ -327,6 +372,10 @@
"placement-date": {
"label": "Earlier dates first",
"help": "Final tie-breaker when all other criteria are equal."
},
"optimization": {
"label": "{rule}: {summary}",
"help": "This optimization preference evaluates its saved configuration. Active preferences follow the displayed order; earlier preferences win before later ones."
}
},
"stages": {
@@ -342,7 +391,9 @@
"unavailable": "This leave balance cannot be used on this date.",
"expired": "This date is after the leave balance expires.",
"type-incompatible": "This leave type cannot be used on this date.",
"rule-invalid": "Using this date would break a planning rule."
"rule-invalid": "Using this date would break a planning rule.",
"invalid-optimization-configuration": "Choose compatible planning elements, conditions, measure and aggregation, with a positive whole-day threshold when needed.",
"unsupported-optimization-configuration": "This rule uses an unsupported combination of planning elements and measures."
},
"preferenceHelp": {
"time_window": "Leave days in this preferred period. More is better.",
@@ -373,6 +424,7 @@
"no-working-date": "No working day falls within both the planning period and its availability and expiry dates.",
"all-dates-occupied": "All working days on which this balance can be used are already occupied by planned leave or already booked days.",
"preserved": "Kept for later: it expires after the planning period and the calculation retained no additional placement under your priorities.",
"active-priorities": "The calculation kept this balance unused under your active planning rules. Change the rules or their order to request a different plan.",
"calculation-incomplete": "The calculation was interrupted before planning finished. Leaving it unused is not a final decision.",
"not-established": "This calculation did not establish a precise reason for leaving it unused."
}
+54 -2
View File
@@ -133,6 +133,51 @@
"after": "Después",
"around": "Alrededor",
"auto": "Auto (Mejor opción)"
},
"optimization": {
"newRule": "Nueva regla de Optimización",
"defaultName": "Prevención de pérdidas",
"elementsLabel": "Elementos del plan",
"conditionsLabel": "Condiciones (deben cumplirse todas)",
"measureLabel": "Medida",
"aggregationLabel": "Agregación",
"directionLabel": "Dirección",
"thresholdLabel": "Umbral de preferencia en días laborables",
"thresholdSummary": "umbral de preferencia: {count} días laborables",
"summary": "{direction} {aggregation}: {measure} — {elements}{conditions}.",
"invalidConfiguration": "Elija elementos, condiciones, medidas y agregaciones compatibles, con un umbral entero positivo cuando sea necesario.",
"nameRequired": "Introduzca un nombre para la regla.",
"elements": {
"leave-balances": "Saldos de vacaciones",
"public-holiday-weeks": "Semanas con festivos",
"time-off-periods": "Períodos sin trabajo",
"work-sequences": "Secuencias de trabajo",
"leave-placements": "Vacaciones colocadas"
},
"conditions": {
"at-risk": "En riesgo de caducar",
"complete": "Completas",
"internal": "Internas",
"non-empty": "No vacías",
"supporting": "De apoyo"
},
"measures": {
"unplaced-quantity": "Cantidad no colocada",
"public-holiday-week-value": "Valor de las semanas festivas",
"non-working-day-gain": "Ganancia de días sin trabajo",
"work-sequence-length": "Longitud de las secuencias de trabajo",
"work-sequence-deficit": "Déficit de las secuencias de trabajo",
"placement-count": "Número de colocaciones"
},
"aggregations": {
"sum": "Suma",
"count": "Cantidad",
"maximum": "Máximo"
},
"directions": {
"minimize": "Minimizar",
"maximize": "Maximizar"
}
}
},
"planning": {
@@ -276,7 +321,7 @@
"withoutDay": "Sin este día",
"withDay": "Con este día",
"noChange": "Estos totales son iguales. Las reglas y los últimos criterios de desempate (vencimiento del saldo y después fecha) aún pueden influir en la elección.",
"priorityHelp": "Los criterios se muestran por prioridad. La primera diferencia prevalece sobre las siguientes.",
"priorityHelp": "Las obligaciones se resuelven conjuntamente. Las preferencias activas se comparan en el orden de sus reglas. Solo esas reglas añaden vacaciones; la caducidad y luego las fechas resuelven los empates.",
"tryDate": "Probar otra fecha",
"plannedDate": "Fecha prevista actualmente:",
"currentPlan": "Plan actual",
@@ -327,6 +372,10 @@
"placement-date": {
"label": "Primero las fechas más tempranas",
"help": "Último desempate cuando los demás criterios son iguales."
},
"optimization": {
"label": "{rule}: {summary}",
"help": "Esta preferencia de optimización evalúa su configuración guardada. Las preferencias activas siguen el orden mostrado; las anteriores tienen prioridad."
}
},
"stages": {
@@ -342,7 +391,9 @@
"unavailable": "Este saldo de permisos no se puede usar en esta fecha.",
"expired": "Esta fecha es posterior al vencimiento del saldo.",
"type-incompatible": "Este tipo de permiso no se puede usar en esta fecha.",
"rule-invalid": "Usar esta fecha incumpliría una regla del plan."
"rule-invalid": "Usar esta fecha incumpliría una regla del plan.",
"invalid-optimization-configuration": "Elija elementos, condiciones, medidas y agregaciones compatibles, con un umbral entero positivo cuando sea necesario.",
"unsupported-optimization-configuration": "Esta regla combina elementos del plan y medidas incompatibles."
},
"preferenceHelp": {
"time_window": "Días de permiso dentro del período preferido. Más es mejor.",
@@ -373,6 +424,7 @@
"no-working-date": "Ningún día laborable está dentro del período planificado y entre sus fechas de disponibilidad y caducidad.",
"all-dates-occupied": "Todos los días laborables en los que puede utilizarse este saldo ya están ocupados por permisos planificados o días ya reservados.",
"preserved": "Guardado para más adelante: caduca después del período planificado y el cálculo no seleccionó ninguna colocación adicional según tus prioridades.",
"active-priorities": "El cálculo conservó este saldo sin utilizar según tus reglas activas. Cambia las reglas o su orden para solicitar otro plan.",
"calculation-incomplete": "El cálculo se interrumpió antes de terminar la planificación. Su falta de uso no es una decisión definitiva.",
"not-established": "Este cálculo no determinó un motivo preciso para dejarlo sin utilizar."
}
+54 -2
View File
@@ -133,6 +133,51 @@
"after": "Après",
"around": "Autour",
"auto": "Auto (Meilleur choix)"
},
"optimization": {
"newRule": "Nouvelle règle d’Optimisation",
"defaultName": "Prévention des pertes",
"elementsLabel": "Éléments du planning",
"conditionsLabel": "Conditions (toutes doivent être vérifiées)",
"measureLabel": "Mesure",
"aggregationLabel": "Agrégation",
"directionLabel": "Direction",
"thresholdLabel": "Seuil de préférence en jours ouvrés",
"thresholdSummary": "seuil de préférence : {count} jours ouvrés",
"summary": "{direction} {aggregation} : {measure} — {elements}{conditions}.",
"invalidConfiguration": "Choisissez des éléments, conditions, une mesure et une agrégation compatibles, avec un seuil entier positif si nécessaire.",
"nameRequired": "Saisissez un nom de règle.",
"elements": {
"leave-balances": "Soldes de congés",
"public-holiday-weeks": "Semaines avec jour férié",
"time-off-periods": "Périodes non travaillées",
"work-sequences": "Séquences de travail",
"leave-placements": "Congés placés"
},
"conditions": {
"at-risk": "À risque d’expiration",
"complete": "Complètes",
"internal": "Internes",
"non-empty": "Non vides",
"supporting": "D’appui"
},
"measures": {
"unplaced-quantity": "Quantité non placée",
"public-holiday-week-value": "Valeur des semaines fériées",
"non-working-day-gain": "Gain de jours non travaillés",
"work-sequence-length": "Longueur des séquences de travail",
"work-sequence-deficit": "Déficit des séquences de travail",
"placement-count": "Nombre de placements"
},
"aggregations": {
"sum": "Somme",
"count": "Nombre",
"maximum": "Maximum"
},
"directions": {
"minimize": "Minimiser",
"maximize": "Maximiser"
}
}
},
"planning": {
@@ -276,7 +321,7 @@
"withoutDay": "Sans ce jour",
"withDay": "Avec ce jour",
"noChange": "Ces totaux sont identiques. Les règles et les derniers critères de départage (expiration du solde, puis date) peuvent encore influencer le choix.",
"priorityHelp": "Les critères sont classés par priorité. La première différence prime sur les suivantes.",
"priorityHelp": "Les obligations sont résolues conjointement. Les préférences actives sont comparées dans l’ordre de vos règles. Seules ces règles déclenchent des placements ; l’expiration puis les dates départagent les résultats égaux.",
"tryDate": "Essayer une autre date",
"plannedDate": "Date actuellement prévue :",
"currentPlan": "Planning actuel",
@@ -327,6 +372,10 @@
"placement-date": {
"label": "Dates les plus proches d’abord",
"help": "Dernier départage lorsque tous les autres critères sont égaux."
},
"optimization": {
"label": "{rule}: {summary}",
"help": "Cette préférence d’optimisation évalue sa configuration enregistrée. Les préférences actives suivent l’ordre affiché ; les premières passent avant les suivantes."
}
},
"stages": {
@@ -342,7 +391,9 @@
"unavailable": "Ce solde de congés ne peut pas être utilisé à cette date.",
"expired": "Cette date dépasse la date d’expiration du solde.",
"type-incompatible": "Ce type de congé ne peut pas être utilisé à cette date.",
"rule-invalid": "Utiliser cette date ne respecterait pas une règle du planning."
"rule-invalid": "Utiliser cette date ne respecterait pas une règle du planning.",
"invalid-optimization-configuration": "Choisissez des éléments, conditions, une mesure et une agrégation compatibles, avec un seuil entier positif si nécessaire.",
"unsupported-optimization-configuration": "Cette règle combine des éléments du planning et des mesures incompatibles."
},
"preferenceHelp": {
"time_window": "Jours de congé dans cette période souhaitée. Plus, c’est mieux.",
@@ -373,6 +424,7 @@
"no-working-date": "Aucun jour ouvré ne se trouve à la fois dans la période planifiée et entre ses dates de disponibilité et d’expiration.",
"all-dates-occupied": "Tous les jours ouvrés où ce solde est utilisable sont déjà occupés par des congés calculés ou des jours déjà posés.",
"preserved": "Conservé pour plus tard : il expire après la période planifiée et le calcul n’a pas retenu de pose supplémentaire selon vos priorités.",
"active-priorities": "Le calcul a conservé ce solde inutilisé selon vos règles actives. Modifiez les règles ou leur ordre pour demander un autre planning.",
"calculation-incomplete": "Le calcul a été interrompu avant de terminer la planification. Sa non-utilisation n’est pas une décision définitive.",
"not-established": "La raison précise de sa non-utilisation n’a pas été établie par ce calcul."
}