Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56b66e10c | ||
|
|
5ee95fe9ca | ||
|
|
389f6eb7e2 | ||
|
|
880efe4e90 | ||
|
|
c59d75e526 | ||
|
|
b086f1ffc8 | ||
|
|
813d9b4d7f | ||
|
|
9158966982 | ||
|
|
d9c7f72461 | ||
|
|
1636355920 | ||
|
|
85fc4561fd | ||
|
|
282d9c6787 | ||
|
|
dfe6d28d2b | ||
|
|
ac69a41fc8 | ||
|
|
f74d744f9b | ||
|
|
94d57add2c | ||
|
|
558c70aa24 | ||
|
|
c949480e49 | ||
|
|
752c8b7a99 | ||
|
|
cee67ce3df | ||
|
|
a472347a5e | ||
|
|
feb651148e | ||
|
|
43677ff434 | ||
|
|
502d561f6f | ||
|
|
5cb0b93339 | ||
|
|
b69b3c3690 | ||
|
|
7fc2b33587 | ||
|
|
40a11c245a | ||
|
|
3bbe989eca | ||
|
|
cfdb28c90a | ||
|
|
63b50c4311 | ||
|
|
acdb19352a | ||
|
|
68a9e33de6 | ||
|
|
7890a03a74 | ||
|
|
87d72064be | ||
|
|
24c35542a8 | ||
|
|
4d26e65672 | ||
|
|
a871f8f28c | ||
|
|
a371e03462 | ||
|
|
1711bc570c | ||
|
|
c6610cd633 | ||
|
|
433a8b0ade | ||
|
|
c7e7abcd24 | ||
|
|
cbde23f572 | ||
|
|
0852cd4444 |
No files matched your search
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: tiered-verified-implement
|
||||||
|
description: Implement a spec or ticket in Claude Code by delegating the work to the cheapest Claude model that can handle each piece (Haiku for mechanical edits, Sonnet for normal work), then independently verify the final change on the orchestrator's model. Use when the user asks to implement, build, or fix work and wants it done cheaply, on a cheaper model, or with delegated implementation and a verification pass.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tiered Verified Implement
|
||||||
|
|
||||||
|
Invoke `/implement` and follow it as the baseline workflow. This skill is its cost-saving, delegated, verified variant: cheap models write the code, the orchestrator's model checks it.
|
||||||
|
|
||||||
|
## Model tiers
|
||||||
|
|
||||||
|
Pick the cheapest tier that can do each piece without guessing:
|
||||||
|
|
||||||
|
- `haiku`: mechanical work with no design decisions — renames, boilerplate, fixture or snapshot updates, wiring an existing pattern into one more place, translations, doc edits.
|
||||||
|
- `sonnet`: the default — new logic, bug fixes, tests for real behavior, anything touching several files in one seam.
|
||||||
|
- Orchestrator (yourself): only for work a cheap agent failed on twice, or that needs a consequential design call the spec leaves open. Ask the user about such calls before delegating rather than letting a cheap agent guess.
|
||||||
|
|
||||||
|
When unsure between two tiers, choose the lower one; escalation is cheaper than defaulting high.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Read the spec or ticket, repository guidance, affected flow, and existing worktree changes. Record the starting commit and task-owned scope; preserve unrelated changes.
|
||||||
|
2. Split only genuinely independent, bounded work. If the change is small or one seam owns it, use one implementation agent; do not manufacture parallelism. Assign each piece a tier.
|
||||||
|
3. Spawn each implementation agent with the Agent tool: `subagent_type: "general-purpose"` and `model` set to its tier. Never use `subagent_type: "fork"` for this — forks ignore the `model` override and run on the expensive orchestrator model. Fresh agents start cold, so the brief carries everything: exact owned files/seam, acceptance criteria, relevant file paths and existing patterns to copy, required checks (targeted tests, typecheck), and an instruction not to overwrite or revert other agents' work. A precise brief is the main cost lever: it stops the cheap agent from re-exploring the repo.
|
||||||
|
4. Wait for agents, inspect their diffs (not their whole exploration), resolve overlaps minimally, and run the checks required by `/implement`: targeted tests and typecheck during work, then the full relevant suite once.
|
||||||
|
5. If an agent's result fails checks or misses the spec, send it back once with the concrete failure (SendMessage keeps its context). If it fails again, escalate one tier: haiku → sonnet → orchestrator.
|
||||||
|
6. Spawn a fresh, read-only verification agent (`subagent_type: "general-purpose"`) with `model` set explicitly to the orchestrator's own tier (e.g. `opus` when you run on Opus). Set it explicitly rather than omitting it, since a configured default subagent model could silently downgrade the verifier. Give it the original request, starting commit, current diff, and test output, but not implementation conclusions. Ask it to check correctness, regressions, specification coverage, and test evidence, and to report findings without changing files.
|
||||||
|
7. Fix confirmed findings (delegate back to the cheap tier when the fix is mechanical), rerun affected checks, and repeat verification once if fixes were needed. Run `/code-review` on the current working result (including staged, unstaged, and new files), passing the original spec, starting commit, and task-owned scope. Follow `/implement`'s commit boundary: commit only when requested, otherwise hand off the verified diff.
|
||||||
|
|
||||||
|
In the final report, list which tier did each piece and any escalations, so the user can see where the cheap models held up.
|
||||||
|
|
||||||
|
## Delegation rules
|
||||||
|
|
||||||
|
- Keep all agents in the same workspace; assign non-overlapping ownership before they edit.
|
||||||
|
- Do not delegate secrets, production writes, destructive operations, or changes requiring new user authority.
|
||||||
|
- If subagents are unavailable, implement directly, run the same checks, and state that delegated implementation and verification were unavailable.
|
||||||
+7
-6
@@ -3,16 +3,17 @@
|
|||||||
All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to the following rules before declaring a task complete:
|
All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to the following rules before declaring a task complete:
|
||||||
|
|
||||||
1. **Test Coverage (>= 80%)**:
|
1. **Test Coverage (>= 80%)**:
|
||||||
- The overall project code coverage (Statements, Lines, and Functions) must be maintained at **80% or higher**.
|
- The overall project line coverage must be maintained at **80% or higher**. Bun reports line data only; statement, branch and function coverage are no longer measured.
|
||||||
- Verify coverage by running:
|
- Verify coverage by running:
|
||||||
```bash
|
```bash
|
||||||
bun scripts/generate-langs.js
|
bun scripts/generate-langs.js
|
||||||
NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
|
bun test --config=bunfig.coverage.toml --isolate --path-ignore-patterns='**/benchmark/**'
|
||||||
|
bun scripts/coverage-reports.mjs
|
||||||
```
|
```
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
2. **Code Formatting (Prettier)**:
|
2. **Code Formatting (Prettier)**:
|
||||||
- All code files must be correctly formatted according to the project's Prettier config before finishing.
|
- All code files, the CI scripts and the workflow must be correctly formatted according to the project's Prettier config before finishing.
|
||||||
- Run the formatter with:
|
- Run the formatter with:
|
||||||
```bash
|
```bash
|
||||||
bun run format
|
bun run format
|
||||||
@@ -29,8 +30,8 @@ All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to
|
|||||||
- 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:
|
- 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)
|
- **Linting check**: `bun run lint` (or use `bun run lint:fix` to resolve auto-fixable issues)
|
||||||
- **Formatting check**: `bun run format:check`
|
- **Formatting check**: `bun run format:check`
|
||||||
- **TypeScript Type check**: `bunx tsc --noEmit -p tsconfig.app.json` and `bunx tsc --noEmit -p tsconfig.spec.json`
|
- **TypeScript Type check**: `bun run typecheck` (`bun check` for application and test code; it does not replace the production build, which alone checks Angular templates)
|
||||||
- **Unit Tests and Coverage**: generate the language registry, then run the coverage command in rule 1, matching the Gitea functional-test job.
|
- **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`
|
- **CI policy and publication**: `bun scripts/ci-policy.test.mjs` and `bun scripts/ci-image.test.mjs`
|
||||||
- **Production Build**: `bun run build -- --configuration production`
|
- **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`.
|
- The separate performance benchmark is a required CI job with a relative gate: each scenario's budget is a multiple of the frozen engine's two-year calibration measured in the same run. Run it outside coverage with `timeout 600s bun test src/app/benchmark --timeout=600000` when verifying performance; see `docs/benchmark/README.md`.
|
||||||
+65
-38
@@ -9,6 +9,11 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
# Gitea's checkout and upload-artifact actions are JavaScript actions that the runner
|
||||||
|
# executes with the job container's own `node`, so those containers keep Node. Project
|
||||||
|
# commands never use it: the jobs that run them install the pinned Bun with
|
||||||
|
# scripts/install-bun.sh and run `run:` steps under a shell whose PATH has Bun but no Node.
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.event_name }}-${{ github.ref }}
|
group: ci-${{ github.event_name }}-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
@@ -18,54 +23,66 @@ jobs:
|
|||||||
name: policy
|
name: policy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.21.0
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
|
||||||
outputs:
|
outputs:
|
||||||
slug: ${{ steps.policy.outputs.slug }}
|
slug: ${{ steps.policy.outputs.slug }}
|
||||||
tag: ${{ steps.policy.outputs.tag }}
|
tag: ${{ steps.policy.outputs.tag }}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- name: Policy regression tests
|
- name: Policy regression tests
|
||||||
run: node scripts/ci-policy.test.mjs && node scripts/ci-image.test.mjs
|
run: bun scripts/ci-policy.test.mjs && bun scripts/ci-image.test.mjs
|
||||||
- name: Validate event and commit
|
- name: Validate event and commit
|
||||||
id: policy
|
id: policy
|
||||||
run: node scripts/ci-policy.mjs
|
run: bun scripts/ci-policy.mjs
|
||||||
|
|
||||||
validate:
|
validate:
|
||||||
name: validate
|
name: validate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.21.0
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- run: npm install --global bun@1.4.2
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- run: bun run lint
|
- run: bun run lint
|
||||||
- run: bun run format:check
|
- run: bun run format:check
|
||||||
- run: bunx prettier --check 'scripts/ci-*.mjs' '.gitea/workflows/*.yml'
|
- run: bun run typecheck
|
||||||
- run: bunx tsc --noEmit -p tsconfig.app.json
|
|
||||||
- run: bunx tsc --noEmit -p tsconfig.spec.json
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: test
|
name: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.21.0
|
||||||
env:
|
defaults:
|
||||||
NODE_OPTIONS: --max-old-space-size=2048
|
run:
|
||||||
|
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- run: npm install --global bun@1.4.2
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- run: bun scripts/generate-langs.js
|
- run: bun scripts/generate-langs.js
|
||||||
- name: Functional coverage
|
- name: Functional coverage
|
||||||
run: bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
|
run: bun test --config=bunfig.coverage.toml --isolate --path-ignore-patterns='**/benchmark/**'
|
||||||
- name: Coverage report
|
- name: Coverage report
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: https://gitea.com/actions/upload-artifact@v3
|
run: bun scripts/coverage-reports.mjs
|
||||||
|
- name: Upload coverage
|
||||||
|
if: ${{ always() }}
|
||||||
|
uses: https://gitea.com/actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: coverage
|
name: coverage
|
||||||
path: coverage/
|
path: coverage/
|
||||||
@@ -75,33 +92,39 @@ jobs:
|
|||||||
benchmark:
|
benchmark:
|
||||||
name: benchmark
|
name: benchmark
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container:
|
||||||
# shortcut: advisory while #78 is open; restore the gate after the protocol passes.
|
image: node:24.21.0
|
||||||
continue-on-error: true
|
options: --memory=3g
|
||||||
env:
|
defaults:
|
||||||
NODE_OPTIONS: --max-old-space-size=2048
|
run:
|
||||||
|
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- run: npm install --global bun@1.4.2
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- run: bun scripts/generate-langs.js
|
- run: bun scripts/generate-langs.js
|
||||||
- name: Unchanged benchmark protocol
|
- name: Benchmark protocol with relative gate
|
||||||
run: timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
run: timeout 600s bun test src/app/benchmark --timeout=600000
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: build
|
name: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.21.0
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- run: npm install --global bun@1.4.2
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- run: bun run build -- --configuration production
|
- run: bun run build -- --configuration production
|
||||||
- uses: https://gitea.com/actions/upload-artifact@v3
|
- uses: https://gitea.com/actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -113,7 +136,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.21.0
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install scanners
|
- name: Install scanners
|
||||||
@@ -139,7 +162,7 @@ jobs:
|
|||||||
run: trivy fs --db-repository public.ecr.aws/aquasecurity/trivy-db:2 --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
|
run: trivy fs --db-repository public.ecr.aws/aquasecurity/trivy-db:2 --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
|
||||||
- name: Security reports
|
- name: Security reports
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: https://gitea.com/actions/upload-artifact@v3
|
uses: https://gitea.com/actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: security
|
name: security
|
||||||
path: '*-results.sarif'
|
path: '*-results.sarif'
|
||||||
@@ -148,20 +171,20 @@ jobs:
|
|||||||
|
|
||||||
images:
|
images:
|
||||||
name: images
|
name: images
|
||||||
needs: [policy, validate, test, build, security]
|
needs: [policy, validate, test, benchmark, build, security]
|
||||||
if: >-
|
if: >-
|
||||||
success() &&
|
success() &&
|
||||||
(github.event_name == 'push' ||
|
(github.event_name == 'push' ||
|
||||||
(github.event_name == 'pull_request' &&
|
(github.event_name == 'pull_request' &&
|
||||||
github.event.pull_request.head.repo.full_name == github.repository))
|
github.event.pull_request.head.repo.full_name == github.repository))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: docker:29.8.2
|
container: docker:29.9.1
|
||||||
concurrency:
|
concurrency:
|
||||||
group: image-${{ needs.policy.outputs.tag != '' && 'stable' || needs.policy.outputs.slug }}
|
group: image-${{ needs.policy.outputs.tag != '' && 'stable' || needs.policy.outputs.slug }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
services:
|
services:
|
||||||
docker:
|
docker:
|
||||||
image: docker:29.8.2-dind
|
image: docker:29.9.1-dind
|
||||||
env:
|
env:
|
||||||
DOCKER_TLS_CERTDIR: ''
|
DOCKER_TLS_CERTDIR: ''
|
||||||
options: >-
|
options: >-
|
||||||
@@ -177,32 +200,36 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
shell: sh
|
shell: sh
|
||||||
steps:
|
steps:
|
||||||
- name: Install checkout and policy runtimes
|
# Alpine's nodejs package is for the checkout action only (it shares /usr/bin
|
||||||
run: apk add --no-cache nodejs git
|
# with git); Bun needs libgcc and libstdc++ and the installer needs curl.
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- name: Install checkout runtime and tools
|
||||||
|
run: apk add --no-cache nodejs git curl libgcc libstdc++
|
||||||
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- name: Build branch images or promote a release
|
- name: Build branch images or promote a release
|
||||||
env:
|
env:
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
run: node scripts/ci-image.mjs
|
run: /opt/bun/bin/bun scripts/ci-image.mjs
|
||||||
|
|
||||||
required:
|
required:
|
||||||
name: required
|
name: required
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
needs: [policy, validate, test, build, security, images]
|
needs: [policy, validate, test, benchmark, build, security, images]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.21.0
|
||||||
env:
|
env:
|
||||||
CHECK_RESULTS: ${{ needs.policy.result }} ${{ needs.validate.result }} ${{ needs.test.result }} ${{ needs.build.result }} ${{ needs.security.result }}
|
CHECK_RESULTS: ${{ needs.policy.result }} ${{ needs.validate.result }} ${{ needs.test.result }} ${{ needs.benchmark.result }} ${{ needs.build.result }} ${{ needs.security.result }}
|
||||||
IMAGE_RESULT: ${{ needs.images.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) }}
|
IMAGE_EXPECTED: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Require successful validation and expected publication
|
- name: Require successful validation and expected publication
|
||||||
run: |
|
run: |
|
||||||
test "$CHECK_RESULTS" = 'success success success success success'
|
test "$CHECK_RESULTS" = 'success success success success success success' || exit 1
|
||||||
if [ "$IMAGE_EXPECTED" = 'true' ]; then
|
if [ "$IMAGE_EXPECTED" = 'true' ]; then
|
||||||
test "$IMAGE_RESULT" = 'success'
|
test "$IMAGE_RESULT" = 'success'
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
/out-tsc
|
/out-tsc
|
||||||
/bazel-out
|
/bazel-out
|
||||||
|
|
||||||
|
.scratch/
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
/node_modules
|
/node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"npm.packageManager": "bun"
|
||||||
|
}
|
||||||
+10
-28
@@ -1,48 +1,30 @@
|
|||||||
# Stage 1: Build the Angular application
|
# Stage 1: Build the Angular application and the Bun static server
|
||||||
FROM oven/bun:alpine AS builder
|
FROM oven/bun:1.4.3-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS builder
|
||||||
|
|
||||||
# Install nodejs to support Angular prerendering worker threads
|
|
||||||
RUN apk add --no-cache nodejs
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
COPY package.json bun.lock ./
|
||||||
COPY package.json bun.lock* ./
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the application for production
|
# bunfig.toml makes the Node-shebang Angular CLI run on Bun; the image has no Node
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
# Stage 2: Production image
|
# Stage 2: Production image
|
||||||
FROM oven/bun:alpine AS production
|
FROM oven/bun:1.4.3-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS production
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# The server is a self-contained Bun bundle that serves the browser build, so
|
||||||
COPY package.json bun.lock* ./
|
# the runtime has no dependencies to install.
|
||||||
|
|
||||||
# Install only production dependencies
|
|
||||||
RUN bun install --production
|
|
||||||
|
|
||||||
# Copy built application from builder stage
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Set environment to production
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Expose port (default Express port)
|
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
CMD bun -e "require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
CMD bun -e "fetch('http://localhost:' + (process.env.PORT || 4000) + '/').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
|
||||||
|
|
||||||
# Start the application
|
CMD ["bun", "dist/vac-optimizer/server/server.js"]
|
||||||
CMD ["bun", "run", "dist/vac-optimizer/server/server.mjs"]
|
|
||||||
@@ -17,7 +17,7 @@ The planner answers these questions:
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
Host["Express production host<br/>static and client assets"] -. serves .-> UI
|
Host["Bun static server<br/>client assets"] -. serves .-> UI
|
||||||
|
|
||||||
subgraph Browser
|
subgraph Browser
|
||||||
UI["Angular standalone components"]
|
UI["Angular standalone components"]
|
||||||
@@ -42,7 +42,7 @@ flowchart LR
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave calculation stays in the browser. The production Express process serves the generated Angular application; it does not participate in planning. Browser calculations run cooperatively in a dedicated worker. The resumable core is emitted as a separate worker/lazy chunk and retains a synchronous adapter for direct core callers. The original decision trace and historical analysis were removed in issue #76; immutable checkpoints and final state remain available for cancellation recovery and displayed-placement alternatives.
|
Leave calculation stays in the browser. The production Bun process serves the generated client-only Angular application; it does not participate in planning. Browser calculations run cooperatively in a dedicated worker. The resumable core is emitted as a separate worker/lazy chunk and retains a synchronous adapter for direct core callers. The original decision trace and historical analysis were removed in issue #76; immutable checkpoints and final state remain available for cancellation recovery and displayed-placement alternatives.
|
||||||
|
|
||||||
### Main execution path
|
### Main execution path
|
||||||
|
|
||||||
@@ -162,9 +162,9 @@ Incomplete outcomes explain the retained plan and unfinished phases; conflicts i
|
|||||||
- Web Workers for planner isolation
|
- Web Workers for planner isolation
|
||||||
- `date-holidays` for the French public calendar
|
- `date-holidays` for the French public calendar
|
||||||
- Bun for dependency management and project scripts
|
- Bun for dependency management and project scripts
|
||||||
- Vitest through the Angular test builder
|
- `bun test` with jsdom and Angular TestBed (JIT)
|
||||||
- ESLint and Prettier
|
- ESLint and Prettier, run by Bun, and `bun check` for standalone type checking
|
||||||
- Angular server build hosted by Express
|
- Client-only Angular build served by a small `Bun.serve` static server (`src/server.ts`)
|
||||||
- Gitea repository hosting 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.
|
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.
|
||||||
@@ -198,9 +198,10 @@ CONTEXT.md # Canonical domain language and rules
|
|||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
Prerequisite: [Bun](https://bun.sh/). Dependency versions are locked in `bun.lock`.
|
Prerequisite: [Bun](https://bun.sh/) 1.4.3, the exact version pinned by `packageManager` in `package.json` and used by CI and the Docker image. Node.js is not needed. Dependency versions are locked in `bun.lock`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.3"
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun start
|
bun start
|
||||||
```
|
```
|
||||||
@@ -210,21 +211,28 @@ Open <http://localhost:4200>. The development server reloads when source files c
|
|||||||
### Useful commands
|
### Useful commands
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
| ----------------------------------------- | ------------------------------------------------------------ |
|
| ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `bun start` | Generate the i18n registry and start the development server. |
|
| `bun start` | Generate the i18n registry and start the development server. |
|
||||||
| `bun run test --watch=false` | Run the unit and integration suite once. |
|
| `bun run watch` | Rebuild the development bundle when source files change. |
|
||||||
| `bun run lint` | Run ESLint. |
|
| `bun run test` | Run the unit, integration and benchmark suites once. |
|
||||||
| `bun run format:check` | Check Prettier formatting. |
|
| `bun test --isolate --path-ignore-patterns='**/benchmark/**'` | Run the functional suite only. |
|
||||||
| `bunx tsc --noEmit -p tsconfig.app.json` | Type-check application code. |
|
| `bun test src/app/benchmark --timeout=600000` | Run the planning benchmark (no coverage). |
|
||||||
| `bunx tsc --noEmit -p tsconfig.spec.json` | Type-check tests. |
|
| `bun test --config=bunfig.coverage.toml --isolate --path-ignore-patterns='**/benchmark/**' && bun scripts/coverage-reports.mjs` | Functional coverage: text, `lcov.info`, HTML and Cobertura in `coverage/`. Line coverage only; frozen engine excluded. |
|
||||||
| `bun run build` | Create the optimized browser and server bundles in `dist/`. |
|
| `bun run lint` | Run ESLint (`bun run lint:fix` applies its fixes). |
|
||||||
| `bun run serve:ssr:vac-optimizer` | Serve a completed production build on port 4000. |
|
| `bun run format:check` | Check Prettier formatting of the application, the CI scripts and the workflow (`bun run format` writes it). |
|
||||||
|
| `bun run typecheck` | Type-check application and test code with `bun check`. It does not check Angular templates; only the build does. |
|
||||||
|
| `bun run build` | Create the optimized browser bundle and the static server bundle in `dist/vac-optimizer/`. |
|
||||||
|
| `bun run serve` | Serve a completed production build on `PORT` (default 4000). |
|
||||||
|
|
||||||
|
Project scripts run Node-shebang tools (`ng`, `eslint`, `prettier`) on Bun even when Node is installed: `bunfig.toml` sets `[run] bun = true`, which is the `--bun` flag for every `bun run`. Start them through the scripts above. A bare `bunx` or `bun x` uses Node when it is on `PATH`; for a one-off tool, use `bun --bun x <tool>`.
|
||||||
|
|
||||||
|
Angular compiles with TypeScript 6. `bun check` is Bun's TypeScript 7 checker for standalone type errors in application and test code. Neither replaces the other: only `bun run build` (Angular's compiler) checks templates, so a passing `typecheck` does not replace the production build. ESLint and Prettier stay until a stable Bun release ships its native linter and formatter ([bun-adoption](docs/bun-adoption.md#status)).
|
||||||
|
|
||||||
## Performance benchmark
|
## Performance benchmark
|
||||||
|
|
||||||
Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, a loaded year, and two- and three-year horizons with dated annual balances. It reports preparation time, planning latency, planning-work time, total wall time through explanation delivery, UI timer delay, available main-thread heap measurements, outcome, last completed stage, placement count, and determinism.
|
Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, a loaded year, and two- and three-year horizons with dated annual balances. It reports preparation time, planning latency, planning-work time, total wall time through explanation delivery, UI timer delay, available main-thread heap measurements, outcome, last completed stage, placement count, and determinism.
|
||||||
|
|
||||||
The release protocol, recorded desktop measurements, and hardware limitations are documented in [docs/benchmark/README.md](docs/benchmark/README.md). No reference-phone run or multi-year duration ceiling is established; timing measurements are release evidence, not CI assertions.
|
The release protocol, recorded desktop measurements, and hardware limitations are documented in [docs/benchmark/README.md](docs/benchmark/README.md). No reference-phone run or individual multi-year duration ceiling is established. CI runs the complete seven-scenario protocol with `bun test` and requires each scenario to stay within its budget relative to a frozen-engine calibration, with a 600-second hang guard. Bun measurements do not establish browser or reference-phone speedups.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
@@ -233,15 +241,19 @@ docker build -t vac-optimizer .
|
|||||||
docker run --rm -p 4000:4000 vac-optimizer
|
docker run --rm -p 4000:4000 vac-optimizer
|
||||||
```
|
```
|
||||||
|
|
||||||
The image builds the Angular application with Bun and Node, then runs the generated Express server with Bun. Set `PORT` to override the default port `4000`.
|
The image builds the Angular application and the server bundle with Bun alone (no Node), then runs `dist/vac-optimizer/server/server.js` with Bun. The bundle has no runtime dependencies, so the production stage only copies `dist/` and installs nothing. Set `PORT` to override the default port `4000`; the health check requests `/`.
|
||||||
|
|
||||||
|
The server answers `GET` and `HEAD` only (other methods get 405). It serves files from `dist/vac-optimizer/browser` with `Cache-Control: public, max-age=31536000` (content-hashed assets), and returns `index.html` with `Cache-Control: no-cache` for `/index.html` and for every extension-less path, so direct navigation to client routes such as `/benchmark` works. A missing path with a file extension is a 404 rather than HTML, malformed or escaping paths are a 400, and unexpected failures are logged and answered with a plain 500.
|
||||||
|
|
||||||
## Continuous integration
|
## Continuous integration
|
||||||
|
|
||||||
[`.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.
|
[`.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.
|
||||||
|
|
||||||
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.
|
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, `bun check` type checks, Bun coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
|
||||||
|
|
||||||
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.
|
Jobs that run project commands install Bun 1.4.3 with `scripts/install-bun.sh` (a checksum-verified download, no npm) and run their steps with a `PATH` that holds Bun but no Node. The job containers keep Node only for Gitea's checkout and artifact actions, which are JavaScript actions the runner executes itself; see the [activation guide](docs/gitea-actions.md#runners-and-permissions).
|
||||||
|
|
||||||
|
Functional tests run with `bun test --isolate`; coverage is line-level only (Bun reports no branch or function data). The separate required benchmark job runs without coverage in a container limited to 3 GiB (JavaScriptCore has no heap-size flag, so this replaces the former 2 GiB Node heap cap) and keeps its 600-second hang guard, one warm-up and two measured repetitions of all seven scenarios. [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) restores this gate after a complete local pass in 298.6 seconds; see [the measurements and hardware limits](docs/benchmark/issue-78.md). Benchmark success is required for image publication and the aggregate `required` job. The narrow local timing margin still needs confirmation on the CI runner.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -278,7 +290,7 @@ The Gitea repository policy requires `develop` as the default branch and **Merge
|
|||||||
|
|
||||||
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.
|
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.
|
Run `bun scripts/ci-policy.test.mjs` and `bun 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
|
## Domain and design references
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,6 @@
|
|||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
],
|
||||||
"server": "src/main.server.ts",
|
|
||||||
"outputMode": "server",
|
|
||||||
"ssr": {
|
|
||||||
"entry": "src/server.ts"
|
|
||||||
},
|
|
||||||
"prerender": false,
|
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"deepmerge",
|
"deepmerge",
|
||||||
"moment-timezone",
|
"moment-timezone",
|
||||||
@@ -85,9 +79,6 @@
|
|||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular/build:extract-i18n"
|
"builder": "@angular/build:extract-i18n"
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"builder": "@analogjs/vitest-angular:test"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./src/test-setup.ts"]
|
||||||
|
root = "src"
|
||||||
|
coverage = true
|
||||||
|
coverageReporter = ["text", "lcov"]
|
||||||
|
coverageDir = "coverage"
|
||||||
|
coverageSkipTestFiles = true
|
||||||
|
coveragePathIgnorePatterns = ["**/frozen-engine/**", "**/testing/**", "**/test-setup.ts"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[run]
|
||||||
|
# Node-shebang tools (ng, eslint, prettier) run on Bun even when Node is on PATH.
|
||||||
|
bun = true
|
||||||
|
|
||||||
|
[test]
|
||||||
|
preload = ["./src/test-setup.ts"]
|
||||||
|
root = "src"
|
||||||
+1
-1
@@ -17,7 +17,7 @@ services:
|
|||||||
'CMD',
|
'CMD',
|
||||||
'bun',
|
'bun',
|
||||||
'-e',
|
'-e',
|
||||||
"require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})",
|
"fetch('http://localhost:' + (process.env.PORT || 4000) + '/').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))",
|
||||||
]
|
]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ services:
|
|||||||
'CMD',
|
'CMD',
|
||||||
'bun',
|
'bun',
|
||||||
'-e',
|
'-e',
|
||||||
"require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})",
|
"fetch('http://localhost:' + (process.env.PORT || 4000) + '/').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))",
|
||||||
]
|
]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
status: proposed
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bun Runtime and Native Tooling
|
||||||
|
|
||||||
|
VacOptimizer will use Bun as the runtime for project-owned JavaScript in
|
||||||
|
development, CI, and production, adopting its native test runner, linter,
|
||||||
|
formatter, type checker, and HTTP server even without measured performance gains.
|
||||||
|
This deliberately accepts Bun-specific tooling and migration work while retaining
|
||||||
|
Angular's standard compiler and build pipeline, including TypeScript 6 for
|
||||||
|
Angular compilation alongside `bun check` for standalone TypeScript checking.
|
||||||
|
|
||||||
|
Verification on stable Bun 1.4.3 narrowed this: ESLint and Prettier stay,
|
||||||
|
run by Bun, until a stable release ships the native linter and formatter;
|
||||||
|
Angular builds a client-only bundle, because its server build relies on Node
|
||||||
|
loader hooks Bun does not apply, and a small `Bun.serve` server delivers it,
|
||||||
|
giving up `@angular/ssr`; coverage is line-level, converted from lcov to the
|
||||||
|
existing HTML and Cobertura reports; and the benchmark's heap cap becomes a
|
||||||
|
container memory limit.
|
||||||
|
|
||||||
|
Existing behavior, tests, lint rules, coverage reporting, and benchmark budgets
|
||||||
|
must be preserved; incompatibilities must be fixed or reported as blockers before
|
||||||
|
declaring completion, and a Node.js fallback does not meet the runtime requirement.
|
||||||
|
The [development spec](../bun-adoption.md) records the agreed boundaries,
|
||||||
|
acceptance criteria, and remaining verification.
|
||||||
+147
-31
@@ -2,44 +2,160 @@
|
|||||||
|
|
||||||
## Composable optimization rules: current verification status
|
## Composable optimization rules: current verification status
|
||||||
|
|
||||||
The #70–#75 implementation uses explicit active optimization configurations in
|
[Issue #78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) passes the
|
||||||
the benchmark fixtures. Before trace removal, the full functional coverage run passed 652 tests
|
unchanged Node acceptance protocol on the documented development laptop:
|
||||||
across 34 files under the 2 GB Node heap limit, excluding the separate benchmark.
|
**298.6 seconds**, including one warm-up and two measured repetitions of all
|
||||||
That run does not establish a passing performance protocol.
|
seven scenarios, without coverage, with one worker and a 2 GiB Node heap.
|
||||||
|
The full protocol keeps a 600-second timeout only as a hang guard (see
|
||||||
|
[Relative CI gate](#relative-ci-gate)). All expected outcomes,
|
||||||
|
placement counts, final stages and determinism checks pass. The fixture inputs
|
||||||
|
are unchanged; the test now prints its existing report.
|
||||||
|
|
||||||
|
## Relative CI gate
|
||||||
|
|
||||||
|
The relative gate replaces the 300-second acceptance protocol named in
|
||||||
|
[#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) as the benchmark
|
||||||
|
policy. This is an explicit policy change from #279/#285: runners differ in
|
||||||
|
speed, so a fixed wall-clock limit was flaky.
|
||||||
|
|
||||||
|
Before the protocol, in the same process, the spec times the frozen engine
|
||||||
|
(`src/app/services/frozen-engine/leave-planner.core.frozen.mjs`, the engine at
|
||||||
|
a871f8f) on the `two years` scenario, best of three, and prints the result.
|
||||||
|
Each scenario's budget is its multiplier times that calibration; the mean
|
||||||
|
`planningMs` of its two measured repetitions must not exceed it. A scenario
|
||||||
|
without a multiplier fails too. `#286` tightens the multipliers once the speed
|
||||||
|
target is met.
|
||||||
|
|
||||||
|
The job log shows a Markdown table with one row per scenario, printed as soon
|
||||||
|
as that scenario finishes. Each row gives both measured planning times, their
|
||||||
|
mean, the budget, the ratio to the calibration, the multiplier, the outcome,
|
||||||
|
placements, determinism and `ok`, `OVER` or `no budget`. The complete
|
||||||
|
tab-separated report follows the table.
|
||||||
|
|
||||||
|
| Scenario | Mean planning (s) | Ratio to calibration | Multiplier |
|
||||||
|
| --------------------------- | ----------------- | -------------------- | ---------- |
|
||||||
|
| representative year | 0.40 | 0.15 | 0.25 |
|
||||||
|
| typed obligations | 5.02 | 1.83 | 3 |
|
||||||
|
| chained earned entitlements | 0.025 | 0.01 | 0.05 |
|
||||||
|
| deliberate conflict | 0.013 | 0.005 | 0.05 |
|
||||||
|
| loaded year | 44.3 | 16.2 | 25 |
|
||||||
|
| two years | 1.84 | 0.67 | 1 |
|
||||||
|
| three years | 5.94 | 2.17 | 3.5 |
|
||||||
|
|
||||||
|
Ratios come from the development machine (calibration about 2.74 s); the
|
||||||
|
multipliers add roughly 1.5x headroom, rounded up, with a floor of 0.05 for the
|
||||||
|
two tiny scenarios. A failure lists, per scenario, the measured mean, the
|
||||||
|
budget, the multiplier and the calibration in milliseconds.
|
||||||
|
|
||||||
|
The first CI runs of PR #287 (commit `63b50c4`) give consistent ratios on two
|
||||||
|
runners:
|
||||||
|
|
||||||
|
| Scenario | nessar-desktop | srvhome |
|
||||||
|
| --------------------------- | -------------- | ------- |
|
||||||
|
| calibration | 3.03 s | 5.71 s |
|
||||||
|
| representative year | 0.17 | 0.14 |
|
||||||
|
| typed obligations | 1.83 | 1.75 |
|
||||||
|
| chained earned entitlements | 0.011 | 0.005 |
|
||||||
|
| deliberate conflict | 0.006 | 0.003 |
|
||||||
|
| loaded year | 16.6 | killed |
|
||||||
|
| two years | 0.65 | killed |
|
||||||
|
| three years | 1.99 | killed |
|
||||||
|
|
||||||
|
srvhome is about 1.9 times slower than nessar-desktop, so its whole protocol
|
||||||
|
needs roughly 400 seconds. The original 300-second `timeout` killed it during
|
||||||
|
the loaded year, while every completed scenario was within budget. The CI
|
||||||
|
`timeout` is therefore 600 seconds and the test timeout 570 seconds. They are
|
||||||
|
only hang guards and do not judge speed; #286 lowers them once the speed
|
||||||
|
target is met.
|
||||||
|
|
||||||
|
The protocol now runs on Bun's test runner, outside coverage:
|
||||||
|
`timeout 600s bun test src/app/benchmark --timeout=600000`. JavaScriptCore has
|
||||||
|
no equivalent of Node's `--max-old-space-size`, so the CI benchmark job enforces
|
||||||
|
memory with a 3 GiB container limit instead of the former 2 GiB heap cap.
|
||||||
|
Scenarios, calibration, warm-up, repetitions, determinism checks and
|
||||||
|
multipliers are unchanged. One local run (Bun 1.4.3, Node unavailable) took
|
||||||
|
268 seconds and passed every scenario; the frozen-engine calibration was
|
||||||
|
3.79 s and the ratios were 0.14 (representative year), 1.43 (typed
|
||||||
|
obligations), 0.01 (chained earned entitlements), 0.00 (deliberate conflict),
|
||||||
|
17.50 (loaded year), 0.64 (two years) and 2.18 (three years). These are Bun
|
||||||
|
measurements; they do not establish browser or reference-phone speedups.
|
||||||
|
|
||||||
The post-removal functional integration checks, browser QA and unchanged-input
|
The post-removal functional integration checks, browser QA and unchanged-input
|
||||||
handoff for #78 are recorded in [the #77 verification report](../trace-free-planning-verification.md).
|
handoff for #78 are recorded in [the #77 verification report](../trace-free-planning-verification.md).
|
||||||
|
|
||||||
On 2026-10-15, the #76 worktree based on `ea42561` passed 630 functional tests
|
The benchmark is required again in Gitea Actions, including the image and
|
||||||
across 33 files with coverage under the 2 GB Node heap limit. Complete outcome
|
aggregate gates. [The #78 report](issue-78.md) records the baseline timeout,
|
||||||
comparisons against that baseline matched for all seven benchmark scenarios.
|
profiling, before/after diagnostics, full protocol output, correctness checks
|
||||||
The separate, unchanged benchmark did not complete within its 300-second limit
|
and browser limitations. That pass had only 1.4 seconds of external-timeout
|
||||||
(external timeout exit 124). Performance acceptance and any further optimization
|
headroom. Three later exact optimizations for PR #278 cut the Vitest duration
|
||||||
remain with #78.
|
to **228.1 seconds** (71.9 seconds of headroom) in the most recent local run:
|
||||||
|
[direct date keys](pr-278-date-keys.md),
|
||||||
|
[immutable date counts](pr-278-immutable-date-counts.md) and
|
||||||
|
[mandatory-move prechecks](pr-278-mandatory-moves.md). Outcomes, explanations
|
||||||
|
and final states are unchanged. CI-runner performance and live workflow
|
||||||
|
validation remain unverified.
|
||||||
|
The existing migration protection stays in place under
|
||||||
|
[issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268).
|
||||||
|
|
||||||
Before the Gitea migration, GitLab pipeline 320 passed all 652 functional tests
|
Before these optimizations, the trace-free integrated baseline at `2f4af14`
|
||||||
in 109.43 seconds, then the seven-scenario protocol timed out at its unchanged
|
timed out after 300 seconds without a complete report. The #76 verification
|
||||||
300-second limit without an out-of-memory failure. In an earlier diagnostic run, the loaded-year warm-up took
|
previously passed 630 functional tests across 33 files with coverage under the
|
||||||
184.36 seconds and its first measured repetition began about 251.78 seconds into
|
same heap setting, while its unchanged benchmark also timed out. Before the
|
||||||
the protocol. Those diagnostic timings precede the final period-projection cache
|
Gitea migration, GitLab pipeline 320 passed 652 functional tests, but its
|
||||||
and the original-decision-trace removal in #76, and are not a result for either.
|
separate benchmark timed out as well. An earlier loaded-year warm-up took
|
||||||
|
184.36 seconds; that historical sample predates the final period-projection
|
||||||
|
cache and trace removal and is not a measurement of the current engine.
|
||||||
|
|
||||||
At the user's request, #75 finishes on saved-rule migration and functional
|
#75 owns functional saved-rule migration, #76 removes tracing, #77 verifies
|
||||||
verification. #78 owns benchmark verification and implementation of further
|
retained behavior and #78 owns performance acceptance. No automatic elapsed-time
|
||||||
optimizations when needed, after trace removal in #76 and integrated functional verification in #77. All seven scenarios, one warm-up and two measured repetitions per
|
engine cutoff is introduced. No reference-phone run is claimed.
|
||||||
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
|
The measurements below describe earlier implementations and protocols. Their
|
||||||
historical measurements rather than the performance result for composable rules or trace removal. Their trace counts, timings and controls describe the former implementation only.
|
trace counters and timings are historical; current evidence is in
|
||||||
|
[the #78 report](issue-78.md).
|
||||||
|
|
||||||
|
## Frozen-engine differential check
|
||||||
|
|
||||||
|
`src/app/services/frozen-engine.spec.ts` runs 5,000 seeded random planning
|
||||||
|
requests (seeds 280000 to 284999, one test per 200 seeds) through the current
|
||||||
|
engine and through a frozen copy of the engine at `a871f8f`, develop after
|
||||||
|
PR #278. For each request, the planning outcome, stable plan explanation,
|
||||||
|
explanation error and final-state snapshot must be strictly deep-equal,
|
||||||
|
including `PlanningConflict` and `InvalidInput` outcomes. Timings are not
|
||||||
|
compared.
|
||||||
|
|
||||||
|
Start dates span 2025 to 2027. About 15% of requests start in late December and
|
||||||
|
cross a year boundary, and about 15% start shortly before a weekday French
|
||||||
|
public holiday. Most horizons last 7 to 28 days; about 10% last 29 to 70 days
|
||||||
|
with optimization presets and at most one calendar rule. Requests cover
|
||||||
|
obligation and preference rules, `IF_THEN` rules with earned leave
|
||||||
|
entitlements, `TIME_WINDOW` rules, optimization presets in random order and
|
||||||
|
subsets, random composed optimization configurations, disabled rules, declared
|
||||||
|
non-working days and a few deliberately invalid requests. The last test fails
|
||||||
|
if an outcome kind, rule type, year-crossing horizon or public-holiday horizon
|
||||||
|
stops appearing. Requests stay small because candidate search grows quickly
|
||||||
|
with horizon length and balances; the slowest case takes about one second.
|
||||||
|
|
||||||
|
The fixture is `src/app/services/frozen-engine/leave-planner.core.frozen.mjs`,
|
||||||
|
a generated bundle with `date-holidays` left external, and its hand-written
|
||||||
|
`leave-planner.core.frozen.d.mts`. Rebuild it only for an intended behavior
|
||||||
|
change, in its own reviewed commit, from the commit that introduces the change:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun build src/app/services/leave-planner.core.ts --target=node --external date-holidays --outfile src/app/services/frozen-engine/leave-planner.core.frozen.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
A failure prints the seed and the full request as JSON. To run only that seed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
FROZEN_ENGINE_SEED=<seed> bun test --isolate src/app/services/frozen-engine.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The check runs in the functional suite. On the development laptop with Bun
|
||||||
|
1.4.3 it takes about 54 seconds with or without coverage
|
||||||
|
(`bun test --config=bunfig.coverage.toml --isolate src/app/services/frozen-engine.spec.ts`; coverage is line-level only).
|
||||||
|
A flipped objective direction in `compareObjectiveValues` made 26 of its tests
|
||||||
|
fail, starting at seed 280002; the change was reverted.
|
||||||
|
|
||||||
## Reference-phone protocol
|
## Reference-phone protocol
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Issue #279: incremental exact scoring
|
||||||
|
|
||||||
|
Step-by-step record for [issue #279](https://gitea.nessar.fr/nessar/vac-optimizer/issues/279).
|
||||||
|
Each step keeps planning outcomes, stable explanations and final states identical
|
||||||
|
to develop after PR #278 (`a871f8f`). The
|
||||||
|
[frozen-engine differential check](README.md#frozen-engine-differential-check)
|
||||||
|
guards every step.
|
||||||
|
|
||||||
|
All measurements use Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the development
|
||||||
|
laptop, with no other test, build or profiling workload running alongside the
|
||||||
|
protocol. Only same-session before/after pairs are comparable.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
The per-scenario figures below are the means of the two measured calculations.
|
||||||
|
|
||||||
|
## Baseline profile
|
||||||
|
|
||||||
|
A CPU profile of the bundled core over all seven scenarios, with
|
||||||
|
`calculationDate: '2024-01-01'`, put 50.5 of 67.0 loaded-year seconds in
|
||||||
|
candidate evaluation (`evaluateRuleCandidate`). Of those, 41.3 seconds went to
|
||||||
|
active-objective evaluation: 15.6 seconds in time-off-period observations,
|
||||||
|
11.5 seconds in building observations and 7.6 seconds in sorting placement dates
|
||||||
|
for the placement tie key and the observation cache key. The asynchronous
|
||||||
|
adapter's 1 ms timer yields cost about 7% on typed obligations.
|
||||||
|
|
||||||
|
## #282: consolidate plan observations
|
||||||
|
|
||||||
|
Time-off periods, work sequences, public-holiday weeks, balance observations,
|
||||||
|
the placement list and both tie keys now live in
|
||||||
|
`src/app/services/plan-observations.ts`. The code moved without behavior
|
||||||
|
changes. All seven scenarios produce byte-identical outcomes, explanations and
|
||||||
|
final states compared with `a871f8f`.
|
||||||
|
|
||||||
|
| Measurement | Before | After |
|
||||||
|
| ------------------- | --------: | --------: |
|
||||||
|
| Vitest duration | 262.000 s | 262.030 s |
|
||||||
|
| Loaded year | 66.010 s | 66.497 s |
|
||||||
|
| Typed obligations | 4.887 s | 4.964 s |
|
||||||
|
| Two years | 2.914 s | 2.977 s |
|
||||||
|
| Three years | 10.203 s | 10.049 s |
|
||||||
|
| Representative year | 0.557 s | 0.569 s |
|
||||||
|
|
||||||
|
The differences are within run-to-run noise. The functional suite passes
|
||||||
|
666 tests across 34 files, including the frozen-engine check.
|
||||||
|
|
||||||
|
## #283: incremental plan observations
|
||||||
|
|
||||||
|
Each placements map now carries an immutable occupancy: its sorted placement
|
||||||
|
dates, time-off period groups, public-holiday-week completeness and lazily
|
||||||
|
computed calendar observations. A candidate derived from its parent by one added
|
||||||
|
or removed date, or by a copy with the same dates, updates only the affected
|
||||||
|
groups and holiday weeks. Unchanged groups keep their cached periods and
|
||||||
|
observation objects. Maps built from snapshots, maps observed against another
|
||||||
|
calendar and dates that are not in-horizon working days fall back to the
|
||||||
|
from-scratch computation.
|
||||||
|
|
||||||
|
The from-scratch computation remains the oracle. A fast-check property test
|
||||||
|
applies random add, remove and retype sequences across year boundaries, horizon
|
||||||
|
edges, declared days, weekends and public holidays, and compares every step with
|
||||||
|
it. An opt-in shadow mode (`VAC_OBSERVATION_SHADOW=1` or
|
||||||
|
`setObservationShadowMode(true)`) compares both on every observation and throws
|
||||||
|
on any mismatch. It passed across the frozen-engine check and all seven
|
||||||
|
benchmark scenarios, whose outcomes, explanations and final states stay
|
||||||
|
byte-identical.
|
||||||
|
|
||||||
|
| Measurement | Before | After |
|
||||||
|
| ------------------- | --------: | --------: |
|
||||||
|
| Vitest duration | 262.030 s | 193.680 s |
|
||||||
|
| Loaded year | 66.497 s | 48.489 s |
|
||||||
|
| Typed obligations | 4.964 s | 4.947 s |
|
||||||
|
| Two years | 2.977 s | 1.906 s |
|
||||||
|
| Three years | 10.049 s | 6.152 s |
|
||||||
|
| Representative year | 0.569 s | 0.441 s |
|
||||||
|
|
||||||
|
A fresh loaded-year CPU profile of the bundled core took 46.8 seconds. Of the
|
||||||
|
1.68 million observed placement maps, one was built from scratch; the rest were
|
||||||
|
derived. Observations now take about 4 seconds. Candidate evaluation still
|
||||||
|
dominates: 24.3 seconds in `evaluateRuleCandidate`, including 15.2 seconds of
|
||||||
|
active objectives and 6.9 seconds of candidate finalization, plus 6.2 seconds
|
||||||
|
assigning dates and about 5 seconds each of garbage collection and timer idle
|
||||||
|
time.
|
||||||
|
|
||||||
|
The functional suite passes 669 tests across 35 files.
|
||||||
|
|
||||||
|
## #284: prune candidates that cannot beat the protected prefix
|
||||||
|
|
||||||
|
Before #284, `evaluateRuleCandidate` finalized every candidate before comparing
|
||||||
|
its objectives. In the loaded year, 650,369 of 1,233,535 evaluations (53%) then
|
||||||
|
ended at a worse protected prefix. For typed obligations it was 74,336 of
|
||||||
|
273,470, and for three years 38,048 of 193,621.
|
||||||
|
|
||||||
|
Finalizing never moves or retypes a placement. It edits only rule
|
||||||
|
provenance, supporting status, earned activations and earned balances. Before
|
||||||
|
finalizing, the evaluation therefore walks the candidate's protected prefix in
|
||||||
|
objective order, using only objectives whose pre-finalize value is already their
|
||||||
|
exact final value, so the bound is tight:
|
||||||
|
|
||||||
|
- time-window and if-then preferences read only placement dates and types,
|
||||||
|
plus declared days;
|
||||||
|
- public-holiday-week, time-off-period and work-sequence optimizations read
|
||||||
|
only the placement dates;
|
||||||
|
- leave-balance optimizations are exact only when no enabled if-then rule earns
|
||||||
|
days and the candidate has no earned activation;
|
||||||
|
- leave-placement optimizations have no safe bound, because finalizing rewrites
|
||||||
|
supporting status.
|
||||||
|
|
||||||
|
The walk prunes at the first differing objective when it is worse than the
|
||||||
|
baseline. It stops without pruning at a better value or at an objective without
|
||||||
|
a safe bound. A pruned candidate would have failed finalization or returned at
|
||||||
|
the same comparison, and incumbents are retained only after the full objective
|
||||||
|
vector is built. Pruning therefore never changes which candidate wins or which
|
||||||
|
incumbent is retained.
|
||||||
|
|
||||||
|
The frozen-engine check now also runs every tenth seed through both engines'
|
||||||
|
asynchronous adapter, cancelled after a seed-derived number of steps. It
|
||||||
|
compares outcomes, artifacts, every checkpoint, the explanation and outcome
|
||||||
|
callbacks, and the step count. Of those 500 cases, 422 end incomplete and 217
|
||||||
|
of those keep an incumbent. Pruning on any differing objective breaks both
|
||||||
|
variants. Wrongly treating leave-placement or earning leave-balance objectives
|
||||||
|
as exact went undetected, because the generated requests rarely put those
|
||||||
|
objectives in a protected prefix, so their exclusion rests on the argument
|
||||||
|
above. The check now takes about 60 seconds with coverage.
|
||||||
|
|
||||||
|
| Measurement | Before | After |
|
||||||
|
| ------------------- | --------: | --------: |
|
||||||
|
| Vitest duration | 193.680 s | 180.250 s |
|
||||||
|
| Loaded year | 48.489 s | 44.287 s |
|
||||||
|
| Typed obligations | 4.947 s | 5.023 s |
|
||||||
|
| Two years | 1.906 s | 1.837 s |
|
||||||
|
| Three years | 6.152 s | 5.935 s |
|
||||||
|
| Representative year | 0.441 s | 0.400 s |
|
||||||
|
|
||||||
|
All seven scenarios keep byte-identical outcomes, explanations and final states.
|
||||||
|
The functional suite passes 684 tests across 35 files.
|
||||||
|
|
||||||
|
## #286: speed target not reached
|
||||||
|
|
||||||
|
The target is a loaded year under 10 seconds and every other scenario under 3
|
||||||
|
seconds, using the existing protocol in Node on the development laptop. The
|
||||||
|
exact optimizations of #282 to #284 do not reach it. Means from the latest
|
||||||
|
protocol run, with the #285 calibration of 2.854 seconds:
|
||||||
|
|
||||||
|
| Scenario | Target | Latest | Gap factor |
|
||||||
|
| --------------------------- | -----: | -------: | ---------: |
|
||||||
|
| Loaded year | < 10 s | 44.094 s | 4.4x |
|
||||||
|
| Typed obligations | < 3 s | 5.071 s | 1.7x |
|
||||||
|
| Three years | < 3 s | 5.887 s | 2.0x |
|
||||||
|
| Two years | < 3 s | 1.865 s | met |
|
||||||
|
| Representative year | < 3 s | 0.402 s | met |
|
||||||
|
| Chained earned entitlements | < 3 s | 0.025 s | met |
|
||||||
|
| Deliberate conflict | < 3 s | 0.013 s | met |
|
||||||
|
|
||||||
|
From develop after PR #278 to now, the loaded year went from 66.0 to 44.1
|
||||||
|
seconds (−33%), three years from 10.2 to 5.9 seconds and two years from 2.9 to
|
||||||
|
1.9 seconds. Typed obligations is unchanged at about 5 seconds. The protocol went
|
||||||
|
from 262.0 to 180.3 seconds.
|
||||||
|
|
||||||
|
Because the target is not met, the #285 multipliers stay at current speed and
|
||||||
|
the hang-guard timeout is not lowered. #286 remains open for a
|
||||||
|
separate decision on search changes.
|
||||||
|
|
||||||
|
### Remaining loaded-year profile
|
||||||
|
|
||||||
|
A fresh CPU profile of the bundled core at the #285 commit took 43.5 seconds,
|
||||||
|
with outcomes still byte-identical:
|
||||||
|
|
||||||
|
| Inclusive cost | Seconds |
|
||||||
|
| ----------------------------------------------------------------------- | ------: |
|
||||||
|
| Candidate evaluation (`evaluateRuleCandidate`) | 21.4 |
|
||||||
|
| — active objectives, including the pruning walk | 16.7 |
|
||||||
|
| — of which objective evaluation (`evaluateOptimization`) | 7.7 |
|
||||||
|
| — of which plan observations | 4.2 |
|
||||||
|
| — candidate finalization | 2.7 |
|
||||||
|
| Date assignment and release (`assignDateToBalance`, `releasePlacement`) | 8.1 |
|
||||||
|
| Garbage collection | 5.1 |
|
||||||
|
| Idle time between asynchronous slices | 4.6 |
|
||||||
|
|
||||||
|
The remaining cost is spread over about 1.2 million candidate evaluations
|
||||||
|
rather than concentrated in one computation. Further exact work could:
|
||||||
|
|
||||||
|
- yield with something cheaper than a 1 ms timer between 8 ms slices, saving up
|
||||||
|
to the 4.6 seconds of idle time;
|
||||||
|
- validate optimization configurations once instead of on every evaluation,
|
||||||
|
about 1 second;
|
||||||
|
- copy fewer balances and earned activations when assigning a date.
|
||||||
|
|
||||||
|
Together these are worth perhaps 8 to 10 seconds, still well above 10 seconds
|
||||||
|
for the loaded year. Reaching the target needs fewer candidate evaluations,
|
||||||
|
which is a search change outside #279's exactness constraint.
|
||||||
|
|
||||||
|
### ADR 0003
|
||||||
|
|
||||||
|
ADR 0003 targets five seconds for the dependency-free planner. Under composable
|
||||||
|
optimization rules, the loaded year takes about 44 seconds in Node on the
|
||||||
|
development laptop even after these exact optimizations. That target should be
|
||||||
|
revisited for the loaded year, together with the decision on search changes.
|
||||||
|
This record does not revise the ADR.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
scenario repetition preparation_ms planning_ms wall_ms outcome last_stage placements deterministic max_ui_delay_ms planning_work_ms peak_main_thread_heap_bytes
|
||||||
|
representative year 1 0.1 564.4 564.6 Plan dates 30 baseline 8.0 552.6 ""
|
||||||
|
representative year 2 0.0 555.0 555.1 Plan dates 30 yes 8.1 544.4 ""
|
||||||
|
typed obligations 1 0.1 5040.7 5040.8 Plan dates 10 baseline 11.3 5030.5 ""
|
||||||
|
typed obligations 2 0.1 5141.2 5141.3 Plan dates 10 yes 12.9 5131.1 ""
|
||||||
|
chained earned entitlements 1 0.1 29.0 29.1 Plan dates 3 baseline 0.0 21.6 ""
|
||||||
|
chained earned entitlements 2 0.1 24.7 24.8 Plan dates 3 yes 0.0 17.4 ""
|
||||||
|
deliberate conflict 1 0.1 13.0 13.2 PlanningConflict 0 baseline 0.0 3.1 ""
|
||||||
|
deliberate conflict 2 0.0 12.6 12.7 PlanningConflict 0 yes 0.0 2.8 ""
|
||||||
|
loaded year 1 0.1 76341.7 76341.8 Plan dates 72 baseline 16.8 76326.9 ""
|
||||||
|
loaded year 2 0.1 79093.8 79094.0 Plan dates 72 yes 16.3 79074.9 ""
|
||||||
|
two years 1 0.1 3391.7 3391.9 Plan dates 60 baseline 12.9 3375.3 ""
|
||||||
|
two years 2 0.1 3282.6 3282.8 Plan dates 60 yes 10.9 3267.1 ""
|
||||||
|
three years 1 0.1 11333.3 11333.5 Plan dates 90 baseline 13.9 11313.4 ""
|
||||||
|
three years 2 0.1 11394.2 11394.3 Plan dates 90 yes 15.7 11368.4 ""
|
||||||
|
@@ -0,0 +1,345 @@
|
|||||||
|
# Issue #78: integrated planner performance
|
||||||
|
|
||||||
|
Measurements on 2026-10-15/16 (Europe/Paris), starting from the trace-free
|
||||||
|
integrated planner at `2f4af14869c47836d2c1008157136f75c9d3e98b`.
|
||||||
|
[Issue #78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) supplies the
|
||||||
|
acceptance criteria. The original spec, baseline source, profiles, diagnostic
|
||||||
|
runners and complete local logs are retained in `/tmp/vac-issue-78`.
|
||||||
|
|
||||||
|
## Required protocol
|
||||||
|
|
||||||
|
```sh
|
||||||
|
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
| Run | Result |
|
||||||
|
| --------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| Unmodified baseline | External timeout, exit 124, 300.008 seconds; no complete report. |
|
||||||
|
| Optimized planner | Exit 0, 298.551 seconds externally; Vitest reports 294.16 seconds, with 292.958 seconds in the test. |
|
||||||
|
| Restart-interrupted optimized run | Stopped by the agent-server restart before a result; excluded from acceptance and timing comparisons. |
|
||||||
|
|
||||||
|
The passing run includes the existing typed-obligation precheck, all seven
|
||||||
|
warm-ups, and two measured repetitions of every scenario. Coverage is disabled,
|
||||||
|
the Node heap is limited to 2048 MiB and the full command retains its 300-second
|
||||||
|
timeout. No other build, test or profiling workload ran alongside it.
|
||||||
|
|
||||||
|
[The complete 14-row report](issue-78-protocol.tsv) preserves every measured row.
|
||||||
|
Every repetition has its expected outcome, placement count and final stage;
|
||||||
|
each second repetition matches its first complete outcome. The deliberate
|
||||||
|
conflict remains a conflict with zero placements. The other scenarios complete
|
||||||
|
through `dates`, with 30, 10, 3, 72, 60 and 90 placements respectively.
|
||||||
|
|
||||||
|
The fixture module is byte-for-byte unchanged, SHA-256
|
||||||
|
`16e057fa042c898d203f71e018f183c9fbecb5cd0edbf0ce0f3f25277cb00d3a`.
|
||||||
|
The test change only prints its existing report; all correctness assertions,
|
||||||
|
configurations, quotas, horizons, warm-ups and repetition counts remain intact.
|
||||||
|
There are no trace counters, transfers or acknowledgement waits.
|
||||||
|
|
||||||
|
The passing command has only **1.449 seconds of external-timeout headroom**.
|
||||||
|
This is a local pass, not a runner-speed guarantee. Gitea Actions now requires
|
||||||
|
benchmark success before image publication and the aggregate `required` check.
|
||||||
|
A successful live PR run, publication setup and migration-gate activation remain
|
||||||
|
with [#268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268). No issue is
|
||||||
|
closed by this local verification.
|
||||||
|
|
||||||
|
## Environment and memory
|
||||||
|
|
||||||
|
AMD Ryzen 5 7640U, 12 logical CPUs, approximately 16 GiB RAM; Linux
|
||||||
|
`6.18.33.2-microsoft-standard-WSL2`. Node 24.18.0, Bun 1.3.14, Vitest 5.0.3,
|
||||||
|
TypeScript 6.0.3, Angular core 22.2.1 and date-holidays 3.30.2. The calculation
|
||||||
|
calendar date is fixed to `2024-01-01`, matching the benchmark test. Node runs
|
||||||
|
are unthrottled. These versions differ from the configured CI container's
|
||||||
|
Node 24.21.0 and Bun 1.4.2; live CI performance has not been measured here.
|
||||||
|
|
||||||
|
The passing protocol's sampled peak process-tree RSS is **1,528,056 KiB**,
|
||||||
|
sampled every 500 ms across the timeout/Bun/Vitest process tree. RSS includes
|
||||||
|
multiple processes and runtime overhead; it is not a JS-heap measurement.
|
||||||
|
Chromium's `performance.memory` is unavailable in the Node protocol, so its
|
||||||
|
`peak_main_thread_heap_bytes` cells remain blank.
|
||||||
|
|
||||||
|
## Profile and changes
|
||||||
|
|
||||||
|
A separate, explicitly interrupted 20-second loaded-year diagnostic used Node's
|
||||||
|
inspector CPU and allocation sampling. Candidate finalization, state copying,
|
||||||
|
calendar observation construction, period bounds and work-sequence scanning
|
||||||
|
were substantial costs. Allocation sampling included collected objects; its
|
||||||
|
cumulative allocation estimates are not peak live memory or protocol timings.
|
||||||
|
The diagnostic retained a valid 72-placement partial plan on cancellation.
|
||||||
|
|
||||||
|
A separate 20-second repeated-work probe observed 29 preference-pass executions
|
||||||
|
and no identical input-state revisit among cache misses. Complete obligation
|
||||||
|
previews remain in place; there is no new state-equivalence or search-budget
|
||||||
|
shortcut.
|
||||||
|
|
||||||
|
The change addresses those measured paths:
|
||||||
|
|
||||||
|
- Evaluate objectives in their actual priority order. Once a protected prefix
|
||||||
|
loses, or an admissible candidate loses to the current best candidate, lower
|
||||||
|
objective values cannot reverse that lexicographic comparison. Full vectors
|
||||||
|
remain available for retained improvements, displayed explanations and date
|
||||||
|
analysis; tied-prefix additions keep their original retention behavior.
|
||||||
|
- Build cached calendar observations only when an evaluated objective needs
|
||||||
|
them. The existing request-local, 4096-entry geometry cache retains its
|
||||||
|
boundary; balance quantities, provenance and supporting status remain outside
|
||||||
|
it. Work-sequence lengths use the prepared working-day prefixes and period
|
||||||
|
anchors instead of rebuilding every sequence's date array for scoring.
|
||||||
|
- Share existing placements for reading during a single-date assignment, while
|
||||||
|
continuing to copy balances and earned activations. Bundle and relocation
|
||||||
|
paths detach placements before changing supporting status. Snapshot,
|
||||||
|
provenance and other mutable-copy paths retain their deep copies.
|
||||||
|
- Reuse calendar-request validation during finalization instead of looking up
|
||||||
|
and scanning the same provenance again for each placement.
|
||||||
|
|
||||||
|
No candidate destinations, allocation choices, obligation previews or rule
|
||||||
|
priorities are removed. Search limits, earned-entitlement chronology, final
|
||||||
|
quality criteria and deterministic tie-breaks are unchanged. No elapsed-time
|
||||||
|
engine cutoff is introduced.
|
||||||
|
|
||||||
|
## Differential and diagnostic measurements
|
||||||
|
|
||||||
|
Complete outcomes, stable explanations and final-state snapshots are compared
|
||||||
|
with the saved baseline. The configuration matrix contains 182 cases: supported
|
||||||
|
conditions, aggregations and directions; personal objectives before and after
|
||||||
|
loss prevention; typed obligations and preferences; declarations; at-risk and
|
||||||
|
deferred balances; custom deficit thresholds; and empty/disabled rule lists.
|
||||||
|
Three permanent regression cases also pin reordered and custom objective
|
||||||
|
vectors, placement dates, provenance and first decisive objectives.
|
||||||
|
|
||||||
|
All seven completed diagnostic comparisons also match in full, including the
|
||||||
|
loaded year and both longer horizons. Table cells show **before → after**.
|
||||||
|
|
||||||
|
| Scenario | Preparation (ms) | Planning work (s) | Wall through explanation (s) | Maximum timer delay (ms) | Sampled Node heap (MiB) |
|
||||||
|
| --------------------------- | ---------------- | ----------------- | ---------------------------- | ------------------------ | ----------------------- |
|
||||||
|
| representative year | 39.7 → 37.5 | 1.261 → 0.787 | 1.302 → 0.826 | 7.7 → 8.5 | 76.5 → 90.8 |
|
||||||
|
| typed obligations | 10.9 → 9.4 | 8.563 → 5.642 | 8.574 → 5.652 | 7.8 → 11.2 | 127.7 → 115.1 |
|
||||||
|
| chained earned entitlements | 5.4 → 6.7 | 0.025 → 0.026 | 0.030 → 0.033 | 0.0 → 0.0 | — → — |
|
||||||
|
| deliberate conflict | 10.1 → 9.5 | 0.004 → 0.004 | 0.013 → 0.013 | 0.0 → 0.0 | — → — |
|
||||||
|
| loaded year | 8.8 → 9.1 | 143.648 → 72.029 | 143.664 → 72.043 | 14.6 → 16.2 | 206.1 → 177.3 |
|
||||||
|
| two years | 17.6 → 12.3 | 5.470 → 2.962 | 5.491 → 2.977 | 13.3 → 11.0 | 186.9 → 164.8 |
|
||||||
|
| three years | 16.4 → 14.4 | 19.674 → 10.287 | 19.696 → 10.310 | 15.8 → 16.1 | 289.2 → 253.5 |
|
||||||
|
|
||||||
|
The cold diagnostics run each unchanged scenario once, without warm-up or
|
||||||
|
coverage, through `calculatePlanningAsyncInternal`, with the same 2048 MiB heap
|
||||||
|
and calculation date. They are separate from the acceptance protocol. Occasional
|
||||||
|
focused/static checks ran during the baseline investigation; those diagnostic
|
||||||
|
timings are informational, not an isolated speedup estimate. The final diagnostic
|
||||||
|
run has no competing build/test workload.
|
||||||
|
|
||||||
|
Diagnostic preparation ends at the first non-preparation progress callback;
|
||||||
|
planning work comes from the core's timing artifact; wall time ends after
|
||||||
|
explanation delivery. In the protocol TSV, `preparation_ms` instead measures
|
||||||
|
request cloning, while calendar preparation is included in `planning_ms`.
|
||||||
|
|
||||||
|
Diagnostic timer delay and `process.memoryUsage().heapUsed` are sampled every
|
||||||
|
50 ms. Heap is the whole Node process, including earlier scenarios' live data
|
||||||
|
and caches. A dash means no interval sample was taken before a fast scenario
|
||||||
|
finished. These are neither allocation totals nor worker-isolated heap claims.
|
||||||
|
|
||||||
|
## Native browser checks
|
||||||
|
|
||||||
|
Production build, native Chrome 150.0.7871.186 Workers, 375×915 viewport,
|
||||||
|
device scale 1, reduced motion and 4× CPU throttling on the laptop above.
|
||||||
|
Each completion uses a new page and worker in one isolated Chrome process,
|
||||||
|
without a planner warm-up; browser/resource caches are shared between pages.
|
||||||
|
The cancellation checks use a separate isolated Chrome process. No other
|
||||||
|
build, test or profiler runs alongside these measurements.
|
||||||
|
|
||||||
|
| Scenario | Outcome usable (s) | Calendar ready (s) | Full artifact delivery (s) | Maximum page timer delay (ms) | Peak page heap (MiB) | Chrome RSS before / after outcome (MiB) |
|
||||||
|
| ------------------- | ------------------ | ------------------ | -------------------------- | ----------------------------- | -------------------- | --------------------------------------- |
|
||||||
|
| representative year | 2.070 | 2.574 | 2.158 | 886.5 | 22.8 | 1482.5 / 1488.4 |
|
||||||
|
| two years | 5.864 | 6.879 | 6.878 | 1287.9 | 22.8 | 1538.1 / 1540.3 |
|
||||||
|
| three years | 15.865 | 17.864 | 17.861 | 1987.2 | 21.1 | 1503.2 / 1506.8 |
|
||||||
|
|
||||||
|
Every completion returns `Plan` through `dates`, with 30, 60 or 90 placements,
|
||||||
|
its stable explanation and one planning request. Worker heartbeats continue
|
||||||
|
through the long calculations. There are no unexpected page exceptions or
|
||||||
|
page-level horizontal overflow at the measured viewport.
|
||||||
|
|
||||||
|
During the three-year worker calculation, keyboard editing changed the first
|
||||||
|
visible balance from 20 to 99. The captured request remained at 20, the plan
|
||||||
|
still contained 90 placements, and no second calculation was sent. Its displayed
|
||||||
|
estimate therefore followed the pending edited configuration (22–136 seconds).
|
||||||
|
The unchanged 90-day inputs in the cancellation runs retain their 11–68 second
|
||||||
|
range; cancelled outcomes add no completion calibration sample. The existing
|
||||||
|
session calibration and request-to-rendered-calendar contract are unchanged;
|
||||||
|
these samples do not justify changing the rough initial estimate.
|
||||||
|
|
||||||
|
Page heap is Chromium's main-thread `performance.memory` sample every 50 ms;
|
||||||
|
it excludes the worker heap. Chrome RSS sums the isolated browser process tree
|
||||||
|
at 500 ms intervals and outcome/artifact boundaries, including workers,
|
||||||
|
renderers and shared pages counted in each process's RSS. It is not a unique
|
||||||
|
physical-memory or worker-heap measurement. The post-outcome samples extend
|
||||||
|
through calendar readiness and artifact delivery. Calendar readiness is
|
||||||
|
observed by a 50 ms page timer after the loading overlay disappears, rather
|
||||||
|
than a physical paint measurement.
|
||||||
|
|
||||||
|
The observed page timer delays include calendar rendering and reach about
|
||||||
|
**2.0 seconds**. Inputs accept edits during worker planning; final calendar
|
||||||
|
rendering remains a visible main-thread pause.
|
||||||
|
|
||||||
|
Both cancellation flows activate Cancel with Enter and send one cancel message,
|
||||||
|
with no additional planning request:
|
||||||
|
|
||||||
|
| Cancellation point | Cancel to outcome (ms) | Cancel to artifacts (ms) | Retained placements |
|
||||||
|
| ---------------------------------------------------------- | ---------------------- | ------------------------ | ------------------- |
|
||||||
|
| Before a valid plan, controlled startup | 61.3 | 155.0 | 0 |
|
||||||
|
| Active optimization, after a valid 90-placement checkpoint | 20.6 | 1979.4 | 90 |
|
||||||
|
|
||||||
|
The preparation probe queues outgoing worker messages until the keyboard action,
|
||||||
|
then releases the plan and cancel in order. It verifies cancellation before any
|
||||||
|
valid plan, rather than ordinary startup-latency performance. It reports
|
||||||
|
`PlanningIncomplete` without an incumbent or a false conflict. The active
|
||||||
|
cancellation displays **Calculation cancelled — partial plan**, retains all 90
|
||||||
|
placements and delivers its partial-plan explanation. The first keyboard probe
|
||||||
|
omitted the Enter character event and issued no cancel; it was corrected and
|
||||||
|
excluded from cancellation evidence.
|
||||||
|
|
||||||
|
No physical reference-phone run is available. Desktop CPU throttling does not
|
||||||
|
establish phone performance or a duration ceiling. The browser reference
|
||||||
|
protocol still uses three measured repetitions; the required Node protocol uses
|
||||||
|
two. These counts are unchanged.
|
||||||
|
|
||||||
|
## Correctness, static checks and build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
|
||||||
|
bunx tsc --noEmit -p tsconfig.app.json
|
||||||
|
bunx tsc --noEmit -p tsconfig.spec.json
|
||||||
|
bun run lint
|
||||||
|
bun run format:check
|
||||||
|
bunx prettier --check 'scripts/ci-*.mjs' '.gitea/workflows/*.yml'
|
||||||
|
node scripts/ci-policy.test.mjs
|
||||||
|
node scripts/ci-image.test.mjs
|
||||||
|
bun run build
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
All pass. Functional coverage: **633 tests across 33 files**, 93.61% statements,
|
||||||
|
85.99% branches, 95.70% functions and 95.44% lines, in 92.05 seconds with the
|
||||||
|
existing 2 GiB Node heap. The separate benchmark also passes, as recorded above.
|
||||||
|
Coverage settings and thresholds are unchanged. The source changes were also
|
||||||
|
checked with the 266-test focused planner suite during implementation.
|
||||||
|
|
||||||
|
A local probe runs the actual aggregate shell body for 12 combinations of
|
||||||
|
benchmark and image outcomes. Failed, cancelled and skipped benchmarks block
|
||||||
|
it; successful benchmarks still preserve the intentional fork/manual image
|
||||||
|
skip. Both `images` and `required` depend on `benchmark`. The aggregate's first
|
||||||
|
failed check exits explicitly, without depending on the runner's shell flags.
|
||||||
|
|
||||||
|
The production build retains the existing warning budgets: initial bundle
|
||||||
|
2.57 MB against 2.50 MB, and calendar-year styles 12.98 kB against 8 kB. Both
|
||||||
|
remain below their unchanged error limits. Sandbox restrictions prevented the
|
||||||
|
first build/policy attempt; reruns with local socket/child-process permissions
|
||||||
|
passed. Semgrep and Trivy are not installed locally, so their security jobs,
|
||||||
|
actual image publication and a live Gitea PR run remain unverified.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
Use the recorded Node/Bun versions and frozen lockfile. Run the exact acceptance
|
||||||
|
command above from the integrated baseline checkout for the before measurement,
|
||||||
|
and from this working result for the after measurement. The test prints the
|
||||||
|
entire TSV report, including failures of its unchanged assertions.
|
||||||
|
|
||||||
|
For the separate cold diagnostics, `measure-core.mjs` runs the bundled baseline
|
||||||
|
or optimized core over the unchanged `benchmarkScenarios()` JSON in fixture
|
||||||
|
order. Generate that JSON in a separate process so the measured Node process
|
||||||
|
does not include Angular fixture-export overhead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p tmp /tmp/vac-issue-78
|
||||||
|
cat > tmp/issue78-fixtures.ts <<'TS'
|
||||||
|
import '@angular/compiler';
|
||||||
|
import { benchmarkScenarios } from '../src/app/benchmark/leave-planner-benchmark';
|
||||||
|
console.log(JSON.stringify(benchmarkScenarios()));
|
||||||
|
TS
|
||||||
|
bun build tmp/issue78-fixtures.ts --target=node --outfile /tmp/vac-issue-78/fixtures.mjs
|
||||||
|
node /tmp/vac-issue-78/fixtures.mjs > /tmp/vac-issue-78/scenarios.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The diagnostic runner supplies `calculationDate: '2024-01-01'`, captures
|
||||||
|
outcome/explanation/final-state callbacks, observes preparation progress and
|
||||||
|
samples timer delay and process heap every 50 ms:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun build src/app/services/leave-planner.core.ts --target=node --outfile /tmp/vac-issue-78/optimized-core.mjs
|
||||||
|
node --max-old-space-size=2048 /tmp/vac-issue-78/measure-core.mjs optimized
|
||||||
|
node --max-old-space-size=2048 /tmp/vac-issue-78/compare-configurations.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The baseline bundle and complete requests, results and profiling sources are
|
||||||
|
kept beside those runners. CPU/allocation profiling and the cancelled repeated-
|
||||||
|
work probe are diagnostics only; neither counts as a completed benchmark run.
|
||||||
|
|
||||||
|
Save the following as `/tmp/vac-issue-78/measure-core.mjs` to recreate the cold
|
||||||
|
measurements and complete comparison snapshots. Build `baseline-core.mjs` from
|
||||||
|
the recorded baseline with the same `bun build` command, then run with
|
||||||
|
`baseline`; use `optimized` for this implementation.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
const engine = process.argv[2] || 'baseline';
|
||||||
|
const { calculatePlanningAsyncInternal } = await import(`./${engine}-core.mjs`);
|
||||||
|
const directory = new URL(`./${engine}-measurements/`, import.meta.url);
|
||||||
|
mkdirSync(directory, { recursive: true });
|
||||||
|
const scenarios = JSON.parse(readFileSync(new URL('./scenarios.json', import.meta.url)));
|
||||||
|
for (const scenario of scenarios.filter(
|
||||||
|
(item) => !process.argv[3] || item.name === process.argv[3],
|
||||||
|
)) {
|
||||||
|
let artifacts;
|
||||||
|
let outcomeAt;
|
||||||
|
let preparationMs;
|
||||||
|
let peakHeapUsedBytes = 0;
|
||||||
|
let maxTimerDelayMs = 0;
|
||||||
|
const start = performance.now();
|
||||||
|
let lastTick = start;
|
||||||
|
const sample = setInterval(() => {
|
||||||
|
const tick = performance.now();
|
||||||
|
maxTimerDelayMs = Math.max(maxTimerDelayMs, tick - lastTick - 50);
|
||||||
|
lastTick = tick;
|
||||||
|
peakHeapUsedBytes = Math.max(peakHeapUsedBytes, process.memoryUsage().heapUsed);
|
||||||
|
}, 50);
|
||||||
|
const outcome = await calculatePlanningAsyncInternal(
|
||||||
|
{
|
||||||
|
...scenario.request,
|
||||||
|
calculationDate: '2024-01-01',
|
||||||
|
progress: (stage) => {
|
||||||
|
if (stage !== 'preparation') preparationMs ??= performance.now() - start;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(value) => {
|
||||||
|
artifacts = value;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
outcomeAt = performance.now() - start;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
clearInterval(sample);
|
||||||
|
const measurement = {
|
||||||
|
scenario: scenario.name,
|
||||||
|
diagnostic: true,
|
||||||
|
warmups: 0,
|
||||||
|
repetitions: 1,
|
||||||
|
preparationMs,
|
||||||
|
outcomeMs: outcomeAt,
|
||||||
|
planningWorkMs: artifacts.timings?.planningWorkMs,
|
||||||
|
wallMs: performance.now() - start,
|
||||||
|
maxTimerDelayMs,
|
||||||
|
peakHeapUsedBytes,
|
||||||
|
peakRssKiB: process.resourceUsage().maxRSS,
|
||||||
|
outcome: outcome.kind,
|
||||||
|
placements: outcome.placements?.length,
|
||||||
|
lastStage: outcome.lastCompletedStage,
|
||||||
|
};
|
||||||
|
writeFileSync(
|
||||||
|
new URL(scenario.name.replaceAll(' ', '-') + '.json', directory),
|
||||||
|
JSON.stringify({
|
||||||
|
measurement,
|
||||||
|
outcome,
|
||||||
|
explanation: artifacts.explanation,
|
||||||
|
finalState: artifacts.finalState,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log(JSON.stringify(measurement));
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# PR #278: build placement date keys directly
|
||||||
|
|
||||||
|
Measured on 2026-10-16, starting from PR #278 at `cbde23f`.
|
||||||
|
|
||||||
|
The unchanged benchmark finished in 299.372 seconds before this change, leaving
|
||||||
|
only 0.628 seconds before its 300-second timeout. A separate loaded-year CPU
|
||||||
|
profile attributed 8.27 of 66.57 seconds to `placementTieKey`, including its
|
||||||
|
callees. That function runs when looking up cached calendar observations and
|
||||||
|
when comparing final placement dates.
|
||||||
|
|
||||||
|
The one-line optimization sorts the placement map's date keys directly, using
|
||||||
|
native string sorting. It removes the intermediate placement-to-date array and
|
||||||
|
the comparison callback. Every placement map entry is keyed by its placement's
|
||||||
|
date, and native string sorting preserves the existing lexical date order, so
|
||||||
|
the resulting keys are identical. Search choices and scoring are unchanged.
|
||||||
|
|
||||||
|
## Before and after
|
||||||
|
|
||||||
|
Both runs used Node 24.18.0, Bun 1.3.14, Vitest 5.0.3, the same local machine,
|
||||||
|
and the exact command below. No other test, build or profiling workload ran
|
||||||
|
alongside either measurement.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
All seven warm-ups, fourteen measured calculations, the typed-obligation precheck,
|
||||||
|
fixtures and assertions remain unchanged. Coverage is disabled for this command.
|
||||||
|
|
||||||
|
| Measurement | Before | After |
|
||||||
|
| ---------------------------------------------- | --------: | --------: |
|
||||||
|
| Entire command, external wall time | 299.372 s | 268.573 s |
|
||||||
|
| Benchmark test | 293.620 s | 263.265 s |
|
||||||
|
| Loaded year, mean of two measured calculations | 76.649 s | 67.747 s |
|
||||||
|
| Headroom before timeout | 0.628 s | 31.427 s |
|
||||||
|
|
||||||
|
The entire command was 10.3% faster in this before-and-after pair. Both runs
|
||||||
|
completed successfully, and every repeated scenario remained deterministic.
|
||||||
|
These are local measurements; Gitea runner performance still needs confirmation.
|
||||||
|
|
||||||
|
## Correctness checks
|
||||||
|
|
||||||
|
Complete outcomes, stable explanations and final-state snapshots match the
|
||||||
|
baseline for all seven scenarios. These separate comparisons ran alongside
|
||||||
|
functional checks; their diagnostic timings are not used in the table above.
|
||||||
|
|
||||||
|
The functional coverage suite passes all 635 tests across 33 files. Application
|
||||||
|
and spec type checks, lint, source formatting and `git diff --check` also pass.
|
||||||
|
No browser timing run or live CI rerun was performed for this change.
|
||||||
|
|
||||||
|
The profiling runner and full protocol logs are retained in
|
||||||
|
`/tmp/vac-opt278-measure`. The original diagnostic reproduction is documented in
|
||||||
|
[issue-78.md](issue-78.md#reproduction).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# PR #278: reuse counts for immutable calendar dates
|
||||||
|
|
||||||
|
Measured on 2026-10-16 against `c7e7abc`, which already includes the
|
||||||
|
[direct date-key optimization](pr-278-date-keys.md).
|
||||||
|
|
||||||
|
A loaded-year CPU profile attributed 4.06 of 69.37 seconds to repeatedly counting
|
||||||
|
distinct non-working dates for time-off-period objectives. The existing calendar
|
||||||
|
cache already shares frozen date arrays between candidate plans, but each score
|
||||||
|
rebuilt a `Set` to count the same dates again.
|
||||||
|
|
||||||
|
The objective evaluator now keeps those counts in a `WeakMap` keyed by the frozen
|
||||||
|
array. Mutable arrays are recounted on every evaluation. The weak keys allow
|
||||||
|
unused arrays to be collected, and configuration validation and duplicate-date
|
||||||
|
handling remain intact. This change touches only that scoring operation.
|
||||||
|
|
||||||
|
## Before and after
|
||||||
|
|
||||||
|
Both protocol runs used Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the same local
|
||||||
|
machine. The baseline is the completed run recorded for `c7e7abc`; its saved core
|
||||||
|
bundle is byte-for-byte identical to a fresh bundle of that commit. No other
|
||||||
|
test, build or profiling workload ran alongside either protocol measurement.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
All seven warm-ups, fourteen measured calculations, the typed-obligation precheck,
|
||||||
|
fixtures, assertions and resource limits remain unchanged.
|
||||||
|
|
||||||
|
| Measurement | Before | After |
|
||||||
|
| ---------------------------------------------- | --------: | --------: |
|
||||||
|
| Entire command, external wall time | 268.573 s | 256.034 s |
|
||||||
|
| Benchmark test | 263.265 s | 250.652 s |
|
||||||
|
| Loaded year, mean of two measured calculations | 67.747 s | 64.360 s |
|
||||||
|
| Headroom before timeout | 31.427 s | 43.966 s |
|
||||||
|
|
||||||
|
The full command was 4.7% faster in this pair. Both runs completed successfully,
|
||||||
|
and every repeated scenario remained deterministic. Live CI performance still
|
||||||
|
needs confirmation.
|
||||||
|
|
||||||
|
An isolated check using the loaded-year plan's frozen date arrays took
|
||||||
|
551–554 ms before and 46–57 ms after for 100,000 scoring calls, following 5,000
|
||||||
|
warm-ups per run in before/after/after/before order. That result isolates the repeated
|
||||||
|
counting cost; the protocol above measures its effect on complete calculations.
|
||||||
|
|
||||||
|
## Correctness checks
|
||||||
|
|
||||||
|
Complete outcomes, stable explanations and final-state snapshots match `c7e7abc`
|
||||||
|
for all seven scenarios. These separate comparisons ran alongside functional
|
||||||
|
checks; their diagnostic timings are not used in the protocol comparison.
|
||||||
|
|
||||||
|
The functional coverage suite passes all 638 tests across 33 files. Three new
|
||||||
|
regression cases cover duplicate dates and changes to mutable arrays alongside
|
||||||
|
frozen arrays for sum, count and maximum scores. Application and spec type checks,
|
||||||
|
lint, formatting and `git diff --check` also pass.
|
||||||
|
|
||||||
|
No browser timing run or live CI rerun was performed for this change.
|
||||||
|
|
||||||
|
Diagnostic sources, profiles, bundles and full logs are retained in
|
||||||
|
`/tmp/vac-opt278-step2`. The original core diagnostic runner is documented in
|
||||||
|
[issue-78.md](issue-78.md#reproduction).
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# PR #278: reject impossible mandatory moves before copying
|
||||||
|
|
||||||
|
Measured on 2026-10-16 against `433a8b0`, which includes the
|
||||||
|
[immutable date-count optimization](pr-278-immutable-date-counts.md).
|
||||||
|
|
||||||
|
A loaded-year CPU profile attributed 5.09 seconds to candidate assignment and
|
||||||
|
6.53 seconds to final validation out of 58.24 seconds. Generic moves and swaps
|
||||||
|
copied the candidate state before discovering that a mandatory placement's new
|
||||||
|
date or leave type could never satisfy its rule.
|
||||||
|
|
||||||
|
The shared assignment function now calls the existing `mandatoryRelocationDate`
|
||||||
|
check before copying. It checks only necessary date and type conditions. Complete
|
||||||
|
multi-day patterns and earned-entitlement chronology are still validated after
|
||||||
|
the candidate is assembled. Preferences remain movable, and valid alternatives,
|
||||||
|
objective order and deterministic tie breaks remain unchanged.
|
||||||
|
|
||||||
|
## Before and after
|
||||||
|
|
||||||
|
Both protocol runs use Node 24.18.0, Bun 1.3.14 and Vitest 5.0.3 on the same local
|
||||||
|
machine. This comparison uses a fresh baseline from this session. No other test,
|
||||||
|
build or profiling workload runs alongside either protocol measurement.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
NODE_OPTIONS=--max-old-space-size=2048 timeout 300s bunx vitest run src/app/benchmark/leave-planner-benchmark.spec.ts --maxWorkers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
All seven warm-ups, fourteen measured calculations, the typed-obligation precheck,
|
||||||
|
fixtures, assertions and resource limits remain unchanged.
|
||||||
|
|
||||||
|
| Measurement | Before | After |
|
||||||
|
| ---------------------------------------------------- | --------: | --------: |
|
||||||
|
| Vitest duration | 232.630 s | 228.130 s |
|
||||||
|
| Benchmark test | 231.483 s | 227.011 s |
|
||||||
|
| Loaded year, mean of two measured calculations | 59.255 s | 57.850 s |
|
||||||
|
| Typed obligations, mean of two measured calculations | 4.552 s | 4.368 s |
|
||||||
|
| Headroom before timeout (Vitest duration) | 67.370 s | 71.870 s |
|
||||||
|
|
||||||
|
The Vitest duration was 1.9% shorter in this pair. External wall time was not
|
||||||
|
recorded for these runs. Both runs completed successfully, and every repeated
|
||||||
|
scenario remained deterministic. The baseline is faster than the one recorded
|
||||||
|
for the preceding optimization, so only same-session pairs are comparable.
|
||||||
|
Live CI performance still needs confirmation.
|
||||||
|
|
||||||
|
## Correctness checks
|
||||||
|
|
||||||
|
Complete outcomes, stable explanations and final-state snapshots match the
|
||||||
|
baseline for all seven scenarios. Baseline snapshots come from the preceding
|
||||||
|
optimization's saved run, whose bundle matches a fresh build of `433a8b0` byte for
|
||||||
|
byte; the loaded-year snapshot was also regenerated in this session. Diagnostic
|
||||||
|
comparisons ran alongside functional checks, so their timings are not used for
|
||||||
|
the protocol comparison.
|
||||||
|
|
||||||
|
The functional coverage suite passes all 640 tests across 33 files. Two additional
|
||||||
|
regression cases cover obligations and lower-priority preferences when a calendar
|
||||||
|
objective competes for the same leave balance. Both cases pass before and after
|
||||||
|
the optimization. Existing tests also exercise typed reassignments, complete
|
||||||
|
multi-day patterns, earned entitlements and counterfactual explanations.
|
||||||
|
Application and spec type checks, lint, source formatting and `git diff --check`
|
||||||
|
pass.
|
||||||
|
|
||||||
|
No browser timing run or live CI rerun was performed for this change.
|
||||||
|
|
||||||
|
Diagnostic sources, profiles, bundles and full logs are retained in
|
||||||
|
`/tmp/vac-opt278-step3`. The original core diagnostic runner is documented in
|
||||||
|
[issue-78.md](issue-78.md#reproduction).
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
# Bun Runtime and Native Tooling — Development Spec
|
||||||
|
|
||||||
|
Status: the agreed decisions are amended by the verification results in #289,
|
||||||
|
recorded below. ADR 0009 remains proposed until the migration passes its final
|
||||||
|
gate.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implementation status after #290 and the local parts of #293. "Verified locally"
|
||||||
|
means a command ran with Node unavailable (or a tripwire `node` that logs every
|
||||||
|
start); it is not a result from the live Gitea workflow.
|
||||||
|
|
||||||
|
**Done, verified locally:**
|
||||||
|
|
||||||
|
- **One pinned release.** Bun 1.4.3 is pinned by `packageManager`, the
|
||||||
|
checksum-verified `scripts/install-bun.sh` that every CI job running project
|
||||||
|
commands uses (no npm), the
|
||||||
|
`@types/bun` version and both Docker stages. The installer refuses a
|
||||||
|
`package.json` that names another version. Frozen installs leave `bun.lock`
|
||||||
|
unchanged.
|
||||||
|
- **Node-shebang tools on Bun.** `bunfig.toml` sets `[run] bun = true`, so
|
||||||
|
`start`, `build`, `watch`, `lint`, `lint:fix`, `format` and `format:check` run
|
||||||
|
`ng`, `eslint` and `prettier` on Bun even when Node is on `PATH`. CI also runs
|
||||||
|
project steps with a `PATH` that has Bun and no Node.
|
||||||
|
- **Standalone type checking.** `bun run typecheck` runs `bun check` on the
|
||||||
|
application and test projects. The production build remains the only check of
|
||||||
|
Angular templates.
|
||||||
|
- **Tests, coverage, benchmark, server, container** (#291, #292), and the policy
|
||||||
|
and image-publisher scripts with their regression tests, under Bun. The fake
|
||||||
|
`docker` fixture uses a `bun` shebang.
|
||||||
|
|
||||||
|
**Still open:**
|
||||||
|
|
||||||
|
- **Native lint and format (#290, user story 5).** Replace ESLint and Prettier in
|
||||||
|
one change when a stable Bun ships `bun lint` and `bun fmt`; today they exist
|
||||||
|
only in canary builds. Until then user story 5 and the matching #290 criteria
|
||||||
|
stay open.
|
||||||
|
- **Live Gitea validation (#293).** The custom job `shell` template, the Bun
|
||||||
|
installer, JavaScript actions beside Node-free project steps, the benchmark's
|
||||||
|
timing margin and the required checks on the final PR revision have not run on
|
||||||
|
the live runner. The `images` job still has Alpine's `nodejs` on `PATH` for the
|
||||||
|
checkout action, which the project command does not use. These and the Gitea
|
||||||
|
activation steps remain unverified until a live pull-request run.
|
||||||
|
- **ADR 0009** stays `proposed` until the final gate.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The maintainer wants VacOptimizer to use Bun as its actual JavaScript runtime and
|
||||||
|
adopt its native tooling wherever agreed, even without measurable performance
|
||||||
|
gains. Today Bun installs dependencies, generates the language registry, and runs
|
||||||
|
the deployed server, but development commands can launch Node-based CLIs, CI uses
|
||||||
|
Node containers, and the Docker build explicitly installs Node.
|
||||||
|
|
||||||
|
The toolchain is also inconsistent: the local Bun installation is 1.3.14, CI pins
|
||||||
|
1.4.2, and Docker uses a floating Bun image. Merely prefixing commands with Bun
|
||||||
|
does not establish which runtime executes their JavaScript.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Use Bun for project-owned JavaScript in development, CI, and production. Adopt
|
||||||
|
Bun's native test runner, linter, formatter, standalone type checker, and HTTP
|
||||||
|
server. Retain Angular and its standard compiler and build pipeline, running the
|
||||||
|
JavaScript tooling under Bun.
|
||||||
|
|
||||||
|
Preserve the application's existing behavior and verification standards. The
|
||||||
|
planner remains client-side: planning horizons, planning rules, leave placements,
|
||||||
|
planning outcomes, plan statistics, and plan explanations retain their current
|
||||||
|
meaning. Browser code and Web Workers continue to run in the user's browser.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
The following acceptance criteria define completion:
|
||||||
|
|
||||||
|
1. **Actual Bun execution.** Development serving and watch mode, production
|
||||||
|
builds, language generation, repository CI scripts, tests, and the production
|
||||||
|
server execute their project-owned JavaScript under Bun, including spawned
|
||||||
|
tools and test fixtures. A Node.js fallback does not satisfy this criterion.
|
||||||
|
2. **Reproducible setup.** Development instructions, CI, and both Docker stages
|
||||||
|
specify the same exact, verified Bun version. Frozen dependency installation
|
||||||
|
succeeds from a clean checkout without changing the lockfile, and the
|
||||||
|
production image installs only production dependencies.
|
||||||
|
3. **Native tests with preserved behavior.** All existing functional and Angular
|
||||||
|
component tests execute under `bun test`, preserving assertions, parameterized
|
||||||
|
cases, clock control, global mocks, cleanup, isolation, and real worker
|
||||||
|
integration. Tests cannot be dropped or weakened to make the migration pass.
|
||||||
|
4. **Preserved coverage.** Functional tests retain coverage reporting and CI
|
||||||
|
artifacts, including useful output when tests fail. Preserve the current
|
||||||
|
production-code coverage scope and exclusion of the frozen engine baseline;
|
||||||
|
report unsupported coverage capabilities or report formats as compatibility
|
||||||
|
gaps rather than silently removing them.
|
||||||
|
5. **Native linting and formatting.** Bun's native linter and formatter replace
|
||||||
|
ESLint and Prettier, preserving the current rule severities, type-aware checks,
|
||||||
|
unused-name exceptions, formatting options, and Angular-template formatting.
|
||||||
|
CI continues to reject violations. Checks cover the same application,
|
||||||
|
repository-script, and workflow content as before.
|
||||||
|
6. **Type checking alongside Angular.** `bun check` checks application and test
|
||||||
|
TypeScript. Angular's compiler retains its supported TypeScript 6 dependency
|
||||||
|
and continues to check templates during builds. Type errors still fail the
|
||||||
|
appropriate checks; successful standalone checking does not replace a
|
||||||
|
successful Angular build.
|
||||||
|
7. **Native HTTP serving.** `Bun.serve` replaces Express, preserving Angular's
|
||||||
|
request-handler integration, configured rendering modes, static assets and
|
||||||
|
cache behavior, configurable port, error handling, and container health check.
|
||||||
|
Development and production routes continue to work, including direct visits
|
||||||
|
to the benchmark page. The current catch-all route uses client rendering;
|
||||||
|
this migration does not enable server rendering for that route.
|
||||||
|
8. **Existing CI guarantees.** Policy and image-publication regression checks,
|
||||||
|
security scans, production builds, artifact retention, and required checks
|
||||||
|
continue to work. The separate required seven-scenario benchmark preserves
|
||||||
|
its calibration protocol, warm-up, measured repetitions, determinism checks,
|
||||||
|
timeouts, and performance budgets. A runtime change cannot justify weakening
|
||||||
|
these gates.
|
||||||
|
9. **Honest completion.** Fix incompatible required tooling before declaring the
|
||||||
|
migration complete. If a fix proves infeasible, report the failing behavior,
|
||||||
|
evidence, and remaining blocker. Local success must be distinguished from
|
||||||
|
successful live Gitea checks.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
- **Agreed: runtime and native tools.** Bun adoption is a requirement independent
|
||||||
|
of speed gains. Use its native test runner, linter, formatter, standalone type
|
||||||
|
checker, and HTTP server. Compatible Node API imports may remain when executed
|
||||||
|
by Bun; rewriting every import is not required to satisfy the runtime goal.
|
||||||
|
- **Agreed: Angular boundary.** Keep Angular's standard compiler, bundler,
|
||||||
|
development server, and framework build support. Preserve the existing
|
||||||
|
rendering configuration and browser-based planner, consistent with ADR 0002.
|
||||||
|
- **Agreed: TypeScript coexistence.** Keep TypeScript 6 for Angular compilation
|
||||||
|
and use Bun's TypeScript 7-based checker for standalone checking. An Angular
|
||||||
|
upgrade is not required for this arrangement.
|
||||||
|
- **Agreed: tests and server integration.** Adapt the Angular test compilation
|
||||||
|
and setup, mocks, DOM environment, worker harnesses, and subprocess fixtures to
|
||||||
|
the chosen runtime and runner. Use Angular's web request API with Bun's HTTP
|
||||||
|
server, preserving its development/build handler contract and static-file
|
||||||
|
protections.
|
||||||
|
- **Migration reach.** Update package commands, test configuration and typings,
|
||||||
|
generated executable fixtures, server entry and exports, container build and
|
||||||
|
runtime stages, project-owned CI invocations, and development documentation.
|
||||||
|
Remove superseded dependencies and configuration after checking their callers.
|
||||||
|
- **Proposed implementation order.** Verify the Bun release, an Angular
|
||||||
|
production build, representative Angular component tests, and native
|
||||||
|
lint/format/type checks first. Extend that verified setup to the full suite,
|
||||||
|
server, containers, and CI before removing the replaced tools.
|
||||||
|
- **Proposed version.** Bun 1.4.3 is the initial candidate based on the researched
|
||||||
|
native-tool documentation. Select the exact version only after verifying the
|
||||||
|
required capabilities together.
|
||||||
|
|
||||||
|
## Verification Results and Decisions (#289)
|
||||||
|
|
||||||
|
Probes ran on 2026-10-17 against the stable Bun `1.4.3` release (`1.4.3+c6da4a4d3`)
|
||||||
|
from a clean checkout. "Node unavailable" means a `PATH` holding only a `bun`
|
||||||
|
executable plus `/usr/bin:/bin`, with `command -v node` failing.
|
||||||
|
|
||||||
|
**Verified:**
|
||||||
|
|
||||||
|
- **Install.** `bun install --frozen-lockfile` succeeds and leaves `bun.lock`
|
||||||
|
unchanged.
|
||||||
|
- **Runtime.** Package binaries with a `#!/usr/bin/env node` shebang (`ng`,
|
||||||
|
`eslint`, `prettier`, `tsc`) run on Bun only with `--bun`. Without it they use
|
||||||
|
Node when Node is on `PATH`, so every such command must pass `--bun`
|
||||||
|
explicitly. #290 sets it once for every `bun run` script with `[run] bun = true`
|
||||||
|
in `bunfig.toml`. Spawned build children are native executables (esbuild,
|
||||||
|
dart-sass); Angular's workers are threads of the Bun process.
|
||||||
|
- **Development server.** `bun --bun x ng serve` serves the application and
|
||||||
|
direct navigation to `/benchmark` with Node unavailable.
|
||||||
|
- **Type checking.** `bun check -p tsconfig.app.json` and
|
||||||
|
`-p tsconfig.spec.json` match `tsc` 6 on the clean tree and catch injected
|
||||||
|
application and test type errors. Only the Angular build catches template
|
||||||
|
errors, as expected.
|
||||||
|
- **Tests.** `bun test` runs the whole functional suite with a preload that
|
||||||
|
compiles components through Angular's JIT transform with inlined templates,
|
||||||
|
sets up jsdom, TestBed and zoneless change detection, plus a small shim for
|
||||||
|
the Vitest APIs Bun lacks (`stubGlobal`, `unstubAllGlobals`, `waitFor`,
|
||||||
|
`advanceTimersByTimeAsync`, `expect.poll`, `toHaveBeenCalledExactlyOnceWith`,
|
||||||
|
`setSystemTime` ordering and Date-only fake timers). Real-worker specs work
|
||||||
|
with Node unavailable. `--isolate` or `--parallel` is required for per-file
|
||||||
|
isolation. No spec uses snapshots.
|
||||||
|
- **Server.** `Bun.serve` passes route, asset, cache-header, missing-file and
|
||||||
|
path-traversal checks.
|
||||||
|
- **CI scripts.** The policy regression script passes under Bun. The image
|
||||||
|
regression script needs its fake `docker` shebang changed from `node` to
|
||||||
|
`bun`.
|
||||||
|
|
||||||
|
**Decisions taken after the probes:**
|
||||||
|
|
||||||
|
- **Linter and formatter.** Native `bun lint` and `bun fmt` exist only in canary
|
||||||
|
builds (`1.4.4-canary.1`), not in stable `1.4.3`. Pin stable `1.4.3`
|
||||||
|
everywhere and keep ESLint and Prettier, executed by Bun, until a stable
|
||||||
|
release ships them. Then replace them in one change. Until then, user story 5
|
||||||
|
stays open.
|
||||||
|
- **Client-only Angular build and a Bun static server.** Angular's production
|
||||||
|
build fails under Bun whenever a server or SSR entry is configured: route
|
||||||
|
extraction loads the server bundle through Node `module.register` loader
|
||||||
|
hooks, which Bun accepts but never applies. Every route is client-rendered
|
||||||
|
today, so the Angular build drops its server and SSR entries and a small
|
||||||
|
`Bun.serve` server delivers static assets with the same cache behavior and
|
||||||
|
falls back to `index.html`. This replaces the agreed Angular web request
|
||||||
|
handler integration and gives up `@angular/ssr`. Adding server rendering later
|
||||||
|
requires revisiting this decision.
|
||||||
|
- **Coverage.** `bun test` reports text and lcov with line data only. HTML and
|
||||||
|
Cobertura reports are generated from the lcov with the istanbul report
|
||||||
|
libraries. Branch and function coverage are no longer reported; this
|
||||||
|
reduction is accepted.
|
||||||
|
- **Benchmark memory guard.** JavaScriptCore has no `--max-old-space-size`
|
||||||
|
equivalent. The CI benchmark job enforces the limit with a container memory
|
||||||
|
limit instead of a heap-only cap.
|
||||||
|
|
||||||
|
**Platform constraints, not project commands:**
|
||||||
|
|
||||||
|
- **Gitea actions.** `actions/checkout@v4` (`node20`) and
|
||||||
|
`actions/upload-artifact@v3` (`node16`) are JavaScript actions, so the job
|
||||||
|
container must still provide `node` for the runner. Project commands must not
|
||||||
|
use it.
|
||||||
|
- **Allowed hosts.** Found while probing: the current Express server answers 400
|
||||||
|
unless `NG_ALLOWED_HOSTS` is set, and the compose files only set `PORT`. The
|
||||||
|
static Bun server removes this dependency.
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- Test observable behavior and failure outcomes, rather than configuration text
|
||||||
|
or internal adapter shapes. Reuse the existing behavior-level tests and change
|
||||||
|
harnesses only where the runtime or runner requires it.
|
||||||
|
- **Runtime and installation:** exercise clean frozen installs, development and
|
||||||
|
production commands, and project-owned subprocesses with Node unavailable to
|
||||||
|
those commands. Verify actual execution by Bun, rather than relying on a
|
||||||
|
command's name. Keep any CI-platform Node requirement separate from this check.
|
||||||
|
- **Angular and planning:** use the existing TestBed-based component and service
|
||||||
|
suites as prior art for compilation and DOM behavior. Retain the planning
|
||||||
|
integration, property-based, migration, date-sensitive, and cancellation tests
|
||||||
|
to check planning outcomes, saved inputs, and plan explanations.
|
||||||
|
- **Workers and subprocesses:** preserve the existing real-worker tests for
|
||||||
|
submitted-configuration isolation, outcome and explanation delivery, and worker
|
||||||
|
cleanup. Run policy and publisher regression scripts with Bun, keeping their
|
||||||
|
temporary Git repositories and fake Docker executable so verification remains
|
||||||
|
independent of real registry writes.
|
||||||
|
- **Lint, format, and types:** compare the current checks with the native tools.
|
||||||
|
Use small representative invalid inputs to establish that unused variables,
|
||||||
|
deprecated APIs, formatting violations, application/test type errors, and
|
||||||
|
Angular template errors still fail the intended command. Verify formatting
|
||||||
|
options and representative Angular templates without an unrelated reformat.
|
||||||
|
- **HTTP and containers:** add focused HTTP behavior checks around the native
|
||||||
|
server, since the explored test suite provides no equivalent server coverage.
|
||||||
|
Exercise configured routes, direct navigation, static JavaScript/CSS assets,
|
||||||
|
cache and content-type headers, missing assets, error responses, and traversal
|
||||||
|
protection. Smoke-test the production container and its health check.
|
||||||
|
- **Coverage and benchmark:** verify functional coverage artifacts, including
|
||||||
|
failure-path uploads, and run the existing benchmark outside coverage. Keep the
|
||||||
|
frozen-engine calibration and current budget assertions. Record Bun results as
|
||||||
|
new measurements rather than claiming browser or phone speedups from them.
|
||||||
|
- **CI:** use the existing policy/publisher regression scenarios as prior art for
|
||||||
|
push, pull-request, fork, manual, and release-event behavior. Obtain successful
|
||||||
|
required checks on the final PR revision; live publication and activation
|
||||||
|
validation remain subject to the repository's existing Gitea process.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Replacing Angular's build pipeline with Bun's bundler, rewriting the framework,
|
||||||
|
or bypassing Angular's TypeScript support checks.
|
||||||
|
- Moving leave planning to the server, changing planning rules or objectives,
|
||||||
|
altering saved data, or adding accounts, synchronization, or backend services.
|
||||||
|
- Changing configured rendering modes or treating Bun as the browser runtime.
|
||||||
|
- Dropping tests, weakening lint rules or benchmark budgets, or silently using
|
||||||
|
Node to complete otherwise incompatible project commands.
|
||||||
|
- Replacing security scanners, changing registry publication policy, provisioning
|
||||||
|
CI credentials, modifying branch protections or activation settings, creating
|
||||||
|
releases, publishing this spec, or deploying the application.
|
||||||
|
- Rewriting compatible Node API imports solely because their names mention Node.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
This spec records the decisions made in the interview. The architecture record
|
||||||
|
remains proposed; the Status section lists what is implemented.
|
||||||
|
The glossary receives no new terms because the choices concern general tooling,
|
||||||
|
rather than new leave-planning concepts.
|
||||||
|
|
||||||
|
Remaining verification (everything else is recorded as verified under Status):
|
||||||
|
|
||||||
|
- Native lint and format wait for a stable Bun release that ships `bun lint` and
|
||||||
|
`bun fmt` (#290).
|
||||||
|
- Live Gitea validation (#293): the custom job `shell` template, the Bun download
|
||||||
|
in each job, the `images` job with publisher credentials, and the benchmark's
|
||||||
|
timing margin on the runners.
|
||||||
|
- The `images` job keeps Alpine's `nodejs` on `PATH` because the checkout action
|
||||||
|
needs it (a runner requirement); project commands do not use it and no Node
|
||||||
|
fallback for them is agreed.
|
||||||
|
- `src/test-setup.ts` imports Analog's internal `jitTransform` by path; replace it
|
||||||
|
when Angular or Analog exports a JIT transform.
|
||||||
|
|
||||||
|
Research checked on 2026-10-17:
|
||||||
|
|
||||||
|
- [Bun runtime selection](https://bun.sh/docs/runtime#--bun) explains why a Bun
|
||||||
|
command can still launch a Node executable.
|
||||||
|
- [Bun 1.4.3](https://bun.sh/blog/bun-v1.4.3),
|
||||||
|
[native linting](https://bun.sh/docs/lint),
|
||||||
|
[formatting](https://bun.sh/docs/format), and
|
||||||
|
[type checking](https://bun.sh/docs/runtime/check) describe the candidate tools.
|
||||||
|
- [Angular build guidance](https://angular.dev/ecosystem/custom-build-pipeline)
|
||||||
|
recommends retaining its standard CLI pipeline.
|
||||||
|
- [Angular's web request engine](https://angular.dev/api/ssr/AngularAppEngine) and
|
||||||
|
[Bun's HTTP server](https://bun.sh/docs/runtime/http/server) describe the original
|
||||||
|
server integration plan, replaced by the `Bun.serve` static server above.
|
||||||
|
- [Angular version compatibility](https://angular.dev/reference/versions) and
|
||||||
|
[compiler roadmap](https://angular.dev/roadmap) establish the current Angular
|
||||||
|
compiler boundary. [Microsoft's coexistence guidance](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#typescript-and-embedded-languages)
|
||||||
|
describes standalone TypeScript 7 checks alongside Angular-specific tooling.
|
||||||
+56
-12
@@ -17,15 +17,23 @@ publishing images. There is no schedule or deployment job in the preserved
|
|||||||
configuration; the Compose files remain manual deployment options.
|
configuration; the Compose files remain manual deployment options.
|
||||||
|
|
||||||
| Job | Behavior |
|
| Job | Behavior |
|
||||||
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `policy` | Tests the policy and publisher, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
|
| `policy` | Tests the policy and publisher with Bun, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
|
||||||
| `validate` | Runs lint, formatting, and application/test type checks. |
|
| `validate` | Runs ESLint, Prettier (application, `scripts/ci-*.mjs` and workflow files) and `bun check` type checks of application and test code, all on Bun with the frozen lockfile. |
|
||||||
| `test` | Runs functional coverage with one worker and a 2 GiB Node heap; uploads coverage for seven days even on failure. |
|
| `test` | Runs functional coverage with `bun test --isolate` (line coverage only), converts it to HTML and Cobertura, and 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. |
|
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage in a container limited to 3 GiB. It first times the frozen engine on `two years` (best of three) and fails any scenario whose mean planning time exceeds its multiplier times that calibration; the 600-second timeout is only a hang guard. |
|
||||||
| `build` | Builds browser/server production bundles and retains `dist` for seven days. |
|
| `build` | Builds the client-only Angular production bundle (the only check of Angular templates; `bun check` does not replace it) and the Bun static-server bundle, 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. |
|
| `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. The scanners are not project JavaScript, so this job installs no Bun. |
|
||||||
| `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. |
|
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag, running the publisher with Bun. 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. |
|
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. It is shell only and needs no Bun. |
|
||||||
|
|
||||||
|
The benchmark runs on both push and pull_request events: the push run gates image
|
||||||
|
publication (`images.needs` includes `benchmark`) and the `required` aggregate.
|
||||||
|
The relative gate absorbs a slower runner and load that lasts through the whole
|
||||||
|
job, because calibration runs in the same job. It does not absorb load that
|
||||||
|
starts or stops during the protocol, such as an overlapping push and
|
||||||
|
pull_request run of the same commit; the 600-second hang guard still bounds
|
||||||
|
those runs.
|
||||||
|
|
||||||
Push and PR runs are separate so PR title and rebase checks run on the PR event.
|
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
|
New runs cancel older runs for the same event/ref. Image jobs serialize per
|
||||||
@@ -33,13 +41,13 @@ branch slug; stable promotions share one group and are not cancelled in progress
|
|||||||
|
|
||||||
## Runners and permissions
|
## Runners and permissions
|
||||||
|
|
||||||
Validation uses Node 24.21.0 and Bun 1.4.2 with the frozen lockfile. The image
|
Every job that runs project commands uses Bun 1.4.3 with the frozen lockfile (see [Bun toolchain and Node](#bun-toolchain-and-node)). Job containers stay on Node 24.21.0. The image
|
||||||
job uses Docker 29.8.2 and its matching Docker-in-Docker service. Scanner
|
job uses Docker 29.9.1 and its matching Docker-in-Docker service. Scanner
|
||||||
versions are Semgrep 1.180.0 and Trivy 0.75.0; Trivy downloads are checksum-checked.
|
versions are Semgrep 1.180.0 and Trivy 0.75.0; Trivy downloads are checksum-checked.
|
||||||
Trivy downloads its vulnerability database from Aqua's public ECR mirror because
|
Trivy downloads its vulnerability database from Aqua's public ECR mirror because
|
||||||
the default registries returned a missing database blob and a download timeout.
|
the default registries returned a missing database blob and a download timeout.
|
||||||
Jobs select the `ubuntu-latest` label and require Linux container-job support.
|
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.
|
Allow at least the benchmark job's 3 GiB container memory limit plus runner overhead.
|
||||||
|
|
||||||
The instance currently has an online inherited runner named `srvhome`, labelled
|
The instance currently has an online inherited runner named `srvhome`, labelled
|
||||||
`ubuntu-latest` and `ubuntu-24.04`. Repository runners, secrets, and variables
|
`ubuntu-latest` and `ubuntu-24.04`. Repository runners, secrets, and variables
|
||||||
@@ -64,6 +72,42 @@ 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
|
administrator token is not a CI publishing credential. No SSH or deployment
|
||||||
secret is required by this workflow.
|
secret is required by this workflow.
|
||||||
|
|
||||||
|
## Bun toolchain and Node
|
||||||
|
|
||||||
|
Project commands run on Bun 1.4.3 only. `package.json` (`packageManager`),
|
||||||
|
`@types/bun`, both Docker stages and `scripts/install-bun.sh` carry the same
|
||||||
|
version. To upgrade, change all of them together and replace the installer's
|
||||||
|
four SHA-256 values with those of the new release's `SHASUMS256.txt`; the
|
||||||
|
installer refuses a `package.json` that names a different version, so a bump of
|
||||||
|
only one place fails CI instead of drifting.
|
||||||
|
|
||||||
|
- **Install.** `policy`, `validate`, `test`, `benchmark`, `build` and `images`
|
||||||
|
run `sh scripts/install-bun.sh`. It downloads the release zip for the
|
||||||
|
runner's architecture (x64 or aarch64) and libc (glibc, or musl on Alpine),
|
||||||
|
verifies its pinned SHA-256, and installs `/opt/bun/bin/bun`. It uses no npm.
|
||||||
|
- **Node-free project steps.** The five Debian jobs set
|
||||||
|
`defaults.run.shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}`. Every
|
||||||
|
`run:` step then sees Bun but not `/usr/local/bin`, where the `node` image keeps
|
||||||
|
`node`, `npm` and `yarn`. `bun run` additionally runs Node-shebang tools on Bun
|
||||||
|
through `bunfig.toml`. The security scanners are not project JavaScript, so
|
||||||
|
`security` has no Bun and no custom shell.
|
||||||
|
- **Why the containers keep Node.** `actions/checkout@v4` (`node20`) and
|
||||||
|
`actions/upload-artifact@v3` (`node16`) are JavaScript actions. The runner
|
||||||
|
starts them with the container's `node`; the custom shell does not affect
|
||||||
|
`uses:` steps. This is a platform requirement, not permission to run project
|
||||||
|
commands on Node.
|
||||||
|
- **`images` exception.** Alpine's `nodejs` package installs `node` into
|
||||||
|
`/usr/bin`, beside `git`, and checkout's post step needs it, so `PATH` cannot
|
||||||
|
hide it there. The project command is `/opt/bun/bin/bun scripts/ci-image.mjs`;
|
||||||
|
it starts only `process.execPath`, `docker` and `git`. Hiding Node there needs
|
||||||
|
an infrastructure decision (for example a runner image that ships Node outside
|
||||||
|
`PATH`), which this conversion does not make.
|
||||||
|
- **Needs live validation.** The custom shell template is a GitHub Actions
|
||||||
|
feature that Gitea's runner implements. A local run of the real engine
|
||||||
|
(`gitea-runner exec` v5.1.0, with the job containers above) executed it, but
|
||||||
|
only a live Gitea run confirms the instance's runner version, the checkout
|
||||||
|
action and the privileged Docker-in-Docker `images` job.
|
||||||
|
|
||||||
## Images and releases
|
## Images and releases
|
||||||
|
|
||||||
Images use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
|
Images use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
|
||||||
|
|||||||
@@ -32,19 +32,18 @@ If Cancel is accepted just before a queued natural result reaches the facade, th
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Run the focused checks with the project's installed Bun and Node:
|
Run the focused checks with the pinned Bun (Node is not needed):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bunx vitest run src/app/services/leave-planner-cancellation.spec.ts
|
bun test --isolate src/app/services/leave-planner-cancellation.spec.ts
|
||||||
bunx vitest run src/app/services/leave-planner.worker.spec.ts
|
bun test --isolate src/app/services/leave-planner.worker.spec.ts
|
||||||
bunx vitest run src/app/components/calendar-year/calendar-year-planning.spec.ts
|
bun test --isolate src/app/components/calendar-year/calendar-year-planning.spec.ts
|
||||||
bunx tsc --noEmit -p tsconfig.app.json
|
bun run typecheck
|
||||||
bunx tsc --noEmit -p tsconfig.spec.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The public cancellation tests cover immediate cancellation, a stable partial outcome despite late completion or artifact error, distinct worker failure and replacement (including non-browser explanation metadata), and cancellation after settlement. Core regressions verify retained validity, provenance, earned chronology and the best available partial plan through the existing checkpoint seam. The CalendarYear tests cover keyboard activation, translated statuses with and without an incumbent, focus, no implicit recalculation, explicit recalculation, and obsolete explanation delivery.
|
The public cancellation tests cover immediate cancellation, a stable partial outcome despite late completion or artifact error, distinct worker failure and replacement (including non-browser explanation metadata), and cancellation after settlement. Core regressions verify retained validity, provenance, earned chronology and the best available partial plan through the existing checkpoint seam. The CalendarYear tests cover keyboard activation, translated statuses with and without an incumbent, focus, no implicit recalculation, explicit recalculation, and obsolete explanation delivery.
|
||||||
|
|
||||||
The real-worker suite bundles the production worker source with Bun and runs it on a separate Node worker thread. Its small host adapter maps browser worker messages to the thread's message port; the planner, CPU work, cancellation handling, and outcome and explanation delivery protocol are real. It checks interruption after preparation has started, before the first obligation solution, during active obligation CPU work after a solution exists, and after a completed checkpoint. It also compares an uncancelled outcome with the synchronous adapter's deterministic result. These checks complement the controlled worker boundary tests.
|
The real-worker suite bundles the production worker source with Bun and runs it on a separate `node:worker_threads` thread of the Bun process. Its small host adapter maps browser worker messages to the thread's message port; the planner, CPU work, cancellation handling, and outcome and explanation delivery protocol are real. It checks interruption after preparation has started, before the first obligation solution, during active obligation CPU work after a solution exists, and after a completed checkpoint. It also compares an uncancelled outcome with the synchronous adapter's deterministic result. These checks complement the controlled worker boundary tests.
|
||||||
|
|
||||||
For a browser check, serve the production browser build and use native Chromium Web Workers with a saved annual horizon. Check cancellation during calendar preparation, a broad obligation search, and a quality pass. Confirm one planning request per Calculate, no new request on Cancel, the appropriate translated status, joint rule satisfaction and balance accounting for an incumbent, and stable explanation delivery after the outcome. Also check the Cancel control with the keyboard, at 375px width, and in light and dark themes.
|
For a browser check, serve the production browser build and use native Chromium Web Workers with a saved annual horizon. Check cancellation during calendar preparation, a broad obligation search, and a quality pass. Confirm one planning request per Calculate, no new request on Cancel, the appropriate translated status, joint rule satisfaction and balance accounting for an incumbent, and stable explanation delivery after the outcome. Also check the Cancel control with the keyboard, at 375px width, and in light and dark themes.
|
||||||
|
|
||||||
|
|||||||
@@ -73,9 +73,8 @@ range is explicitly indicative, not a confidence interval or a promise.
|
|||||||
|
|
||||||
## What is timed
|
## What is timed
|
||||||
|
|
||||||
Manual requests capture `performance.now()` inside
|
Every request, manual or initial, captures `performance.now()` inside
|
||||||
`UserInputService.requestComputation`, before updating the calculation trigger.
|
`PlanningCalculationService.start`.
|
||||||
The existing initial calculation falls back to the start of `startCalculation`.
|
|
||||||
The endpoint is Angular's `afterNextRender` callback after the complete plan has
|
The endpoint is Angular's `afterNextRender` callback after the complete plan has
|
||||||
been projected into the calendar, its months and placements have been rendered,
|
been projected into the calendar, its months and placements have been rendered,
|
||||||
and the loading overlay has been removed. This measures request scheduling,
|
and the loading overlay has been removed. This measures request scheduling,
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ benchmark inputs, CI settings or performance limits.
|
|||||||
| Benchmark callers | Supported calculation outcome, explanation and timings; no trace promises, waits, counts or replacement trace metrics. |
|
| Benchmark callers | Supported calculation outcome, explanation and timings; no trace promises, waits, counts or replacement trace metrics. |
|
||||||
|
|
||||||
Production-source searches found no decision-trace, historical analysis,
|
Production-source searches found no decision-trace, historical analysis,
|
||||||
acknowledgement or replay implementation. Angular hydration's `withEventReplay`
|
acknowledgement or replay implementation. `PlanningArtifacts` carries actual explanation,
|
||||||
is unrelated to planning history. `PlanningArtifacts` carries actual explanation,
|
|
||||||
final-state and planning-work timing data, rather than a compatibility shell.
|
final-state and planning-work timing data, rather than a compatibility shell.
|
||||||
|
|
||||||
## Existing deterministic fixtures
|
## Existing deterministic fixtures
|
||||||
|
|||||||
+25
-33
@@ -1,17 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "vac-optimizer",
|
"name": "vac-optimizer",
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
|
"packageManager": "bun@1.4.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "bun scripts/generate-langs.js && ng serve",
|
"start": "bun scripts/generate-langs.js && ng serve",
|
||||||
"build": "bun scripts/generate-langs.js && ng build",
|
"build": "bun scripts/generate-langs.js && ng build",
|
||||||
|
"postbuild": "bun build src/server.ts --target=bun --outfile dist/vac-optimizer/server/server.js",
|
||||||
"watch": "bun scripts/generate-langs.js && ng build --watch --configuration development",
|
"watch": "bun scripts/generate-langs.js && ng build --watch --configuration development",
|
||||||
"test": "bun scripts/generate-langs.js && ng test",
|
"test": "bun scripts/generate-langs.js && bun test --isolate",
|
||||||
"serve:ssr:vac-optimizer": "bun dist/vac-optimizer/server/server.mjs",
|
"serve": "bun dist/vac-optimizer/server/server.js",
|
||||||
|
"typecheck": "bun scripts/generate-langs.js && bun check -p tsconfig.app.json && bun check -p tsconfig.spec.json",
|
||||||
"lint": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\"",
|
"lint": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\"",
|
||||||
"lint:fix": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\" --fix",
|
"lint:fix": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\" --fix",
|
||||||
"format": "bun scripts/generate-langs.js && prettier --write \"src/**/*.{ts,html,scss,css,json}\"",
|
"format": "bun scripts/generate-langs.js && prettier --write \"src/**/*.{ts,html,scss,css,json}\" \"scripts/ci-*.mjs\" \".gitea/workflows/*.yml\"",
|
||||||
"format:check": "bun scripts/generate-langs.js && prettier --check \"src/**/*.{ts,html,scss,css,json}\""
|
"format:check": "bun scripts/generate-langs.js && prettier --check \"src/**/*.{ts,html,scss,css,json}\" \"scripts/ci-*.mjs\" \".gitea/workflows/*.yml\""
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
@@ -27,51 +30,40 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "22.2.1",
|
"@angular/animations": "22.2.3",
|
||||||
"@angular/cdk": "^22.0.0",
|
"@angular/cdk": "^22.0.0",
|
||||||
"@angular/common": "22.2.1",
|
"@angular/common": "22.2.3",
|
||||||
"@angular/compiler": "22.2.1",
|
"@angular/compiler": "22.2.3",
|
||||||
"@angular/core": "22.2.1",
|
"@angular/core": "22.2.3",
|
||||||
"@angular/forms": "22.2.1",
|
"@angular/forms": "22.2.3",
|
||||||
"@angular/material": "^22.0.0",
|
"@angular/material": "^22.0.0",
|
||||||
"@angular/platform-browser": "22.2.1",
|
"@angular/platform-browser": "22.2.3",
|
||||||
"@angular/platform-browser-dynamic": "22.2.1",
|
"@angular/platform-browser-dynamic": "22.2.3",
|
||||||
"@angular/platform-server": "22.2.1",
|
"@angular/router": "22.2.3",
|
||||||
"@angular/router": "22.2.1",
|
|
||||||
"@angular/ssr": "22.2.2",
|
|
||||||
"date-holidays": "^3.30.2",
|
"date-holidays": "^3.30.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"express": "^5.2.1",
|
|
||||||
"rxjs": "~7.8.2",
|
"rxjs": "~7.8.2",
|
||||||
"tslib": "^2.8.1"
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@analogjs/platform": "^2.7.5",
|
|
||||||
"@analogjs/vite-plugin-angular": "^2.7.5",
|
"@analogjs/vite-plugin-angular": "^2.7.5",
|
||||||
"@analogjs/vitest-angular": "^2.7.5",
|
"@angular/build": "22.2.3",
|
||||||
"@angular/build": "22.2.2",
|
"@angular/cli": "22.2.3",
|
||||||
"@angular/cli": "22.2.2",
|
"@angular/compiler-cli": "22.2.3",
|
||||||
"@angular/compiler-cli": "22.2.1",
|
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@nx/devkit": "^23.0.0",
|
"@types/bun": "1.4.3",
|
||||||
"@nx/vite": "^23.0.0",
|
"@types/jsdom": "^21.1.7",
|
||||||
"@types/express": "^5.0.6",
|
|
||||||
"@types/jest": "^30.0.0",
|
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^26.0.0",
|
||||||
"@vitest/coverage-istanbul": "^5.0.0",
|
|
||||||
"@vitest/coverage-v8": "^5.0.0",
|
|
||||||
"@vitest/ui": "^5.0.0",
|
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.6",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"jest": "^30.4.2",
|
"fast-check": "4.10.2",
|
||||||
"jest-preset-angular": "^17.0.0",
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
|
"istanbul-lib-report": "^3.0.1",
|
||||||
|
"istanbul-reports": "^3.2.0",
|
||||||
"jsdom": "^30.0.0",
|
"jsdom": "^30.0.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.60.0",
|
"typescript-eslint": "^8.60.0"
|
||||||
"vite": "^8.0.0",
|
|
||||||
"vite-tsconfig-paths": "^6.0.0",
|
|
||||||
"vitest": "^5.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ try {
|
|||||||
const docker = join(bin, 'docker');
|
const docker = join(bin, 'docker');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
docker,
|
docker,
|
||||||
`#!/usr/bin/env node
|
`#!/usr/bin/env bun
|
||||||
import { appendFileSync, readFileSync } from 'node:fs';
|
import { appendFileSync, readFileSync } from 'node:fs';
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const input = args[0] === 'login' ? readFileSync(0, 'utf8') : '';
|
const input = args[0] === 'login' ? readFileSync(0, 'utf8') : '';
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Converts coverage/lcov.info (Bun) to HTML and Cobertura. Run: bun scripts/coverage-reports.mjs
|
||||||
|
// shortcut: line coverage only, Bun's lcov has no usable branch/function data; revisit if Bun adds it.
|
||||||
|
// Totals here sum lines (8351/8578); Bun's text table averages per-file percentages, so it reads lower.
|
||||||
|
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import libCoverage from 'istanbul-lib-coverage';
|
||||||
|
import libReport from 'istanbul-lib-report';
|
||||||
|
import reports from 'istanbul-reports';
|
||||||
|
|
||||||
|
const map = libCoverage.createCoverageMap({});
|
||||||
|
const loc = (line) => ({ start: { line, column: 0 }, end: { line, column: 1 } });
|
||||||
|
for (const record of readFileSync('coverage/lcov.info', 'utf8').split('end_of_record')) {
|
||||||
|
const file = /^SF:(.+)$/m.exec(record)?.[1];
|
||||||
|
if (!file) continue;
|
||||||
|
const statementMap = {};
|
||||||
|
const s = {};
|
||||||
|
for (const [, line, hits] of record.matchAll(/^DA:(\d+),(\d+)/gm)) {
|
||||||
|
statementMap[line] = loc(+line);
|
||||||
|
s[line] = +hits;
|
||||||
|
}
|
||||||
|
map.addFileCoverage({
|
||||||
|
path: resolve(file),
|
||||||
|
statementMap,
|
||||||
|
s,
|
||||||
|
fnMap: {},
|
||||||
|
f: {},
|
||||||
|
branchMap: {},
|
||||||
|
b: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const context = libReport.createContext({ dir: 'coverage', coverageMap: map });
|
||||||
|
for (const name of ['html', 'cobertura']) reports.create(name).execute(context);
|
||||||
|
|
||||||
|
// Istanbul reports 0 branches/functions as 100%; relabel them instead of claiming coverage.
|
||||||
|
const rewrite = (file, edit) => writeFileSync(file, edit(readFileSync(file, 'utf8')));
|
||||||
|
for (const page of readdirSync('coverage', { recursive: true }).filter((f) => f.endsWith('.html')))
|
||||||
|
rewrite(join('coverage', page), (html) =>
|
||||||
|
html
|
||||||
|
.replace(
|
||||||
|
/<span class="strong">100% <\/span>(\s*<span class="quiet">(?:Branches|Functions)<\/span>\s*<span class='fraction'>)0\/0</g,
|
||||||
|
'<span class="strong">n/a </span>$1not collected<',
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/<td data-value="100" class="pct \w+">100%<\/td>(\s*)<td data-value="0" class="abs \w+">0\/0<\/td>/g,
|
||||||
|
'<td data-value="0" class="pct">n/a</td>$1<td data-value="0" class="abs">not collected</td>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
rewrite('coverage/cobertura-coverage.xml', (xml) =>
|
||||||
|
xml
|
||||||
|
.replaceAll('branch-rate="1"', 'branch-rate="0"')
|
||||||
|
.replace(
|
||||||
|
'<coverage ',
|
||||||
|
'<!-- Line coverage only: bun test does not collect branch or function coverage. -->\n<coverage ',
|
||||||
|
),
|
||||||
|
);
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Installs the pinned Bun release to /opt/bun/bin without npm, after verifying its SHA-256.
|
||||||
|
# Needs curl, unzip and sha256sum. Bun upgrades change the version, package.json and every checksum together.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
version=1.4.3
|
||||||
|
dest=${BUN_INSTALL_DIR:-/opt/bun}
|
||||||
|
|
||||||
|
grep -q "\"packageManager\": \"bun@${version}\"" package.json || {
|
||||||
|
echo "package.json packageManager must be bun@${version}." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) arch=x64 ;;
|
||||||
|
aarch64 | arm64) arch=aarch64 ;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported Bun architecture: $(uname -m)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
libc=
|
||||||
|
if [ -e /etc/alpine-release ]; then libc=-musl; fi
|
||||||
|
asset="bun-linux-${arch}${libc}"
|
||||||
|
case "$asset" in
|
||||||
|
bun-linux-x64) sha256=3e730528d89775f03f87f05c06787c4df4099d0a37f3f252e7b829cb23816e18 ;;
|
||||||
|
bun-linux-x64-musl) sha256=6e9ea98554ea0d05efda5840ae292618aaa3adefd66b0853520c5c86a37eac9c ;;
|
||||||
|
bun-linux-aarch64) sha256=efa9813da5ed72423bf847f916e8d2c47c0d776add972354026a75e10da9aa21 ;;
|
||||||
|
bun-linux-aarch64-musl) sha256=8de8a0003cc2707bb555887e3d450e24e1edf3f605f491393e89ed61a8853420 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
curl --fail --location --silent --show-error \
|
||||||
|
"https://github.com/oven-sh/bun/releases/download/bun-v${version}/${asset}.zip" \
|
||||||
|
--output "$work/bun.zip"
|
||||||
|
echo "${sha256} ${work}/bun.zip" | sha256sum -c -
|
||||||
|
unzip -q "$work/bun.zip" -d "$work"
|
||||||
|
mkdir -p "${dest}/bin"
|
||||||
|
install -m 0755 "${work}/${asset}/bun" "${dest}/bin/bun"
|
||||||
|
"${dest}/bin/bun" --version
|
||||||
@@ -7,7 +7,7 @@ import { MatOptionHarness } from '@angular/material/core/testing';
|
|||||||
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
||||||
import { MatSelectHarness } from '@angular/material/select/testing';
|
import { MatSelectHarness } from '@angular/material/select/testing';
|
||||||
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
||||||
import { type MockInstance, vi } from 'vitest';
|
import { type MockInstance, vi } from '../testing/vitest-compat';
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
import { CalendarService } from './services/calendar-service';
|
import { CalendarService } from './services/calendar-service';
|
||||||
import { calculatePlanningInternal } from './services/leave-planner.core';
|
import { calculatePlanningInternal } from './services/leave-planner.core';
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
|
||||||
import { provideServerRendering, withRoutes } from '@angular/ssr';
|
|
||||||
import { appConfig } from './app.config';
|
|
||||||
import { serverRoutes } from './app.routes.server';
|
|
||||||
|
|
||||||
const serverConfig: ApplicationConfig = {
|
|
||||||
providers: [provideServerRendering(withRoutes(serverRoutes))],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
|
||||||
@@ -6,13 +6,11 @@ import {
|
|||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideClientHydration(withEventReplay()),
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
|
||||||
|
|
||||||
export const serverRoutes: ServerRoute[] = [
|
|
||||||
{
|
|
||||||
path: '**',
|
|
||||||
renderMode: RenderMode.Client,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
BenchmarkMeasurement,
|
||||||
|
benchmarkSummaryHeader,
|
||||||
|
benchmarkSummaryRow,
|
||||||
|
budgetViolations,
|
||||||
|
} from './leave-planner-benchmark';
|
||||||
|
|
||||||
|
const measure = (scenario: string, planningMs: number) =>
|
||||||
|
({ scenario, planningMs }) as BenchmarkMeasurement;
|
||||||
|
|
||||||
|
describe('benchmark budget gate', () => {
|
||||||
|
const multipliers = { a: 2 };
|
||||||
|
it('accepts a mean within multiplier x calibration', () => {
|
||||||
|
expect(budgetViolations([measure('a', 150), measure('a', 250)], 100, multipliers)).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('reports measured time, budget, multiplier and calibration when over budget', () => {
|
||||||
|
const [message] = budgetViolations(
|
||||||
|
[measure('a', 300), measure('a', 320)],
|
||||||
|
100,
|
||||||
|
multipliers,
|
||||||
|
);
|
||||||
|
expect(message).toContain('a:');
|
||||||
|
expect(message).toContain('measured 310.0 ms');
|
||||||
|
expect(message).toContain('budget 200.0 ms');
|
||||||
|
expect(message).toContain('multiplier 2');
|
||||||
|
expect(message).toContain('calibration 100.0 ms');
|
||||||
|
});
|
||||||
|
it('fails a scenario without a multiplier', () => {
|
||||||
|
expect(budgetViolations([measure('new', 1)], 100, multipliers)).toEqual([
|
||||||
|
'new: no budget multiplier defined',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('benchmark summary table', () => {
|
||||||
|
const runs = (planningMs: number[]) =>
|
||||||
|
planningMs.map(
|
||||||
|
(ms, index) =>
|
||||||
|
({
|
||||||
|
scenario: 'a',
|
||||||
|
planningMs: ms,
|
||||||
|
outcome: 'Plan',
|
||||||
|
lastCompletedStage: 'dates',
|
||||||
|
placements: 3,
|
||||||
|
deterministic: index ? 'yes' : 'baseline',
|
||||||
|
}) as BenchmarkMeasurement,
|
||||||
|
);
|
||||||
|
it('aligns rows with the header', () => {
|
||||||
|
const [header, separator] = benchmarkSummaryHeader().split('\n');
|
||||||
|
const row = benchmarkSummaryRow(runs([150, 250]), 100, { a: 2 });
|
||||||
|
expect(row).toHaveLength(header.length);
|
||||||
|
expect(separator).toHaveLength(header.length);
|
||||||
|
expect(row).toContain('0.15 / 0.25 s');
|
||||||
|
expect(row).toContain('Plan (dates)');
|
||||||
|
expect(row).toMatch(/\| ok +\|$/);
|
||||||
|
});
|
||||||
|
it('marks scenarios over budget or without a budget', () => {
|
||||||
|
expect(benchmarkSummaryRow(runs([300, 320]), 100, { a: 2 })).toMatch(/\| OVER +\|$/);
|
||||||
|
expect(benchmarkSummaryRow(runs([1]), 100, {})).toMatch(/\| no budget \|$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
|
import { calculatePlanningAsyncInternal } from '../services/frozen-engine/leave-planner.core.frozen.mjs';
|
||||||
import { createPlanningCalculation } from '../services/leave-planner.service';
|
import { createPlanningCalculation } from '../services/leave-planner.service';
|
||||||
import {
|
import {
|
||||||
|
BENCHMARK_BUDGET_MULTIPLIERS,
|
||||||
benchmarkReport,
|
benchmarkReport,
|
||||||
benchmarkScenarios,
|
benchmarkScenarios,
|
||||||
|
benchmarkSummaryHeader,
|
||||||
|
benchmarkSummaryRow,
|
||||||
|
budgetViolations,
|
||||||
runLeavePlannerBenchmark,
|
runLeavePlannerBenchmark,
|
||||||
} from './leave-planner-benchmark';
|
} from './leave-planner-benchmark';
|
||||||
|
|
||||||
@@ -64,7 +69,25 @@ describe('leave planner benchmark protocol', () => {
|
|||||||
),
|
),
|
||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
}
|
}
|
||||||
const measurements = await runLeavePlannerBenchmark(2);
|
// Frozen engine (a871f8f) on 'two years', best of three: the runner-speed yardstick.
|
||||||
|
const calibrationRequest = scenarios.find((item) => item.name === 'two years')!.request;
|
||||||
|
let calibrationMs = Infinity;
|
||||||
|
for (let run = 0; run < 3; run++) {
|
||||||
|
const request = structuredClone(calibrationRequest);
|
||||||
|
const start = performance.now();
|
||||||
|
await calculatePlanningAsyncInternal({ ...request, calculationDate: '2024-01-01' });
|
||||||
|
calibrationMs = Math.min(calibrationMs, performance.now() - start);
|
||||||
|
}
|
||||||
|
// Direct stdout writes show each row as soon as its scenario finishes.
|
||||||
|
const print = (text: string) => process.stdout.write(`${text}\n`);
|
||||||
|
print(
|
||||||
|
`\nCalibration: frozen engine on "two years", best of 3 = ${calibrationMs.toFixed(1)} ms` +
|
||||||
|
'\nBudget = limit x calibration; ratio = mean / calibration.\n',
|
||||||
|
);
|
||||||
|
print(benchmarkSummaryHeader());
|
||||||
|
const measurements = await runLeavePlannerBenchmark(2, (rows) =>
|
||||||
|
print(benchmarkSummaryRow(rows, calibrationMs)),
|
||||||
|
);
|
||||||
for (const [name, placements] of [
|
for (const [name, placements] of [
|
||||||
['two years', 60],
|
['two years', 60],
|
||||||
['three years', 90],
|
['three years', 90],
|
||||||
@@ -96,12 +119,20 @@ describe('leave planner benchmark protocol', () => {
|
|||||||
loaded[0].preparationMs + loaded[0].planningMs,
|
loaded[0].preparationMs + loaded[0].planningMs,
|
||||||
);
|
);
|
||||||
const report = benchmarkReport(measurements);
|
const report = benchmarkReport(measurements);
|
||||||
|
print(`\nRaw report (TSV):\n${report}\n`);
|
||||||
expect(report.split('\n')).toHaveLength(15);
|
expect(report.split('\n')).toHaveLength(15);
|
||||||
expect(report).toContain('planning_ms\twall_ms');
|
expect(report).toContain('planning_ms\twall_ms');
|
||||||
expect(report).toContain('planning_work_ms\tpeak_main_thread_heap_bytes');
|
expect(report).toContain('planning_work_ms\tpeak_main_thread_heap_bytes');
|
||||||
expect(report).toContain('\tbaseline\t');
|
expect(report).toContain('\tbaseline\t');
|
||||||
|
const violations = budgetViolations(
|
||||||
|
measurements,
|
||||||
|
calibrationMs,
|
||||||
|
BENCHMARK_BUDGET_MULTIPLIERS,
|
||||||
|
);
|
||||||
|
if (violations.length)
|
||||||
|
throw new Error(`Benchmark over budget:\n${violations.join('\n')}`);
|
||||||
} finally {
|
} finally {
|
||||||
vi.stubGlobal('window', browserWindow);
|
vi.stubGlobal('window', browserWindow);
|
||||||
}
|
}
|
||||||
}, 300_000);
|
}, 570_000);
|
||||||
});
|
});
|
||||||
@@ -193,18 +193,24 @@ function resultSummary(outcome: PlanningOutcome): {
|
|||||||
return { outcome: outcome.kind, placements: 0 };
|
return { outcome: outcome.kind, placements: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runLeavePlannerBenchmark(repetitions = 1): Promise<BenchmarkMeasurement[]> {
|
export async function runLeavePlannerBenchmark(
|
||||||
|
repetitions = 1,
|
||||||
|
onScenario?: (measurements: BenchmarkMeasurement[]) => void,
|
||||||
|
): Promise<BenchmarkMeasurement[]> {
|
||||||
const scenarios = benchmarkScenarios();
|
const scenarios = benchmarkScenarios();
|
||||||
for (const scenario of scenarios) await warmUpScenario(scenario);
|
for (const scenario of scenarios) await warmUpScenario(scenario);
|
||||||
|
|
||||||
const measurements: BenchmarkMeasurement[] = [];
|
const measurements: BenchmarkMeasurement[] = [];
|
||||||
for (const scenario of scenarios) {
|
for (const scenario of scenarios) {
|
||||||
|
const scenarioMeasurements: BenchmarkMeasurement[] = [];
|
||||||
let baseline: string | undefined;
|
let baseline: string | undefined;
|
||||||
for (let repetition = 1; repetition <= repetitions; repetition++) {
|
for (let repetition = 1; repetition <= repetitions; repetition++) {
|
||||||
const result = await measureRepetition(scenario, repetition, baseline);
|
const result = await measureRepetition(scenario, repetition, baseline);
|
||||||
measurements.push(result.measurement);
|
scenarioMeasurements.push(result.measurement);
|
||||||
baseline ??= result.stable;
|
baseline ??= result.stable;
|
||||||
}
|
}
|
||||||
|
measurements.push(...scenarioMeasurements);
|
||||||
|
onScenario?.(scenarioMeasurements);
|
||||||
}
|
}
|
||||||
return measurements;
|
return measurements;
|
||||||
}
|
}
|
||||||
@@ -294,3 +300,102 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string {
|
|||||||
),
|
),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Measured on the development machine (ratio to the frozen two-years run), tightened by #286.
|
||||||
|
export const BENCHMARK_BUDGET_MULTIPLIERS: Record<string, number> = {
|
||||||
|
'representative year': 0.25,
|
||||||
|
'typed obligations': 3,
|
||||||
|
'chained earned entitlements': 0.05,
|
||||||
|
'deliberate conflict': 0.05,
|
||||||
|
'loaded year': 25,
|
||||||
|
'two years': 1,
|
||||||
|
'three years': 3.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One message per scenario whose mean planning time exceeds multiplier x calibration, or that has no multiplier. */
|
||||||
|
export function budgetViolations(
|
||||||
|
measurements: BenchmarkMeasurement[],
|
||||||
|
calibrationMs: number,
|
||||||
|
multipliers: Record<string, number> = BENCHMARK_BUDGET_MULTIPLIERS,
|
||||||
|
): string[] {
|
||||||
|
const byScenario = new Map<string, number[]>();
|
||||||
|
for (const { scenario, planningMs } of measurements)
|
||||||
|
byScenario.set(scenario, [...(byScenario.get(scenario) ?? []), planningMs]);
|
||||||
|
const violations: string[] = [];
|
||||||
|
for (const [scenario, times] of byScenario) {
|
||||||
|
const mean = average(times);
|
||||||
|
const multiplier = multipliers[scenario];
|
||||||
|
if (multiplier === undefined) {
|
||||||
|
violations.push(`${scenario}: no budget multiplier defined`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const budget = multiplier * calibrationMs;
|
||||||
|
if (mean > budget)
|
||||||
|
violations.push(
|
||||||
|
`${scenario}: measured ${mean.toFixed(1)} ms > budget ${budget.toFixed(1)} ms (multiplier ${multiplier} x calibration ${calibrationMs.toFixed(1)} ms)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function average(values: number[]): number {
|
||||||
|
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed widths keep rows printed as each scenario finishes aligned with the header.
|
||||||
|
const SUMMARY_COLUMNS: [title: string, width: number, alignRight: boolean][] = [
|
||||||
|
['Scenario', 27, false],
|
||||||
|
['Planning (runs)', 17, true],
|
||||||
|
['Mean', 9, true],
|
||||||
|
['Budget', 9, true],
|
||||||
|
['Ratio', 6, true],
|
||||||
|
['Limit', 6, true],
|
||||||
|
['Outcome', 16, false],
|
||||||
|
['Placements', 10, true],
|
||||||
|
['Deterministic', 13, false],
|
||||||
|
['Status', 9, false],
|
||||||
|
];
|
||||||
|
|
||||||
|
function summaryLine(cells: string[]): string {
|
||||||
|
return `| ${cells
|
||||||
|
.map((cell, index) => {
|
||||||
|
const [, width, alignRight] = SUMMARY_COLUMNS[index];
|
||||||
|
return alignRight ? cell.padStart(width) : cell.padEnd(width);
|
||||||
|
})
|
||||||
|
.join(' | ')} |`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Markdown table header for {@link benchmarkSummaryRow}. */
|
||||||
|
export function benchmarkSummaryHeader(): string {
|
||||||
|
return [
|
||||||
|
summaryLine(SUMMARY_COLUMNS.map(([title]) => title)),
|
||||||
|
`|${SUMMARY_COLUMNS.map(([, width, alignRight]) => (alignRight ? `${'-'.repeat(width + 1)}:` : `:${'-'.repeat(width + 1)}`)).join('|')}|`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One aligned table row for one scenario's measured repetitions, judged like {@link budgetViolations}. */
|
||||||
|
export function benchmarkSummaryRow(
|
||||||
|
scenarioMeasurements: BenchmarkMeasurement[],
|
||||||
|
calibrationMs: number,
|
||||||
|
multipliers: Record<string, number> = BENCHMARK_BUDGET_MULTIPLIERS,
|
||||||
|
): string {
|
||||||
|
const [first] = scenarioMeasurements;
|
||||||
|
const seconds = (ms: number) => (ms / 1000).toFixed(2);
|
||||||
|
const mean = average(scenarioMeasurements.map(({ planningMs }) => planningMs));
|
||||||
|
const multiplier = multipliers[first.scenario];
|
||||||
|
const budget = multiplier === undefined ? undefined : multiplier * calibrationMs;
|
||||||
|
return summaryLine([
|
||||||
|
first.scenario,
|
||||||
|
`${scenarioMeasurements.map(({ planningMs }) => seconds(planningMs)).join(' / ')} s`,
|
||||||
|
`${seconds(mean)} s`,
|
||||||
|
budget === undefined ? '-' : `${seconds(budget)} s`,
|
||||||
|
(mean / calibrationMs).toFixed(2),
|
||||||
|
multiplier === undefined ? '-' : String(multiplier),
|
||||||
|
first.lastCompletedStage ? `${first.outcome} (${first.lastCompletedStage})` : first.outcome,
|
||||||
|
String(first.placements),
|
||||||
|
scenarioMeasurements.slice(1).every(({ deterministic }) => deterministic === 'yes')
|
||||||
|
? 'yes'
|
||||||
|
: 'no',
|
||||||
|
budget === undefined ? 'no budget' : mean > budget ? 'OVER' : 'ok',
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -184,14 +184,10 @@ export class CalendarMonth implements OnInit {
|
|||||||
|
|
||||||
getDayTooltip(day: Date): string | null {
|
getDayTooltip(day: Date): string | null {
|
||||||
const previewed = this.declarationPreview?.dates.has(this.getDateKey(day));
|
const previewed = this.declarationPreview?.dates.has(this.getDateKey(day));
|
||||||
const tooltips: string[] = this.selectedDates.datesSelected
|
const tooltips: string[] = this.selectedDates
|
||||||
.filter(
|
.dayTypes(day)
|
||||||
(dateSelected) =>
|
.filter((type) => !previewed || !['cp', 'rtt', 'other'].includes(type))
|
||||||
dateSelected.isSelected(day) &&
|
.map((type) => this.translationService.getDayTypeLabel(type));
|
||||||
dateSelected.type &&
|
|
||||||
(!previewed || !['cp', 'rtt', 'other'].includes(dateSelected.type)),
|
|
||||||
)
|
|
||||||
.map((dateSelected) => this.translationService.getDayTypeLabel(dateSelected.type!));
|
|
||||||
const declaredType = this.getDeclaredType(this.getDateKey(day));
|
const declaredType = this.getDeclaredType(this.getDateKey(day));
|
||||||
if (declaredType)
|
if (declaredType)
|
||||||
tooltips.push(
|
tooltips.push(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createOptimizationPresetRules } from '../../services/rule-service';
|
import { createOptimizationPresetRules } from '../../services/rule-service';
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../../testing/vitest-compat';
|
||||||
import { CalendarYear } from './calendar-year';
|
import { CalendarYear } from './calendar-year';
|
||||||
import { UserInput } from '../user-input/user-input';
|
import { UserInput } from '../user-input/user-input';
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
DayType,
|
DayType,
|
||||||
SelectedDate,
|
SelectedDate,
|
||||||
SelectedDates,
|
SelectedDates,
|
||||||
|
calendarProjectionHorizon,
|
||||||
SelectableDayType,
|
SelectableDayType,
|
||||||
} from '../../services/calendar-service';
|
} from '../../services/calendar-service';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +31,7 @@ import { CalendarSettingsService } from '../../services/calendar-settings-servic
|
|||||||
import { RuleService, RuleType } from '../../services/rule-service';
|
import { RuleService, RuleType } from '../../services/rule-service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||||
|
import { PlanningCalculationService } from '../../services/planning-calculation.service';
|
||||||
import { DateRange } from '@angular/material/datepicker';
|
import { DateRange } from '@angular/material/datepicker';
|
||||||
|
|
||||||
const invalid: InvalidInputOutcome = {
|
const invalid: InvalidInputOutcome = {
|
||||||
@@ -124,17 +126,39 @@ const explanation: PlanningExplanation = {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
function selectedDatesForPlan(value: PlanOutcome, includeHoliday = false): SelectedDates {
|
function projectionFor(
|
||||||
const selected = new SelectedDates();
|
value: Parameters<typeof calendarProjectionHorizon>[0] & {
|
||||||
|
horizon: { start: string; end: string };
|
||||||
|
},
|
||||||
|
includeHoliday = false,
|
||||||
|
) {
|
||||||
|
const horizon = calendarProjectionHorizon(value)!;
|
||||||
|
const months: { monthIndex: number; year: number }[] = [];
|
||||||
|
const last = new Date(`${horizon.end}T00:00:00`);
|
||||||
|
last.setDate(last.getDate() - 1);
|
||||||
|
const cursor = new Date(`${horizon.start}T00:00:00`);
|
||||||
|
cursor.setDate(1);
|
||||||
|
while (cursor <= last) {
|
||||||
|
months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
|
||||||
|
cursor.setMonth(cursor.getMonth() + 1);
|
||||||
|
}
|
||||||
|
return { months, selections: selectedDatesForPlan(value, includeHoliday) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedDatesForPlan(
|
||||||
|
value: { placements: readonly PlanOutcome['placements'][number][] },
|
||||||
|
includeHoliday = false,
|
||||||
|
): SelectedDates {
|
||||||
|
const ranges: SelectedDate[] = [];
|
||||||
if (includeHoliday) {
|
if (includeHoliday) {
|
||||||
const holiday = new Date('2024-05-08T00:00:00');
|
const holiday = new Date('2024-05-08T00:00:00');
|
||||||
selected.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday)));
|
ranges.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday)));
|
||||||
}
|
}
|
||||||
for (const placement of value.placements) {
|
for (const placement of value.placements) {
|
||||||
const date = new Date(`${placement.date}T00:00:00`);
|
const date = new Date(`${placement.date}T00:00:00`);
|
||||||
selected.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
|
ranges.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
|
||||||
}
|
}
|
||||||
return selected;
|
return new SelectedDates(ranges);
|
||||||
}
|
}
|
||||||
|
|
||||||
function placementForSelection(selection: CounterfactualSelection): LeavePlacement {
|
function placementForSelection(selection: CounterfactualSelection): LeavePlacement {
|
||||||
@@ -208,11 +232,9 @@ function calendarFor(outcome: PlanningOutcome, stableExplanation?: PlanningExpla
|
|||||||
return {
|
return {
|
||||||
calculation,
|
calculation,
|
||||||
calendar: {
|
calendar: {
|
||||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
|
||||||
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
|
|
||||||
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
|
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
|
||||||
createPlanningCalculation: vi.fn(() => calculation),
|
createPlanningCalculation: vi.fn(() => calculation),
|
||||||
selectedDatesForPlan: vi.fn(selectedDatesForPlan),
|
project: vi.fn((value: Parameters<typeof projectionFor>[0]) => projectionFor(value)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -474,7 +496,9 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const before = {
|
const before = {
|
||||||
balances: structuredClone(input.vacationNumberSignal()),
|
balances: structuredClone(input.vacationNumberSignal()),
|
||||||
remaining: structuredClone(input.remainingVacationDaysSignal()),
|
displayed: structuredClone(
|
||||||
|
TestBed.inject(PlanningCalculationService).displayedPlan(),
|
||||||
|
),
|
||||||
declarations: structuredClone(input.declaredDaysSignal()),
|
declarations: structuredClone(input.declaredDaysSignal()),
|
||||||
rules: structuredClone(rules.rules),
|
rules: structuredClone(rules.rules),
|
||||||
};
|
};
|
||||||
@@ -506,9 +530,11 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
];
|
];
|
||||||
expect(statistics()).toEqual(expectedStatistics);
|
expect(statistics()).toEqual(expectedStatistics);
|
||||||
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
|
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
|
||||||
expect(input.planningOutcomeSignal()).toBe(outcome);
|
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
|
||||||
expect(input.vacationNumberSignal()).toEqual(before.balances);
|
expect(input.vacationNumberSignal()).toEqual(before.balances);
|
||||||
expect(input.remainingVacationDaysSignal()).toEqual(before.remaining);
|
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toEqual(
|
||||||
|
before.displayed,
|
||||||
|
);
|
||||||
expect(input.declaredDaysSignal()).toEqual(before.declarations);
|
expect(input.declaredDaysSignal()).toEqual(before.declarations);
|
||||||
expect(rules.rules).toEqual(before.rules);
|
expect(rules.rules).toEqual(before.rules);
|
||||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||||||
@@ -525,7 +551,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
expect(statistics()).toEqual(expectedStatistics);
|
expect(statistics()).toEqual(expectedStatistics);
|
||||||
expect(input.planningOutcomeSignal()).toBe(outcome);
|
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
|
||||||
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1047,7 +1073,6 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
{ provide: CalendarService, useValue: calendar },
|
{ provide: CalendarService, useValue: calendar },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const input = TestBed.inject(UserInputService);
|
|
||||||
const fixture = TestBed.createComponent(CalendarYear);
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
@@ -1056,10 +1081,10 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
|
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
||||||
input.requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
|
||||||
input.requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const otherControl = document.createElement('button');
|
const otherControl = document.createElement('button');
|
||||||
otherControl.textContent = 'Another control';
|
otherControl.textContent = 'Another control';
|
||||||
@@ -1071,7 +1096,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
oldOutcome.resolve(plan);
|
oldOutcome.resolve(plan);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(input.planningOutcomeSignal()).toBe(replacement);
|
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(replacement);
|
||||||
const opener = root.querySelector<HTMLButtonElement>('.statistics-button');
|
const opener = root.querySelector<HTMLButtonElement>('.statistics-button');
|
||||||
if (replacement.kind === 'Plan') {
|
if (replacement.kind === 'Plan') {
|
||||||
expect(opener).not.toBeNull();
|
expect(opener).not.toBeNull();
|
||||||
@@ -1747,7 +1772,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const submitted = duration.request()!;
|
const submitted = TestBed.inject(PlanningCalculationService).request()!;
|
||||||
calendar.createPlanningCalculation.mockReturnValue({
|
calendar.createPlanningCalculation.mockReturnValue({
|
||||||
...calculation,
|
...calculation,
|
||||||
outcome: pending.promise,
|
outcome: pending.promise,
|
||||||
@@ -1849,11 +1874,13 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const settings = TestBed.inject(CalendarSettingsService);
|
const settings = TestBed.inject(CalendarSettingsService);
|
||||||
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
||||||
const input = TestBed.inject(UserInputService);
|
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
calendar.createPlanningCalculation.mockImplementation(() => ({
|
calendar.createPlanningCalculation.mockImplementation(() => ({
|
||||||
...calculation,
|
...calculation,
|
||||||
snapshot: { ...calculation.snapshot, ...duration.request()! },
|
snapshot: {
|
||||||
|
...calculation.snapshot,
|
||||||
|
...TestBed.inject(PlanningCalculationService).request()!,
|
||||||
|
},
|
||||||
outcome:
|
outcome:
|
||||||
calendar.createPlanningCalculation.mock.calls.length === 1
|
calendar.createPlanningCalculation.mock.calls.length === 1
|
||||||
? old.promise
|
? old.promise
|
||||||
@@ -1863,7 +1890,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
now = 11_000;
|
now = 11_000;
|
||||||
settings.setPlanningDates('2024-01-01', '2026-12-31');
|
settings.setPlanningDates('2024-01-01', '2026-12-31');
|
||||||
input.requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
now = 12_000; // Rendering/scheduling after the request is part of the wait.
|
now = 12_000; // Rendering/scheduling after the request is part of the wait.
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const initial = duration.estimate();
|
const initial = duration.estimate();
|
||||||
@@ -1885,7 +1912,10 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
const revisitedOutcome = deferred<PlanningOutcome>();
|
const revisitedOutcome = deferred<PlanningOutcome>();
|
||||||
calendar.createPlanningCalculation.mockReturnValueOnce({
|
calendar.createPlanningCalculation.mockReturnValueOnce({
|
||||||
...calculation,
|
...calculation,
|
||||||
snapshot: { ...calculation.snapshot, ...duration.request()! },
|
snapshot: {
|
||||||
|
...calculation.snapshot,
|
||||||
|
...TestBed.inject(PlanningCalculationService).request()!,
|
||||||
|
},
|
||||||
outcome: revisitedOutcome.promise,
|
outcome: revisitedOutcome.promise,
|
||||||
});
|
});
|
||||||
now = 631_000; // Returning to the calendar is a fresh initial calculation.
|
now = 631_000; // Returning to the calendar is a fresh initial calculation.
|
||||||
@@ -1947,7 +1977,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
expect(root.querySelectorAll('button.selected')).toHaveLength(withIncumbent ? 2 : 0);
|
expect(root.querySelectorAll('button.selected')).toHaveLength(withIncumbent ? 2 : 0);
|
||||||
expect(root.querySelector('.loading-overlay')).toBeNull();
|
expect(root.querySelector('.loading-overlay')).toBeNull();
|
||||||
expect(document.activeElement).toBe(root.querySelector('#planning-results-title'));
|
expect(document.activeElement).toBe(root.querySelector('#planning-results-title'));
|
||||||
TestBed.inject(UserInputService).requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -1958,6 +1988,146 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('does not calibrate or announce a previous result when the planner is remounted', async () => {
|
||||||
|
const { calendar } = calendarFor(plan, explanation);
|
||||||
|
calendar.createPlanningCalculation
|
||||||
|
.mockReturnValueOnce(calculationFor(plan, explanation))
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
...calculationFor(plan, explanation),
|
||||||
|
outcome: new Promise(() => undefined),
|
||||||
|
});
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const record = vi.spyOn(TestBed.inject(PlanningDurationService), 'recordCompletion');
|
||||||
|
const settle = async (fixture: ComponentFixture<CalendarYear>) => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
};
|
||||||
|
const first = TestBed.createComponent(CalendarYear);
|
||||||
|
await settle(first);
|
||||||
|
expect(record).toHaveBeenCalledTimes(1);
|
||||||
|
first.destroy();
|
||||||
|
|
||||||
|
const second = TestBed.createComponent(CalendarYear);
|
||||||
|
await settle(second);
|
||||||
|
expect(record).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second.componentInstance['liveMessage']()).toBe(
|
||||||
|
second.componentInstance['tr']().loading,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not announce a previous rejected explanation when the planner is remounted', async () => {
|
||||||
|
const { calendar } = calendarFor(plan, explanation);
|
||||||
|
const rejected = {
|
||||||
|
...calculationFor(plan, explanation),
|
||||||
|
explanation: Promise.reject(new Error('explanation exploded')),
|
||||||
|
};
|
||||||
|
rejected.explanation.catch(() => undefined);
|
||||||
|
calendar.createPlanningCalculation.mockReturnValueOnce(rejected).mockReturnValueOnce({
|
||||||
|
...calculationFor(plan, explanation),
|
||||||
|
outcome: new Promise(() => undefined),
|
||||||
|
});
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const settle = async (fixture: ComponentFixture<CalendarYear>) => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
};
|
||||||
|
const first = TestBed.createComponent(CalendarYear);
|
||||||
|
await settle(first);
|
||||||
|
expect(first.componentInstance['liveMessage']()).toContain('explanation exploded');
|
||||||
|
first.destroy();
|
||||||
|
|
||||||
|
const second = TestBed.createComponent(CalendarYear);
|
||||||
|
await settle(second);
|
||||||
|
expect(second.componentInstance['liveMessage']()).toBe(
|
||||||
|
second.componentInstance['tr']().loading,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces loading and keeps focus when a calculation starts during a cancellation', async () => {
|
||||||
|
const first = deferred<PlanningOutcome>();
|
||||||
|
const { calendar, calculation } = calendarFor(plan, undefined);
|
||||||
|
calendar.createPlanningCalculation
|
||||||
|
.mockReturnValueOnce({ ...calculation, outcome: first.promise })
|
||||||
|
.mockReturnValueOnce({ ...calculation, outcome: new Promise(() => undefined) });
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const live = () => fixture.componentInstance['liveMessage']();
|
||||||
|
const tr = fixture.componentInstance['tr']();
|
||||||
|
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.cancel-calculation')!);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(live()).toBe(tr.planning.cancelling);
|
||||||
|
const outside = document.createElement('button');
|
||||||
|
document.body.append(outside);
|
||||||
|
outside.focus();
|
||||||
|
|
||||||
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
first.resolve(plan);
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(outside);
|
||||||
|
expect(live()).toBe(tr.loading);
|
||||||
|
outside.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a delivered explanation error', false],
|
||||||
|
['a rejected explanation', true],
|
||||||
|
])('announces only %s when it rejected', async (_, rejected) => {
|
||||||
|
const reason = { code: 'explanation-error', message: 'Explanation boom' };
|
||||||
|
const { calendar } = calendarFor(plan, undefined);
|
||||||
|
calendar.createPlanningCalculation.mockReturnValue({
|
||||||
|
...calculationFor(plan, undefined),
|
||||||
|
explanation: rejected
|
||||||
|
? Promise.reject(new Error(reason.message))
|
||||||
|
: Promise.resolve(undefined),
|
||||||
|
explanationError: Promise.resolve(rejected ? undefined : reason),
|
||||||
|
});
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const service = TestBed.inject(PlanningCalculationService);
|
||||||
|
expect(service.explanationError()).toMatchObject(reason);
|
||||||
|
expect(service.explanationRejected()).toBe(rejected);
|
||||||
|
expect(fixture.componentInstance['liveMessage']().includes(reason.message)).toBe(rejected);
|
||||||
|
});
|
||||||
|
|
||||||
it('displays an accepted historical year below 100 without shifting it to the twentieth century', async () => {
|
it('displays an accepted historical year below 100 without shifting it to the twentieth century', async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CalendarYear],
|
imports: [CalendarYear],
|
||||||
@@ -2051,7 +2221,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
const fixture = TestBed.createComponent(CalendarYear);
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
settings.setPlanningDates('2021-05-14', '2021-05-14');
|
settings.setPlanningDates('2021-05-14', '2021-05-14');
|
||||||
TestBed.inject(UserInputService).requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
oldOutcome.resolve(plan);
|
oldOutcome.resolve(plan);
|
||||||
@@ -2117,7 +2287,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
expect(calculate).toHaveBeenCalledTimes(1);
|
expect(calculate).toHaveBeenCalledTimes(1);
|
||||||
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
|
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
|
||||||
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
|
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
|
||||||
input.requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -2156,6 +2326,9 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
['PlanningIncomplete without incumbent', incomplete],
|
['PlanningIncomplete without incumbent', incomplete],
|
||||||
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
|
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
|
||||||
const { calendar } = calendarFor(outcome);
|
const { calendar } = calendarFor(outcome);
|
||||||
|
calendar.createPlanningCalculation
|
||||||
|
.mockReturnValueOnce(calculationFor(plan, explanation))
|
||||||
|
.mockReturnValueOnce(calculationFor(outcome));
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CalendarYear],
|
imports: [CalendarYear],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -2163,20 +2336,27 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
{ provide: CalendarService, useValue: calendar },
|
{ provide: CalendarService, useValue: calendar },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const userInput = TestBed.inject(UserInputService);
|
|
||||||
userInput.remainingVacationDaysSignal.set([
|
|
||||||
{
|
|
||||||
id: 'stale',
|
|
||||||
type: SelectableDayType.CP,
|
|
||||||
numberOfDays: 1,
|
|
||||||
expiryDate: new Date('2025-01-01'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const fixture: ComponentFixture<CalendarYear> = TestBed.createComponent(CalendarYear);
|
const fixture: ComponentFixture<CalendarYear> = TestBed.createComponent(CalendarYear);
|
||||||
|
const service = TestBed.inject(PlanningCalculationService);
|
||||||
|
const settle = async () => {
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
};
|
||||||
|
await settle();
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(service.displayedPlan()).toBeDefined();
|
||||||
|
expect(
|
||||||
|
Array.from(root.querySelectorAll('.plan-summary dd')).map((e) => e.textContent?.trim()),
|
||||||
|
).toEqual(['2', '4', '6']);
|
||||||
|
|
||||||
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
|
service.start();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(service.displayedPlan()).toBeUndefined();
|
||||||
|
expect(root.querySelector('.plan-summary')).toBeNull();
|
||||||
|
expect(root.querySelectorAll('button.selected')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps and summarizes a returned result', async () => {
|
it('keeps and summarizes a returned result', async () => {
|
||||||
@@ -2188,14 +2368,14 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
{ provide: CalendarService, useValue: calendar },
|
{ provide: CalendarService, useValue: calendar },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const userInput = TestBed.inject(UserInputService);
|
|
||||||
userInput.remainingVacationDaysSignal.set([]);
|
|
||||||
const fixture = TestBed.createComponent(CalendarYear);
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
|
expect(
|
||||||
|
TestBed.inject(PlanningCalculationService).displayedPlan()?.remainingBalances,
|
||||||
|
).toEqual(plan.remainingBalances);
|
||||||
const summaryValues = Array.from(
|
const summaryValues = Array.from(
|
||||||
fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf<HTMLElement>,
|
fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf<HTMLElement>,
|
||||||
).map((element) => element.textContent?.trim());
|
).map((element) => element.textContent?.trim());
|
||||||
@@ -2340,9 +2520,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
placeType: 'cp',
|
placeType: 'cp',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
calendar.selectedDatesForPlan.mockImplementation((value) =>
|
calendar.project.mockImplementation((value) => projectionFor(value, true));
|
||||||
selectedDatesForPlan(value, true),
|
|
||||||
);
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CalendarYear],
|
imports: [CalendarYear],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -2547,7 +2725,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
);
|
);
|
||||||
expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1);
|
expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
TestBed.inject(UserInputService).requestComputation();
|
TestBed.inject(PlanningCalculationService).start();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
@@ -2952,14 +3130,12 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
|
|
||||||
it('turns planner rejection into an incomplete error and clears loading', async () => {
|
it('turns planner rejection into an incomplete error and clears loading', async () => {
|
||||||
const calendar = {
|
const calendar = {
|
||||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
|
||||||
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
|
|
||||||
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
|
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
|
||||||
createPlanningCalculation: vi.fn(() => ({
|
createPlanningCalculation: vi.fn(() => ({
|
||||||
...calculationFor(plan),
|
...calculationFor(plan),
|
||||||
outcome: Promise.reject(new Error('planner exploded')),
|
outcome: Promise.reject(new Error('planner exploded')),
|
||||||
})),
|
})),
|
||||||
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
|
project: vi.fn((value: Parameters<typeof projectionFor>[0]) => projectionFor(value)),
|
||||||
};
|
};
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [CalendarYear],
|
imports: [CalendarYear],
|
||||||
@@ -2968,15 +3144,6 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
{ provide: CalendarService, useValue: calendar },
|
{ provide: CalendarService, useValue: calendar },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const userInput = TestBed.inject(UserInputService);
|
|
||||||
userInput.remainingVacationDaysSignal.set([
|
|
||||||
{
|
|
||||||
id: 'stale',
|
|
||||||
type: SelectableDayType.CP,
|
|
||||||
numberOfDays: 1,
|
|
||||||
expiryDate: new Date('2025-01-01'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const fixture = TestBed.createComponent(CalendarYear);
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
@@ -2988,6 +3155,6 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
if (outcome?.kind === 'PlanningIncomplete')
|
if (outcome?.kind === 'PlanningIncomplete')
|
||||||
expect(outcome.reasons[0].code).toBe('planner-error');
|
expect(outcome.reasons[0].code).toBe('planner-error');
|
||||||
expect(fixture.componentInstance['isLoading']()).toBe(false);
|
expect(fixture.componentInstance['isLoading']()).toBe(false);
|
||||||
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
|
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -33,26 +33,17 @@ import {
|
|||||||
import { CalendarMonth, DeclarationPreview } from '../calendar-month/calendar-month';
|
import { CalendarMonth, DeclarationPreview } from '../calendar-month/calendar-month';
|
||||||
import { addPlanningDays } from '../../services/planning-date';
|
import { addPlanningDays } from '../../services/planning-date';
|
||||||
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
|
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
|
||||||
import {
|
import { CalendarService, SelectedDates } from '../../services/calendar-service';
|
||||||
CalendarService,
|
|
||||||
calendarProjectionHorizon,
|
|
||||||
SelectedDates,
|
|
||||||
SelectableDayType,
|
|
||||||
VacDay,
|
|
||||||
} from '../../services/calendar-service';
|
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||||
|
import { PlanningCalculationService } from '../../services/planning-calculation.service';
|
||||||
import {
|
import {
|
||||||
CounterfactualAnalysis,
|
CounterfactualAnalysis,
|
||||||
CounterfactualSelection,
|
CounterfactualSelection,
|
||||||
LeaveType,
|
LeaveType,
|
||||||
DeclaredNonWorkingDay,
|
|
||||||
PlanExplanation,
|
PlanExplanation,
|
||||||
PlanOutcome,
|
|
||||||
PlanningCalculationContext,
|
|
||||||
PlanningExplanation,
|
|
||||||
PlanningObjective,
|
PlanningObjective,
|
||||||
PlanningObjectiveVector,
|
PlanningObjectiveVector,
|
||||||
PlanningOutcome,
|
PlanningOutcome,
|
||||||
@@ -89,6 +80,7 @@ export class CalendarYear {
|
|||||||
private readonly calendarService = inject(CalendarService);
|
private readonly calendarService = inject(CalendarService);
|
||||||
private readonly settings = inject(CalendarSettingsService);
|
private readonly settings = inject(CalendarSettingsService);
|
||||||
private readonly duration = inject(PlanningDurationService);
|
private readonly duration = inject(PlanningDurationService);
|
||||||
|
private readonly calculations = inject(PlanningCalculationService);
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
||||||
@@ -101,13 +93,13 @@ export class CalendarYear {
|
|||||||
private statisticsDialog: MatDialogRef<unknown> | null = null;
|
private statisticsDialog: MatDialogRef<unknown> | null = null;
|
||||||
protected readonly translationService = inject(TranslationService);
|
protected readonly translationService = inject(TranslationService);
|
||||||
|
|
||||||
protected readonly isLoading = signal(false);
|
protected readonly isLoading = this.calculations.isCalculating;
|
||||||
protected readonly isCancelling = signal(false);
|
protected readonly isCancelling = this.calculations.isCancelling;
|
||||||
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
|
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
|
||||||
protected readonly loadingProgress = signal(0);
|
protected readonly loadingProgress = this.calculations.progress;
|
||||||
protected readonly planningOutcome = this.userInput.planningOutcomeSignal;
|
protected readonly planningOutcome = this.calculations.outcome;
|
||||||
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
|
protected readonly planningExplanation = this.calculations.explanation;
|
||||||
protected readonly explanationError = signal<PlanningReason | null>(null);
|
protected readonly explanationError = this.calculations.explanationError;
|
||||||
protected readonly selectedPlacementIndex = signal(0);
|
protected readonly selectedPlacementIndex = signal(0);
|
||||||
protected readonly planDetailsOpen = signal(false);
|
protected readonly planDetailsOpen = signal(false);
|
||||||
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
|
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
|
||||||
@@ -118,8 +110,7 @@ export class CalendarYear {
|
|||||||
protected readonly invalidComparisonDate = signal(false);
|
protected readonly invalidComparisonDate = signal(false);
|
||||||
protected readonly comparisonDateInput = signal<Date | null>(null);
|
protected readonly comparisonDateInput = signal<Date | null>(null);
|
||||||
protected readonly liveMessage = signal('');
|
protected readonly liveMessage = signal('');
|
||||||
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
|
protected readonly planningHorizon = this.calculations.displayedHorizon;
|
||||||
protected readonly selectedDates = signal(new SelectedDates());
|
|
||||||
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
|
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
|
||||||
private readonly monthGrid = viewChild<ElementRef<HTMLElement>>('monthGrid');
|
private readonly monthGrid = viewChild<ElementRef<HTMLElement>>('monthGrid');
|
||||||
private readonly declarationDrag = signal<{
|
private readonly declarationDrag = signal<{
|
||||||
@@ -144,21 +135,21 @@ export class CalendarYear {
|
|||||||
}
|
}
|
||||||
return { dates, type: this.userInput.declaredTypeSignal() };
|
return { dates, type: this.userInput.declaredTypeSignal() };
|
||||||
});
|
});
|
||||||
private readonly resultDeclaredDays = signal<readonly DeclaredNonWorkingDay[]>([]);
|
|
||||||
protected readonly calendarHorizon = computed(() =>
|
protected readonly calendarHorizon = computed(() =>
|
||||||
this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(),
|
this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(),
|
||||||
);
|
);
|
||||||
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
|
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
|
||||||
const days = this.declarationEntry()
|
const days = this.declarationEntry()
|
||||||
? this.userInput.declaredDaysSignal()
|
? this.userInput.declaredDaysSignal()
|
||||||
: (this.displayedPlan()?.declaredDays ?? this.resultDeclaredDays());
|
: (this.calculations.accepted()?.declaredDays ?? []);
|
||||||
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
|
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
|
||||||
});
|
});
|
||||||
|
|
||||||
private calculationVersion = 0;
|
|
||||||
private counterfactualVersion = 0;
|
private counterfactualVersion = 0;
|
||||||
private activeCalculation: PlanningCalculationContext | null = null;
|
private wasCancelling = false;
|
||||||
private runningCalculation: PlanningCalculationContext | null = null;
|
private readonly initialAccepted = this.calculations.accepted();
|
||||||
|
private readonly initialExplanation = this.calculations.explanation();
|
||||||
|
private readonly initialExplanationError = this.calculations.explanationError();
|
||||||
private displayDateLocale = '';
|
private displayDateLocale = '';
|
||||||
private displayDateFormatter?: Intl.DateTimeFormat;
|
private displayDateFormatter?: Intl.DateTimeFormat;
|
||||||
|
|
||||||
@@ -166,7 +157,7 @@ export class CalendarYear {
|
|||||||
return this.translationService.tr;
|
return this.translationService.tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
|
protected readonly displayedPlan = this.calculations.displayedPlan;
|
||||||
protected readonly weekendConnectedHolidays = computed(() => {
|
protected readonly weekendConnectedHolidays = computed(() => {
|
||||||
const isWeekend = (date: string) => {
|
const isWeekend = (date: string) => {
|
||||||
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
|
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
|
||||||
@@ -277,20 +268,22 @@ export class CalendarYear {
|
|||||||
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
|
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
|
||||||
);
|
);
|
||||||
|
|
||||||
protected readonly calendarSelectedDates = computed(() => {
|
protected readonly calendarProjection = computed(() => {
|
||||||
if (this.declarationEntry()) {
|
|
||||||
const horizon = this.calendarHorizon();
|
const horizon = this.calendarHorizon();
|
||||||
return horizon
|
if (!horizon) return undefined;
|
||||||
? this.calendarService.selectedDatesForPlan({
|
if (this.declarationEntry())
|
||||||
placements: [],
|
return this.calendarService.project(
|
||||||
horizon,
|
{ placements: [], horizon, declaredDays: this.userInput.declaredDaysSignal() },
|
||||||
declaredDays: this.userInput.declaredDaysSignal(),
|
{ fixedMonths: true },
|
||||||
})
|
);
|
||||||
: new SelectedDates();
|
const plan = this.displayedPlan();
|
||||||
}
|
return this.calendarService.project(
|
||||||
|
plan ? { ...plan, horizon: plan.horizon ?? horizon } : { placements: [], horizon },
|
||||||
return this.selectedDates();
|
);
|
||||||
});
|
});
|
||||||
|
protected readonly calendarSelectedDates = computed(
|
||||||
|
() => this.calendarProjection()?.selections ?? new SelectedDates(),
|
||||||
|
);
|
||||||
|
|
||||||
protected readonly currentPlacementDate = computed(() => {
|
protected readonly currentPlacementDate = computed(() => {
|
||||||
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
|
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
|
||||||
@@ -300,26 +293,7 @@ export class CalendarYear {
|
|||||||
this.planDetailsOpen() ? this.selectedExplanation()?.timeOffPeriod : undefined,
|
this.planDetailsOpen() ? this.selectedExplanation()?.timeOffPeriod : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
protected readonly months = computed(() => {
|
protected readonly months = computed(() => this.calendarProjection()?.months ?? []);
|
||||||
const horizon = this.calendarHorizon();
|
|
||||||
if (!horizon) return [];
|
|
||||||
const plan = this.displayedPlan();
|
|
||||||
const projectionHorizon =
|
|
||||||
!this.declarationEntry() && plan
|
|
||||||
? (calendarProjectionHorizon(plan) ?? horizon)
|
|
||||||
: horizon;
|
|
||||||
const first = new Date(`${projectionHorizon.start}T00:00:00`);
|
|
||||||
const last = new Date(`${projectionHorizon.end}T00:00:00`);
|
|
||||||
last.setDate(last.getDate() - 1);
|
|
||||||
const result: { monthIndex: number; year: number }[] = [];
|
|
||||||
const cursor = new Date(first);
|
|
||||||
cursor.setDate(1);
|
|
||||||
while (cursor <= last) {
|
|
||||||
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
|
|
||||||
cursor.setMonth(cursor.getMonth() + 1);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
|
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
|
||||||
@@ -342,188 +316,75 @@ export class CalendarYear {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
effect(() => {
|
effect(() => {
|
||||||
this.userInput.computeTriggerSignal();
|
if (this.calculations.pendingId() !== null)
|
||||||
untracked(() => this.startCalculation());
|
untracked(() => this.liveMessage.set(this.tr().loading));
|
||||||
});
|
});
|
||||||
}
|
effect(() => {
|
||||||
|
const pending = this.calculations.pendingId() !== null;
|
||||||
private startCalculation(): void {
|
const cancelling = this.isCancelling();
|
||||||
const requestedAt = this.userInput.computationRequestedAtMs() ?? performance.now();
|
untracked(() => {
|
||||||
this.userInput.computationRequestedAtMs.set(null);
|
if (this.wasCancelling && !pending) this.resultsTitle()?.nativeElement.focus();
|
||||||
const request = this.duration.request();
|
this.wasCancelling = cancelling;
|
||||||
if (!request) return;
|
|
||||||
const { horizon, rules: currentRules } = request;
|
|
||||||
this.declarationEntry.set(false);
|
|
||||||
if (!this.planningHorizon()) this.planningHorizon.set(horizon);
|
|
||||||
const version = ++this.calculationVersion;
|
|
||||||
this.isLoading.set(true);
|
|
||||||
this.isCancelling.set(false);
|
|
||||||
this.loadingProgress.set(0);
|
|
||||||
this.liveMessage.set(this.tr().loading);
|
|
||||||
let calculation: PlanningCalculationContext;
|
|
||||||
let completed = false;
|
|
||||||
try {
|
|
||||||
calculation = this.calendarService.createPlanningCalculation({
|
|
||||||
...request,
|
|
||||||
balances: request.balances.map((balance) => ({
|
|
||||||
...balance,
|
|
||||||
label: this.translationService.getDayTypeLabel(balance.type),
|
|
||||||
})),
|
|
||||||
labels: Object.fromEntries(
|
|
||||||
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
|
|
||||||
),
|
|
||||||
progress: (stage) => {
|
|
||||||
if (version === this.calculationVersion)
|
|
||||||
this.loadingProgress.set(this.stageProgress(stage));
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
this.resetCalculationState(horizon);
|
|
||||||
this.planningHorizon.set(horizon);
|
|
||||||
this.failCalculation(error, version);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.runningCalculation = calculation;
|
|
||||||
void calculation.outcome
|
|
||||||
.then((outcome) => {
|
|
||||||
if (version !== this.calculationVersion) return;
|
|
||||||
this.resetCalculationState(horizon);
|
|
||||||
this.activeCalculation = calculation;
|
|
||||||
this.userInput.planningSnapshotSignal.set(calculation.snapshot);
|
|
||||||
this.planningHorizon.set(horizon);
|
|
||||||
this.applyOutcome(outcome);
|
|
||||||
this.loadArtifacts(calculation);
|
|
||||||
completed = outcome.kind === 'Plan' && !this.isCancelling();
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (version !== this.calculationVersion) return;
|
|
||||||
this.resetCalculationState(horizon);
|
|
||||||
this.activeCalculation = calculation;
|
|
||||||
this.userInput.planningSnapshotSignal.set(calculation.snapshot);
|
|
||||||
this.planningHorizon.set(horizon);
|
|
||||||
this.applyOutcome({
|
|
||||||
kind: 'PlanningIncomplete',
|
|
||||||
completedStages: [],
|
|
||||||
reasons: [
|
|
||||||
{
|
|
||||||
code: 'planner-error',
|
|
||||||
message:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'The planner failed unexpectedly.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
this.loadArtifacts(calculation);
|
effect(() => {
|
||||||
})
|
const accepted = this.calculations.accepted();
|
||||||
.finally(() => {
|
if (!accepted || accepted === this.initialAccepted) return;
|
||||||
if (version !== this.calculationVersion) return;
|
untracked(() => {
|
||||||
this.runningCalculation = null;
|
this.closeStatistics();
|
||||||
this.loadingProgress.set(100);
|
this.selectedPlacementIndex.set(0);
|
||||||
this.isLoading.set(false);
|
this.planDetailsOpen.set(false);
|
||||||
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
|
this.clearCounterfactual();
|
||||||
this.isCancelling.set(false);
|
this.liveMessage.set(this.outcomeLabel(accepted.outcome));
|
||||||
if (completed && !this.destroyRef.destroyed)
|
if (accepted.successful && !this.destroyRef.destroyed)
|
||||||
afterNextRender(
|
afterNextRender(
|
||||||
() => {
|
() => {
|
||||||
if (version === this.calculationVersion && !this.destroyRef.destroyed)
|
if (
|
||||||
|
this.calculations.accepted() === accepted &&
|
||||||
|
!this.destroyRef.destroyed &&
|
||||||
|
accepted.snapshot
|
||||||
|
)
|
||||||
this.duration.recordCompletion(
|
this.duration.recordCompletion(
|
||||||
calculation.snapshot,
|
accepted.snapshot,
|
||||||
performance.now() - requestedAt,
|
performance.now() - accepted.requestedAt,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ injector: this.injector },
|
{ injector: this.injector },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
const explanation = this.calculations.explanation();
|
||||||
|
if (explanation?.kind !== 'PlanExplanation' || explanation === this.initialExplanation)
|
||||||
|
return;
|
||||||
|
untracked(() => {
|
||||||
|
this.selectPlacement(0, false);
|
||||||
|
const outcome = this.planningOutcome();
|
||||||
|
this.liveMessage.set(
|
||||||
|
`${outcome ? this.outcomeLabel(outcome) : ''} ${this.placementAnnouncement()}`.trim(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
const reason = this.explanationError();
|
||||||
|
if (
|
||||||
|
reason &&
|
||||||
|
reason !== this.initialExplanationError &&
|
||||||
|
this.calculations.explanationRejected()
|
||||||
|
)
|
||||||
|
untracked(() =>
|
||||||
|
this.liveMessage.set(
|
||||||
|
`${this.tr().planning.explanationError} ${reason.message}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this.calculations.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected cancelCalculation(): void {
|
protected cancelCalculation(): void {
|
||||||
if (!this.isLoading() || this.isCancelling() || !this.runningCalculation) return;
|
if (!this.isLoading() || this.isCancelling()) return;
|
||||||
this.isCancelling.set(true);
|
this.calculations.cancel();
|
||||||
this.liveMessage.set(this.tr().planning.cancelling);
|
this.liveMessage.set(this.tr().planning.cancelling);
|
||||||
this.runningCalculation.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
private failCalculation(error: unknown, version: number): void {
|
|
||||||
if (version !== this.calculationVersion) return;
|
|
||||||
const reason = {
|
|
||||||
code: 'planner-error',
|
|
||||||
message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
|
|
||||||
};
|
|
||||||
this.applyOutcome({ kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] });
|
|
||||||
this.explanationError.set(reason);
|
|
||||||
this.loadingProgress.set(100);
|
|
||||||
this.isLoading.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetCalculationState(horizon: { start: string; end: string }): void {
|
|
||||||
this.closeStatistics();
|
|
||||||
this.activeCalculation = null;
|
|
||||||
this.userInput.planningSnapshotSignal.set(null);
|
|
||||||
this.planningOutcome.set(null);
|
|
||||||
this.planningExplanation.set(null);
|
|
||||||
this.explanationError.set(null);
|
|
||||||
this.selectedPlacementIndex.set(0);
|
|
||||||
this.planDetailsOpen.set(false);
|
|
||||||
this.clearCounterfactual();
|
|
||||||
this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
|
|
||||||
this.userInput.remainingVacationDaysSignal.set([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadArtifacts(calculation: PlanningCalculationContext): void {
|
|
||||||
void calculation.explanation
|
|
||||||
.then((explanation) => {
|
|
||||||
if (calculation !== this.activeCalculation) return;
|
|
||||||
this.planningExplanation.set(explanation ?? null);
|
|
||||||
if (explanation?.kind === 'PlanExplanation') {
|
|
||||||
this.selectPlacement(0, false);
|
|
||||||
this.liveMessage.set(
|
|
||||||
`${this.planningOutcome() ? this.outcomeLabel(this.planningOutcome()!) : ''} ${this.placementAnnouncement()}`.trim(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (calculation !== this.activeCalculation) return;
|
|
||||||
const reason = {
|
|
||||||
code: 'explanation-error',
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
this.explanationError.set(reason);
|
|
||||||
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
|
|
||||||
});
|
|
||||||
void calculation.explanationError
|
|
||||||
.then((reason) => {
|
|
||||||
if (calculation === this.activeCalculation)
|
|
||||||
this.explanationError.set(reason ?? null);
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (calculation !== this.activeCalculation) return;
|
|
||||||
const reason = {
|
|
||||||
code: 'explanation-error',
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
this.explanationError.set(reason);
|
|
||||||
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyOutcome(outcome: PlanningOutcome): void {
|
|
||||||
this.planningOutcome.set(outcome);
|
|
||||||
this.liveMessage.set(this.outcomeLabel(outcome));
|
|
||||||
const plan = this.displayedPlan();
|
|
||||||
this.resultDeclaredDays.set(
|
|
||||||
plan?.declaredDays ?? this.activeCalculation?.snapshot.declaredDays ?? [],
|
|
||||||
);
|
|
||||||
if (plan) {
|
|
||||||
this.planningHorizon.set(plan.horizon);
|
|
||||||
this.selectedDates.set(this.calendarService.selectedDatesForPlan(plan));
|
|
||||||
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.selectedDates.set(
|
|
||||||
this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!),
|
|
||||||
);
|
|
||||||
this.userInput.remainingVacationDaysSignal.set([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected formatStatistic(value: number | null): string {
|
protected formatStatistic(value: number | null): string {
|
||||||
@@ -829,7 +690,7 @@ export class CalendarYear {
|
|||||||
selection: CounterfactualSelection,
|
selection: CounterfactualSelection,
|
||||||
openDialog = false,
|
openDialog = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const calculation = this.activeCalculation;
|
const calculation = this.calculations.accepted()?.calculation;
|
||||||
if (!calculation) return;
|
if (!calculation) return;
|
||||||
this.counterfactualSelection = selection;
|
this.counterfactualSelection = selection;
|
||||||
const version = ++this.counterfactualVersion;
|
const version = ++this.counterfactualVersion;
|
||||||
@@ -844,7 +705,10 @@ export class CalendarYear {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const result = await calculation.analyzeCounterfactual(selection);
|
const result = await calculation.analyzeCounterfactual(selection);
|
||||||
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
|
if (
|
||||||
|
version !== this.counterfactualVersion ||
|
||||||
|
calculation !== this.calculations.accepted()?.calculation
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
if (result.kind === 'Counterfactual') {
|
if (result.kind === 'Counterfactual') {
|
||||||
this.counterfactual.set(result);
|
this.counterfactual.set(result);
|
||||||
@@ -855,7 +719,10 @@ export class CalendarYear {
|
|||||||
this.liveMessage.set(this.tr().planning.counterfactualReady);
|
this.liveMessage.set(this.tr().planning.counterfactualReady);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
|
if (
|
||||||
|
version !== this.counterfactualVersion ||
|
||||||
|
calculation !== this.calculations.accepted()?.calculation
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
this.counterfactualError.set(message);
|
this.counterfactualError.set(message);
|
||||||
@@ -920,10 +787,10 @@ export class CalendarYear {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected ruleLabel(ruleId: string): string {
|
protected ruleLabel(ruleId: string): string {
|
||||||
const rule = this.activeCalculation?.snapshot.rules.find(
|
const rule = this.calculations
|
||||||
(candidate) => candidate.id === ruleId,
|
.snapshot()
|
||||||
);
|
?.rules.find((candidate) => candidate.id === ruleId);
|
||||||
return rule?.name ?? this.activeCalculation?.snapshot.labels[ruleId] ?? ruleId;
|
return rule?.name ?? this.calculations.snapshot()?.labels[ruleId] ?? ruleId;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected balanceLabel(explanation: PlacementExplanation): string {
|
protected balanceLabel(explanation: PlacementExplanation): string {
|
||||||
@@ -955,9 +822,9 @@ export class CalendarYear {
|
|||||||
protected objectiveCopy(id: string) {
|
protected objectiveCopy(id: string) {
|
||||||
if (id.startsWith('optimization:')) {
|
if (id.startsWith('optimization:')) {
|
||||||
const ruleId = id.slice('optimization:'.length);
|
const ruleId = id.slice('optimization:'.length);
|
||||||
const rule = this.activeCalculation?.snapshot.rules.find(
|
const rule = this.calculations
|
||||||
(candidate) => candidate.id === ruleId,
|
.snapshot()
|
||||||
);
|
?.rules.find((candidate) => candidate.id === ruleId);
|
||||||
const copy = this.tr().planning.explanation.objectives['optimization'];
|
const copy = this.tr().planning.explanation.objectives['optimization'];
|
||||||
return {
|
return {
|
||||||
...copy,
|
...copy,
|
||||||
@@ -975,9 +842,9 @@ export class CalendarYear {
|
|||||||
const copy = this.tr().planning.explanation.objectives[key];
|
const copy = this.tr().planning.explanation.objectives[key];
|
||||||
const rule =
|
const rule =
|
||||||
key === 'preference'
|
key === 'preference'
|
||||||
? this.activeCalculation?.snapshot.rules.find(
|
? this.calculations
|
||||||
(candidate) => candidate.id === id.slice('preference:'.length),
|
.snapshot()
|
||||||
)
|
?.rules.find((candidate) => candidate.id === id.slice('preference:'.length))
|
||||||
: undefined;
|
: undefined;
|
||||||
return (
|
return (
|
||||||
copy && {
|
copy && {
|
||||||
@@ -1062,32 +929,4 @@ export class CalendarYear {
|
|||||||
: this.tr().planning.cancelled;
|
: this.tr().planning.cancelled;
|
||||||
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
|
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
private stageProgress(stage: string): number {
|
|
||||||
if (stage === 'complete') return 100;
|
|
||||||
if (stage === 'validation') return 5;
|
|
||||||
if (stage === 'obligations') return 20;
|
|
||||||
if (stage === 'at-risk') return 35;
|
|
||||||
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;
|
|
||||||
if (stage === 'short-sequence-deficit') return 79;
|
|
||||||
if (stage === 'supporting-placements') return 86;
|
|
||||||
if (stage === 'expiry') return 93;
|
|
||||||
if (stage === 'dates') return 98;
|
|
||||||
return this.loadingProgress();
|
|
||||||
}
|
|
||||||
|
|
||||||
private toLegacyBalances(plan: PlanOutcome): VacDay[] {
|
|
||||||
return plan.remainingBalances.map((balance) => ({
|
|
||||||
id: balance.id,
|
|
||||||
type: balance.type as SelectableDayType,
|
|
||||||
numberOfDays: balance.quantity,
|
|
||||||
availableDate: new Date(`${balance.availableFrom}T00:00:00`),
|
|
||||||
expiryDate: new Date(`${balance.expiryDate}T00:00:00`),
|
|
||||||
classification: balance.classification,
|
|
||||||
reason: balance.reason,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import { TranslationService } from '../../services/translation-service';
|
|||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
|
import { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
|
||||||
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../../testing/vitest-compat';
|
||||||
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
|
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
|
||||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
|
|||||||
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../../testing/vitest-compat';
|
||||||
import { RuleService, RuleType } from '../../services/rule-service';
|
import { RuleService, RuleType } from '../../services/rule-service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { calculatePlanningInternal } from '../../services/leave-planner.core';
|
import { calculatePlanningInternal } from '../../services/leave-planner.core';
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ import { MatDialog } from '@angular/material/dialog';
|
|||||||
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
||||||
import { MatSelectHarness } from '@angular/material/select/testing';
|
import { MatSelectHarness } from '@angular/material/select/testing';
|
||||||
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
||||||
import { vi } from 'vitest';
|
import { type MockInstance, vi } from '../../../testing/vitest-compat';
|
||||||
import { UserInput } from './user-input';
|
import { UserInput } from './user-input';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
|
import { CalendarService } from '../../services/calendar-service';
|
||||||
|
import { PlanningCalculationService } from '../../services/planning-calculation.service';
|
||||||
import { UserSettings } from '../user-settings/user-settings';
|
import { UserSettings } from '../user-settings/user-settings';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
import { createPlanningCalculation, PlanningRequest } from '../../services/leave-planner.service';
|
import {
|
||||||
|
createPlanningCalculation,
|
||||||
|
type PlanningCalculationContext,
|
||||||
|
type PlanningOutcome,
|
||||||
|
PlanningRequest,
|
||||||
|
} from '../../services/leave-planner.service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
|
|
||||||
const presets = createOptimizationPresetRules();
|
const presets = createOptimizationPresetRules();
|
||||||
@@ -21,6 +28,8 @@ describe('UserInput', () => {
|
|||||||
let component: UserInput;
|
let component: UserInput;
|
||||||
let fixture: ComponentFixture<UserInput>;
|
let fixture: ComponentFixture<UserInput>;
|
||||||
let userInputService: UserInputService;
|
let userInputService: UserInputService;
|
||||||
|
let createCalculation: MockInstance<CalendarService['createPlanningCalculation']>;
|
||||||
|
let calculations: PlanningCalculationService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -45,6 +54,11 @@ describe('UserInput', () => {
|
|||||||
fixture = TestBed.createComponent(UserInput);
|
fixture = TestBed.createComponent(UserInput);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
userInputService = TestBed.inject(UserInputService);
|
userInputService = TestBed.inject(UserInputService);
|
||||||
|
calculations = TestBed.inject(PlanningCalculationService);
|
||||||
|
// Unless a test supplies a result, a started calculation stays pending.
|
||||||
|
createCalculation = vi
|
||||||
|
.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation')
|
||||||
|
.mockImplementation(() => ({ outcome: new Promise(() => undefined) }) as never);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
}
|
}
|
||||||
@@ -91,16 +105,25 @@ describe('UserInput', () => {
|
|||||||
|
|
||||||
async function calculate(request: PlanningRequest): Promise<void> {
|
async function calculate(request: PlanningRequest): Promise<void> {
|
||||||
const browserWindow = globalThis.window;
|
const browserWindow = globalThis.window;
|
||||||
let calculation: ReturnType<typeof createPlanningCalculation>;
|
let calculation: PlanningCalculationContext;
|
||||||
try {
|
try {
|
||||||
vi.stubGlobal('window', undefined);
|
vi.stubGlobal('window', undefined);
|
||||||
calculation = createPlanningCalculation(request);
|
calculation = createPlanningCalculation(request);
|
||||||
} finally {
|
} finally {
|
||||||
vi.stubGlobal('window', browserWindow);
|
vi.stubGlobal('window', browserWindow);
|
||||||
}
|
}
|
||||||
const outcome = await calculation.outcome;
|
await show(calculation);
|
||||||
userInputService.planningSnapshotSignal.set(calculation.snapshot);
|
}
|
||||||
userInputService.planningOutcomeSignal.set(outcome);
|
|
||||||
|
async function show(
|
||||||
|
calculation: PlanningCalculationContext,
|
||||||
|
outcome?: PlanningOutcome,
|
||||||
|
): Promise<void> {
|
||||||
|
const shown = outcome ? { ...calculation, outcome: Promise.resolve(outcome) } : calculation;
|
||||||
|
createCalculation.mockReturnValueOnce(shown);
|
||||||
|
calculations.start();
|
||||||
|
await shown.outcome;
|
||||||
|
await Promise.resolve();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +155,33 @@ describe('UserInput', () => {
|
|||||||
expect(card.textContent).toBe(explanation);
|
expect(card.textContent).toBe(explanation);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears the remaining-days summary when a valid plan is replaced by a conflict', async () => {
|
||||||
|
await calculate({
|
||||||
|
horizon: { start: '2027-05-01', end: '2027-05-10' },
|
||||||
|
balances: [
|
||||||
|
{
|
||||||
|
id: 'other',
|
||||||
|
type: 'other',
|
||||||
|
quantity: 1,
|
||||||
|
availableFrom: '2027-05-10',
|
||||||
|
expiryDate: '2028-05-10',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rules: [],
|
||||||
|
});
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(calculations.displayedPlan()).toBeDefined();
|
||||||
|
expect(root.querySelector('.warning-card')).not.toBeNull();
|
||||||
|
|
||||||
|
await show(calculations.accepted()!.calculation!, {
|
||||||
|
kind: 'PlanningConflict',
|
||||||
|
reasons: [],
|
||||||
|
conflictingRuleIds: ['rule'],
|
||||||
|
});
|
||||||
|
expect(calculations.displayedPlan()).toBeUndefined();
|
||||||
|
expect(root.querySelector('.warning-card')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('explains usable leave kept for later and incomplete balances without attempt controls', async () => {
|
it('explains usable leave kept for later and incomplete balances without attempt controls', async () => {
|
||||||
TestBed.inject(TranslationService).setLanguage('fr');
|
TestBed.inject(TranslationService).setLanguage('fr');
|
||||||
await calculate({
|
await calculate({
|
||||||
@@ -155,19 +205,18 @@ describe('UserInput', () => {
|
|||||||
expect(card.textContent).toContain('Aucune action nécessaire');
|
expect(card.textContent).toContain('Aucune action nécessaire');
|
||||||
expect(card.textContent).not.toContain('Disponible seulement');
|
expect(card.textContent).not.toContain('Disponible seulement');
|
||||||
expect(root.querySelector('.remaining-history')).toBeNull();
|
expect(root.querySelector('.remaining-history')).toBeNull();
|
||||||
const outcome = JSON.stringify(userInputService.planningOutcomeSignal());
|
const outcome = JSON.stringify(calculations.outcome());
|
||||||
expect(card.querySelector('details')).toBeNull();
|
expect(card.querySelector('details')).toBeNull();
|
||||||
expect(card.textContent).not.toContain('trace');
|
expect(card.textContent).not.toContain('trace');
|
||||||
expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
|
expect(JSON.stringify(calculations.outcome())).toBe(outcome);
|
||||||
|
|
||||||
const plan = userInputService.displayedPlanSignal()!;
|
const plan = calculations.displayedPlan()!;
|
||||||
userInputService.planningOutcomeSignal.set({
|
await show(calculations.accepted()!.calculation!, {
|
||||||
kind: 'PlanningIncomplete',
|
kind: 'PlanningIncomplete',
|
||||||
incumbent: plan,
|
incumbent: plan,
|
||||||
completedStages: [],
|
completedStages: [],
|
||||||
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
||||||
});
|
});
|
||||||
fixture.detectChanges();
|
|
||||||
expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive');
|
expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive');
|
||||||
expect(card.textContent).not.toContain('Conservé pour plus tard');
|
expect(card.textContent).not.toContain('Conservé pour plus tard');
|
||||||
expect(root.querySelector('.remaining-history')).toBeNull();
|
expect(root.querySelector('.remaining-history')).toBeNull();
|
||||||
@@ -185,7 +234,7 @@ describe('UserInput', () => {
|
|||||||
TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03');
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(estimateText()).not.toBe(initial);
|
expect(estimateText()).not.toBe(initial);
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(0);
|
expect(createCalculation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
|
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
|
||||||
@@ -218,7 +267,7 @@ describe('UserInput', () => {
|
|||||||
start: '2024-02-27',
|
start: '2024-02-27',
|
||||||
lastPlanningDate: '2024-03-09',
|
lastPlanningDate: '2024-03-09',
|
||||||
});
|
});
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(0);
|
expect(createCalculation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
|
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
|
||||||
@@ -233,11 +282,11 @@ describe('UserInput', () => {
|
|||||||
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
|
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
|
||||||
calculate.click();
|
calculate.click();
|
||||||
component.triggerComputation();
|
component.triggerComputation();
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(0);
|
expect(createCalculation).not.toHaveBeenCalled();
|
||||||
editInput('input[name="planningStart"]', '29/02/2024');
|
editInput('input[name="planningStart"]', '29/02/2024');
|
||||||
expect(calculate.disabled).toBe(false);
|
expect(calculate.disabled).toBe(false);
|
||||||
calculate.click();
|
calculate.click();
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(1);
|
expect(createCalculation).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['', '30/02/2024', 'not-a-date', '2'])(
|
it.each(['', '30/02/2024', 'not-a-date', '2'])(
|
||||||
@@ -282,7 +331,7 @@ describe('UserInput', () => {
|
|||||||
expect(last.getAttribute('aria-invalid')).toBe('false');
|
expect(last.getAttribute('aria-invalid')).toBe('false');
|
||||||
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
|
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
|
||||||
expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' });
|
expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' });
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(0);
|
expect(createCalculation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize with vacation days from service', () => {
|
it('should initialize with vacation days from service', () => {
|
||||||
@@ -518,7 +567,7 @@ describe('UserInput', () => {
|
|||||||
fixture.nativeElement.querySelector('.calculate-button').click();
|
fixture.nativeElement.querySelector('.calculate-button').click();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
|
||||||
expect(userInputService.computeTriggerSignal()).toBe(1);
|
expect(createCalculation).toHaveBeenCalledTimes(1);
|
||||||
expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
|
expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
|
||||||
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { VacDay } from '../../services/calendar-service';
|
|||||||
import type { LeaveType } from '../../services/leave-planner.service';
|
import type { LeaveType } from '../../services/leave-planner.service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
|
import { PlanningCalculationService } from '../../services/planning-calculation.service';
|
||||||
import { PlanningDurationService } from '../../services/planning-duration.service';
|
import { PlanningDurationService } from '../../services/planning-duration.service';
|
||||||
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
|
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
|
||||||
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
||||||
@@ -45,6 +46,7 @@ export class UserInput {
|
|||||||
protected readonly declaredTypes: readonly LeaveType[] = ['cp', 'rtt', 'other'];
|
protected readonly declaredTypes: readonly LeaveType[] = ['cp', 'rtt', 'other'];
|
||||||
protected readonly calendarSettings = inject(CalendarSettingsService);
|
protected readonly calendarSettings = inject(CalendarSettingsService);
|
||||||
protected readonly duration = inject(PlanningDurationService);
|
protected readonly duration = inject(PlanningDurationService);
|
||||||
|
private readonly calculations = inject(PlanningCalculationService);
|
||||||
protected readonly translationService = inject(TranslationService);
|
protected readonly translationService = inject(TranslationService);
|
||||||
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
||||||
protected readonly planningDates = computed(() => {
|
protected readonly planningDates = computed(() => {
|
||||||
@@ -72,22 +74,21 @@ export class UserInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected readonly remainingDays = computed(() =>
|
protected readonly remainingDays = computed(() =>
|
||||||
(this.userInputService.displayedPlanSignal()?.remainingBalances ?? []).reduce(
|
(this.calculations.displayedPlan()?.remainingBalances ?? []).reduce(
|
||||||
(total, balance) => total + balance.quantity,
|
(total, balance) => total + balance.quantity,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
protected readonly hasAtRiskDays = computed(() =>
|
protected readonly hasAtRiskDays = computed(() =>
|
||||||
this.userInputService
|
this.calculations
|
||||||
.displayedPlanSignal()
|
.displayedPlan()
|
||||||
?.remainingBalances.some((balance) => balance.classification !== 'deferred'),
|
?.remainingBalances.some((balance) => balance.classification !== 'deferred'),
|
||||||
);
|
);
|
||||||
|
|
||||||
protected remainingDaysDetails = computed(() => {
|
protected remainingDaysDetails = computed(() => {
|
||||||
const plan = this.userInputService.displayedPlanSignal();
|
const plan = this.calculations.displayedPlan();
|
||||||
if (!plan) return [];
|
if (!plan) return [];
|
||||||
const incomplete =
|
const incomplete = this.calculations.outcome()?.kind === 'PlanningIncomplete';
|
||||||
this.userInputService.planningOutcomeSignal()?.kind === 'PlanningIncomplete';
|
|
||||||
const copy = this.tr().userInput;
|
const copy = this.tr().userInput;
|
||||||
return plan.remainingBalances.map((balance) => {
|
return plan.remainingBalances.map((balance) => {
|
||||||
const code =
|
const code =
|
||||||
@@ -147,10 +148,7 @@ export class UserInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ruleName(id: string): string {
|
private ruleName(id: string): string {
|
||||||
return (
|
return this.calculations.snapshot()?.rules.find((rule) => rule.id === id)?.name ?? id;
|
||||||
this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id)
|
|
||||||
?.name ?? id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
|
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
|
||||||
@@ -178,6 +176,6 @@ export class UserInput {
|
|||||||
|
|
||||||
public triggerComputation() {
|
public triggerComputation() {
|
||||||
if (this.calendarSettings.planningHorizonError()) return;
|
if (this.calendarSettings.planningHorizonError()) return;
|
||||||
this.userInputService.requestComputation();
|
this.calculations.start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { Worker as NodeWorker } from 'node:worker_threads';
|
import { Worker as NodeWorker } from 'node:worker_threads';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { createPlanningCalculation } from './leave-planner.service';
|
import { createPlanningCalculation } from './leave-planner.service';
|
||||||
import type { OptimizationPlanningRule } from './leave-planner.core';
|
import type { OptimizationPlanningRule } from './leave-planner.core';
|
||||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||||
@@ -28,7 +28,13 @@ await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'
|
|||||||
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
|
await promisify(execFile)(process.execPath, [
|
||||||
|
'build',
|
||||||
|
entry,
|
||||||
|
'--target=node',
|
||||||
|
'--outfile',
|
||||||
|
bundle,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -46,21 +46,18 @@ describe('CalendarService', () => {
|
|||||||
const previousTimeZone = process.env['TZ'];
|
const previousTimeZone = process.env['TZ'];
|
||||||
process.env['TZ'] = 'Europe/Paris';
|
process.env['TZ'] = 'Europe/Paris';
|
||||||
try {
|
try {
|
||||||
const selected = service.selectedDatesForPlan({
|
const { selections: selected } = service.project({
|
||||||
horizon: { start: date, end },
|
horizon: { start: date, end },
|
||||||
placements: [
|
placements: [
|
||||||
{ date, balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
|
{ date, balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const period = selected.datesSelected.find(
|
const day = (key: string) => new Date(`${key}T00:00:00`);
|
||||||
(item) =>
|
expect(selected.isStart(day(periodStart))).toBe(true);
|
||||||
item.type === null &&
|
expect(selected.isEnd(day(periodStart))).toBe(false);
|
||||||
item.range.start?.getTime() ===
|
expect(selected.isStart(day(date))).toBe(false);
|
||||||
new Date(`${periodStart}T00:00:00`).getTime() &&
|
expect(selected.isEnd(day(date))).toBe(true);
|
||||||
item.range.end?.getTime() === new Date(`${date}T00:00:00`).getTime(),
|
expect(selected.isSelected(day(end))).toBe(false);
|
||||||
);
|
|
||||||
expect(period).toBeDefined();
|
|
||||||
expect(period?.getLengthInDays()).toBe(3);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (previousTimeZone === undefined) delete process.env['TZ'];
|
if (previousTimeZone === undefined) delete process.env['TZ'];
|
||||||
else process.env['TZ'] = previousTimeZone;
|
else process.env['TZ'] = previousTimeZone;
|
||||||
@@ -70,7 +67,7 @@ describe('CalendarService', () => {
|
|||||||
|
|
||||||
it('projects adjacent non-working dates into a preceding month without moving leave outside the horizon', () => {
|
it('projects adjacent non-working dates into a preceding month without moving leave outside the horizon', () => {
|
||||||
const horizon = { start: '2024-04-02', end: '2024-04-03' };
|
const horizon = { start: '2024-04-02', end: '2024-04-03' };
|
||||||
const selected = service.selectedDatesForPlan({
|
const { selections: selected } = service.project({
|
||||||
horizon,
|
horizon,
|
||||||
placements: [
|
placements: [
|
||||||
{ date: '2024-04-02', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
|
{ date: '2024-04-02', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
|
||||||
@@ -107,21 +104,15 @@ describe('CalendarService', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
|
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
|
||||||
const selected = service.selectedDatesForPlan(outcome);
|
const selected = service.project(outcome).selections;
|
||||||
const date = (key: string) => new Date(`${key}T00:00:00`);
|
const date = (key: string) => new Date(`${key}T00:00:00`);
|
||||||
expect(selected.getClasses(date('2024-05-31'))).toContain(DayType.OTHER);
|
expect(selected.getClasses(date('2024-05-31'))).toContain(DayType.OTHER);
|
||||||
expect(selected.getClasses(date('2024-06-01'))).toContain(DayType.WEEKEND);
|
expect(selected.getClasses(date('2024-06-01'))).toContain(DayType.WEEKEND);
|
||||||
expect(selected.getClasses(date('2024-06-03'))).toContain(DayType.CP);
|
expect(selected.getClasses(date('2024-06-03'))).toContain(DayType.CP);
|
||||||
expect(selected.isSelected(date('2024-06-05'))).toBe(false);
|
expect(selected.isSelected(date('2024-06-05'))).toBe(false);
|
||||||
expect(
|
expect(selected.isStart(date('2024-05-31'))).toBe(true);
|
||||||
selected.datesSelected
|
expect(selected.isEnd(date('2024-06-03'))).toBe(true);
|
||||||
.find(
|
expect(selected.isEnd(date('2024-06-02'))).toBe(false);
|
||||||
(selection) =>
|
|
||||||
selection.type === null &&
|
|
||||||
selection.range.start?.getTime() === date('2024-05-31').getTime(),
|
|
||||||
)
|
|
||||||
?.getLengthInDays(),
|
|
||||||
).toBe(4);
|
|
||||||
expect(outcome.placements).toEqual([]);
|
expect(outcome.placements).toEqual([]);
|
||||||
expect(outcome.horizon).toEqual(horizon);
|
expect(outcome.horizon).toEqual(horizon);
|
||||||
});
|
});
|
||||||
@@ -134,7 +125,7 @@ describe('CalendarService', () => {
|
|||||||
];
|
];
|
||||||
const before = { horizon, declaredDays, placements: [] };
|
const before = { horizon, declaredDays, placements: [] };
|
||||||
expect(calendarProjectionHorizon(before)).toEqual(horizon);
|
expect(calendarProjectionHorizon(before)).toEqual(horizon);
|
||||||
expect(service.selectedDatesForPlan(before).isSelected(new Date('2024-08-01'))).toBe(false);
|
expect(service.project(before).selections.isSelected(new Date('2024-08-01'))).toBe(false);
|
||||||
const after = {
|
const after = {
|
||||||
...before,
|
...before,
|
||||||
placements: [
|
placements: [
|
||||||
@@ -147,7 +138,7 @@ describe('CalendarService', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const selected = service.selectedDatesForPlan(after);
|
const selected = service.project(after).selections;
|
||||||
expect(calendarProjectionHorizon(after)).toEqual({
|
expect(calendarProjectionHorizon(after)).toEqual({
|
||||||
start: horizon.start,
|
start: horizon.start,
|
||||||
end: '2024-08-02',
|
end: '2024-08-02',
|
||||||
@@ -201,52 +192,60 @@ describe('CalendarService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getSelectedDatesForYear', () => {
|
describe('project', () => {
|
||||||
it('should return selected dates for the year', () => {
|
const project = (horizon: { start: string; end: string }) =>
|
||||||
const selectedDates = service.getSelectedDatesForYear();
|
service.project({ placements: [], horizon });
|
||||||
expect(selectedDates).toBeDefined();
|
const day = (key: string) => new Date(`${key}T00:00:00`);
|
||||||
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
|
|
||||||
|
it('projects weekends, holidays and today for the default planning horizon', () => {
|
||||||
|
const { selections } = project({ start: '2026-01-01', end: '2027-01-01' });
|
||||||
|
expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND);
|
||||||
|
expect(selections.dayTypes(day('2026-05-01'))).toContain(DayType.CLOSED_DAY);
|
||||||
|
const horizon = service.getDefaultPlanningHorizon();
|
||||||
|
expect(project(horizon).selections.getClasses(day(horizon.start))).toContain(
|
||||||
|
DayType.TODAY,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include weekends', () => {
|
it('lists the covered months, including partial boundary months and year changes', () => {
|
||||||
const selectedDates = service.getSelectedDatesForYear();
|
expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([
|
||||||
const hasWeekend = selectedDates._datesSelected.some(
|
{ monthIndex: 10, year: 2024 },
|
||||||
(date) => date.type === DayType.WEEKEND,
|
{ monthIndex: 11, year: 2024 },
|
||||||
);
|
{ monthIndex: 0, year: 2025 },
|
||||||
expect(hasWeekend).toBe(true);
|
{ monthIndex: 1, year: 2025 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include holidays', () => {
|
it('keeps fixed months when an edge declaration widens the projection', () => {
|
||||||
const selectedDates = service.getSelectedDatesForYear();
|
const plan = {
|
||||||
const hasHoliday = selectedDates._datesSelected.some(
|
placements: [],
|
||||||
(date) => date.type === DayType.CLOSED_DAY,
|
horizon: { start: '2024-07-01', end: '2025-01-04' },
|
||||||
);
|
declaredDays: [{ date: '2024-07-01', type: 'cp' as const }],
|
||||||
expect(hasHoliday).toBe(true);
|
};
|
||||||
});
|
const months = (projection: ReturnType<typeof service.project>) =>
|
||||||
|
projection.months.map(({ monthIndex, year }) => `${year}-${monthIndex}`);
|
||||||
it('should include today', () => {
|
expect(months(service.project(plan))[0]).toBe('2024-5');
|
||||||
const selectedDates = service.getSelectedDatesForYear();
|
const fixed = service.project(plan, { fixedMonths: true });
|
||||||
const hasToday = selectedDates._datesSelected.some(
|
expect(months(fixed)).toEqual([
|
||||||
(date) => date.type === DayType.TODAY,
|
'2024-6',
|
||||||
);
|
'2024-7',
|
||||||
expect(hasToday).toBe(true);
|
'2024-8',
|
||||||
|
'2024-9',
|
||||||
|
'2024-10',
|
||||||
|
'2024-11',
|
||||||
|
'2025-0',
|
||||||
|
]);
|
||||||
|
expect(fixed.selections.dayTypes(day('2024-06-30'))).toContain(DayType.WEEKEND);
|
||||||
|
expect(fixed.selections.isStart(day('2024-07-01'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes the final partial month of a half-open one-year horizon', () => {
|
it('includes the final partial month of a half-open one-year horizon', () => {
|
||||||
const selectedDates = service.getSelectedDatesForHorizon({
|
const { selections } = project({ start: '2024-01-15', end: '2025-01-15' });
|
||||||
start: '2024-01-15',
|
expect(selections.dayTypes(day('2025-01-04'))).toContain(DayType.WEEKEND);
|
||||||
end: '2025-01-15',
|
|
||||||
});
|
|
||||||
expect(selectedDates.getClasses(new Date('2025-01-04T00:00:00'))).toContain(
|
|
||||||
DayType.WEEKEND,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('projects holidays in every covered year and in both partial boundary months', () => {
|
it('projects holidays in every covered year and in both partial boundary months', () => {
|
||||||
const selected = service.getSelectedDatesForHorizon({
|
const { selections } = project({ start: '2023-12-26', end: '2027-01-03' });
|
||||||
start: '2023-12-26',
|
|
||||||
end: '2027-01-03',
|
|
||||||
});
|
|
||||||
for (const date of [
|
for (const date of [
|
||||||
'2023-12-25',
|
'2023-12-25',
|
||||||
'2024-04-01',
|
'2024-04-01',
|
||||||
@@ -254,20 +253,34 @@ describe('CalendarService', () => {
|
|||||||
'2026-04-06',
|
'2026-04-06',
|
||||||
'2027-01-01',
|
'2027-01-01',
|
||||||
]) {
|
]) {
|
||||||
expect(selected.getClasses(new Date(`${date}T00:00:00`))).toContain(
|
expect(selections.dayTypes(day(date))).toContain(DayType.CLOSED_DAY);
|
||||||
DayType.CLOSED_DAY,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
expect(selected.getClasses(new Date('2027-01-02T00:00:00'))).toContain(DayType.WEEKEND);
|
expect(selections.dayTypes(day('2027-01-02'))).toContain(DayType.WEEKEND);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setWeekendSelection', () => {
|
it('does not mutate the submitted plan or its horizon', () => {
|
||||||
it('should add weekend for Saturday', () => {
|
const plan = {
|
||||||
const selectedDates = new SelectedDates();
|
horizon: { start: '2024-04-02', end: '2024-04-03' },
|
||||||
const saturday = new Date(2024, 0, 6); // A Saturday
|
placements: [
|
||||||
service.setWeekendSelection(saturday, selectedDates);
|
{
|
||||||
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
|
date: '2024-04-02',
|
||||||
|
balanceId: 'cp',
|
||||||
|
type: 'cp' as const,
|
||||||
|
ruleIds: [],
|
||||||
|
supporting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
declaredDays: [{ date: '2024-04-03', type: 'rtt' as const }],
|
||||||
|
};
|
||||||
|
const before = structuredClone(plan);
|
||||||
|
const frozen = JSON.parse(JSON.stringify(plan), (_, value) =>
|
||||||
|
typeof value === 'object' && value ? Object.freeze(value) : value,
|
||||||
|
);
|
||||||
|
const projection = service.project(frozen);
|
||||||
|
expect(projection.selections.dayTypes(new Date('2024-04-03T00:00:00'))).toContain(
|
||||||
|
DayType.RTT,
|
||||||
|
);
|
||||||
|
expect(frozen).toEqual(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -298,13 +311,6 @@ describe('SelectedDate', () => {
|
|||||||
expect(classes).toContain(DayType.CP);
|
expect(classes).toContain(DayType.CP);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return tooltip for selected date', () => {
|
|
||||||
const date = new Date(2024, 0, 1);
|
|
||||||
const selectedDate = new SelectedDate(DayType.CP, new DateRange<Date>(date, date));
|
|
||||||
const tooltip = selectedDate.getTooltip(date);
|
|
||||||
expect(tooltip).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate length in days', () => {
|
it('should calculate length in days', () => {
|
||||||
const startDate = new Date(2024, 0, 1);
|
const startDate = new Date(2024, 0, 1);
|
||||||
const endDate = new Date(2024, 0, 3);
|
const endDate = new Date(2024, 0, 3);
|
||||||
@@ -320,47 +326,64 @@ describe('SelectedDate', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('SelectedDates', () => {
|
describe('SelectedDates', () => {
|
||||||
it('should push selected dates', () => {
|
const range = (type: DayType, start: Date, end = start) =>
|
||||||
const selectedDates = new SelectedDates();
|
new SelectedDate(type, new DateRange<Date>(start, end));
|
||||||
const date = new Date(2024, 0, 1);
|
|
||||||
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
|
|
||||||
expect(selectedDates._datesSelected.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should check if date is selected', () => {
|
it('selects covered dates and reports their day types', () => {
|
||||||
const selectedDates = new SelectedDates();
|
|
||||||
const date = new Date(2024, 0, 1);
|
const date = new Date(2024, 0, 1);
|
||||||
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
|
const selectedDates = new SelectedDates([range(DayType.CP, date)]);
|
||||||
expect(selectedDates.isSelected(date)).toBe(true);
|
expect(selectedDates.isSelected(date)).toBe(true);
|
||||||
|
expect(selectedDates.dayTypes(date)).toEqual([DayType.CP]);
|
||||||
|
expect(selectedDates.getClasses(date)).toContain(DayType.CP);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should group adjacent dates', () => {
|
it('treats adjacent ranges as one period with shared boundaries', () => {
|
||||||
const selectedDates = new SelectedDates();
|
const first = new Date(2024, 0, 1);
|
||||||
const date1 = new Date(2024, 0, 1);
|
const second = new Date(2024, 0, 2);
|
||||||
const date2 = new Date(2024, 0, 2);
|
const third = new Date(2024, 0, 3);
|
||||||
selectedDates._datesSelected.push(
|
const selectedDates = new SelectedDates([
|
||||||
new SelectedDate(DayType.CP, new DateRange<Date>(date1, date1)),
|
range(DayType.CP, first),
|
||||||
);
|
range(DayType.RTT, second),
|
||||||
selectedDates._datesSelected.push(
|
range(DayType.OTHER, third),
|
||||||
new SelectedDate(DayType.RTT, new DateRange<Date>(date2, date2)),
|
]);
|
||||||
);
|
expect(selectedDates.isStart(first)).toBe(true);
|
||||||
selectedDates.update();
|
expect(selectedDates.isEnd(first)).toBe(false);
|
||||||
expect(selectedDates.datesSelected.length).toBeGreaterThan(2);
|
expect(selectedDates.isStart(second)).toBe(false);
|
||||||
|
expect(selectedDates.isEnd(second)).toBe(false);
|
||||||
|
expect(selectedDates.isEnd(third)).toBe(true);
|
||||||
|
expect(selectedDates.dayTypes(second)).toEqual([DayType.RTT]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return correct classes', () => {
|
it('keeps separated ranges as separate periods', () => {
|
||||||
const selectedDates = new SelectedDates();
|
const first = new Date(2024, 0, 1);
|
||||||
|
const later = new Date(2024, 0, 5);
|
||||||
|
const selectedDates = new SelectedDates([
|
||||||
|
range(DayType.CP, first),
|
||||||
|
range(DayType.CP, later),
|
||||||
|
]);
|
||||||
|
expect(selectedDates.isStart(later)).toBe(true);
|
||||||
|
expect(selectedDates.isEnd(first)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges overlapping ranges and keeps every overlapping day type', () => {
|
||||||
|
const start = new Date(2024, 0, 1);
|
||||||
|
const overlap = new Date(2024, 0, 2);
|
||||||
|
const end = new Date(2024, 0, 3);
|
||||||
|
const selectedDates = new SelectedDates([
|
||||||
|
range(DayType.WEEKEND, start, overlap),
|
||||||
|
range(DayType.CLOSED_DAY, overlap, end),
|
||||||
|
]);
|
||||||
|
expect(selectedDates.dayTypes(overlap)).toEqual([DayType.WEEKEND, DayType.CLOSED_DAY]);
|
||||||
|
expect(selectedDates.isStart(start)).toBe(true);
|
||||||
|
expect(selectedDates.isEnd(end)).toBe(true);
|
||||||
|
expect(selectedDates.isStart(overlap)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not select today', () => {
|
||||||
const date = new Date(2024, 0, 1);
|
const date = new Date(2024, 0, 1);
|
||||||
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
|
const selectedDates = new SelectedDates([range(DayType.TODAY, date)]);
|
||||||
const classes = selectedDates.getClasses(date);
|
expect(selectedDates.isSelected(date)).toBe(false);
|
||||||
expect(classes).toContain(DayType.CP);
|
expect(selectedDates.dayTypes(date)).toEqual([]);
|
||||||
});
|
expect(selectedDates.getClasses(date)).toContain(DayType.TODAY);
|
||||||
|
|
||||||
it('should return tooltip', () => {
|
|
||||||
const selectedDates = new SelectedDates();
|
|
||||||
const date = new Date(2024, 0, 1);
|
|
||||||
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
|
|
||||||
const tooltip = selectedDates.getTooltip(date);
|
|
||||||
expect(tooltip).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -10,9 +10,7 @@ import {
|
|||||||
planLeave as planLeaveOperation,
|
planLeave as planLeaveOperation,
|
||||||
LeaveType,
|
LeaveType,
|
||||||
PlanOutcome,
|
PlanOutcome,
|
||||||
PlanningReason,
|
|
||||||
PlanningStateSnapshot,
|
PlanningStateSnapshot,
|
||||||
RemainingClassification,
|
|
||||||
} from './leave-planner.service';
|
} from './leave-planner.service';
|
||||||
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
|
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
|
||||||
|
|
||||||
@@ -54,6 +52,11 @@ export function calendarProjectionHorizon(plan: CalendarPlan) {
|
|||||||
return horizon;
|
return horizon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CalendarProjection {
|
||||||
|
months: { monthIndex: number; year: number }[];
|
||||||
|
selections: SelectedDates;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
@@ -68,112 +71,82 @@ export class CalendarService {
|
|||||||
return { start, end: addPlanningYears(start, 1) };
|
return { start, end: addPlanningYears(start, 1) };
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedDatesForPlan(plan: CalendarPlan): SelectedDates {
|
/** `fixedMonths` lists only the plan horizon's months, so editing declarations never adds a month. */
|
||||||
const projectionHorizon = calendarProjectionHorizon(plan);
|
project(
|
||||||
const selected = projectionHorizon
|
plan: CalendarPlan & { horizon: PlanningStateSnapshot['horizon'] },
|
||||||
? this.getSelectedDatesForHorizon(projectionHorizon)
|
{ fixedMonths = false } = {},
|
||||||
: this.getSelectedDatesForYear();
|
): CalendarProjection {
|
||||||
for (const placement of [
|
const horizon = calendarProjectionHorizon(plan)!;
|
||||||
...plan.placements,
|
const firstMonth = plan.horizon.start.slice(0, 7);
|
||||||
...(plan.declaredDays ?? []).filter(
|
const lastMonth = addPlanningDays(plan.horizon.end, -1).slice(0, 7);
|
||||||
(day) =>
|
const first = new Date(`${horizon.start}T00:00:00`);
|
||||||
!projectionHorizon ||
|
|
||||||
(day.date >= projectionHorizon.start && day.date < projectionHorizon.end),
|
|
||||||
),
|
|
||||||
]) {
|
|
||||||
selected.push(
|
|
||||||
new SelectedDate(
|
|
||||||
dayTypeForLeaveType[placement.type],
|
|
||||||
new DateRange<Date>(
|
|
||||||
new Date(`${placement.date}T00:00:00`),
|
|
||||||
new Date(`${placement.date}T00:00:00`),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get selected dates from current date to the next year
|
|
||||||
getSelectedDatesFromNow(): SelectedDates {
|
|
||||||
const _holidays = this.getHolidays();
|
|
||||||
const selectedDates: SelectedDates = new SelectedDates();
|
|
||||||
// Logic to populate selectedDates from now until the next year
|
|
||||||
return selectedDates;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSelectedDatesForYear(): SelectedDates {
|
|
||||||
return this.getSelectedDatesForHorizon(this.getDefaultPlanningHorizon());
|
|
||||||
}
|
|
||||||
|
|
||||||
getSelectedDatesForHorizon(horizon: { start: string; end: string }): SelectedDates {
|
|
||||||
const start = new Date(`${horizon.start}T00:00:00`);
|
|
||||||
const last = new Date(`${horizon.end}T00:00:00`);
|
const last = new Date(`${horizon.end}T00:00:00`);
|
||||||
last.setDate(last.getDate() - 1);
|
last.setDate(last.getDate() - 1);
|
||||||
const holidays = this.getHolidays(start.getFullYear(), last.getFullYear());
|
const holidays = new Set(
|
||||||
const monthInHorizon: Year = [];
|
this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()),
|
||||||
const cursor = new Date(start);
|
|
||||||
cursor.setDate(1);
|
|
||||||
while (cursor <= last) {
|
|
||||||
monthInHorizon.push(
|
|
||||||
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
|
|
||||||
);
|
);
|
||||||
cursor.setMonth(cursor.getMonth() + 1);
|
|
||||||
}
|
|
||||||
const selectedDates: SelectedDates = new SelectedDates();
|
|
||||||
|
|
||||||
// Use DateCacheService to get today's date components
|
|
||||||
const {
|
const {
|
||||||
date: todayDate,
|
date: todayDate,
|
||||||
month: todayMonth,
|
month: todayMonth,
|
||||||
year: todayYear,
|
year: todayYear,
|
||||||
} = this.dateCache.getTodayComponents();
|
} = this.dateCache.getTodayComponents();
|
||||||
|
const months: CalendarProjection['months'] = [];
|
||||||
// Create a Set of holiday timestamps for O(1) lookup instead of O(n)
|
const ranges: SelectedDate[] = [];
|
||||||
const holidayTimestamps = new Set(holidays.map((h) => h.getTime()));
|
const cursor = new Date(first);
|
||||||
|
cursor.setDate(1);
|
||||||
for (const weeksInAMonth of monthInHorizon) {
|
while (cursor <= last) {
|
||||||
for (const daysInAWeek of weeksInAMonth) {
|
const month = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`;
|
||||||
for (const day of daysInAWeek) {
|
if (!fixedMonths || (month >= firstMonth && month <= lastMonth))
|
||||||
if (day) {
|
months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
|
||||||
this.setWeekendSelection(day, selectedDates);
|
for (const week of CalendarService.weeksInMonth(
|
||||||
if (holidayTimestamps.has(day.getTime())) {
|
cursor.getMonth(),
|
||||||
selectedDates._datesSelected.push(
|
cursor.getFullYear(),
|
||||||
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
|
)) {
|
||||||
|
for (const day of week) {
|
||||||
|
if (!day) continue;
|
||||||
|
if (day.getDay() === 6) {
|
||||||
|
const end = new Date(day);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
ranges.push(
|
||||||
|
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(day, end)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (day.getDate() === 1 && day.getDay() === 0) {
|
||||||
|
const start = new Date(day);
|
||||||
|
start.setDate(start.getDate() - 1);
|
||||||
|
ranges.push(
|
||||||
|
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(start, day)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (holidays.has(day.getTime()))
|
||||||
|
ranges.push(
|
||||||
|
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
day.getDate() === todayDate &&
|
day.getDate() === todayDate &&
|
||||||
day.getMonth() === todayMonth &&
|
day.getMonth() === todayMonth &&
|
||||||
day.getFullYear() === todayYear
|
day.getFullYear() === todayYear
|
||||||
) {
|
)
|
||||||
selectedDates._datesSelected.push(
|
ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)));
|
||||||
new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
cursor.setMonth(cursor.getMonth() + 1);
|
||||||
}
|
}
|
||||||
}
|
for (const placement of [
|
||||||
}
|
...plan.placements,
|
||||||
selectedDates.update();
|
...(plan.declaredDays ?? []).filter(
|
||||||
return selectedDates;
|
(day) => day.date >= horizon.start && day.date < horizon.end,
|
||||||
}
|
),
|
||||||
|
]) {
|
||||||
setWeekendSelection(date: Date, selectedDates: SelectedDates) {
|
const day = new Date(`${placement.date}T00:00:00`);
|
||||||
if (date.getDay() === 6) {
|
ranges.push(
|
||||||
const end = new Date(date);
|
new SelectedDate(
|
||||||
end.setDate(end.getDate() + 1);
|
dayTypeForLeaveType[placement.type],
|
||||||
selectedDates._datesSelected.push(
|
new DateRange<Date>(day, day),
|
||||||
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(date, end)),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
if (date.getDate() === 1 && date.getDay() === 0) {
|
|
||||||
const start = new Date(date);
|
|
||||||
start.setDate(start.getDate() - 1);
|
|
||||||
selectedDates._datesSelected.push(
|
|
||||||
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(start, date)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return { months, selections: new SelectedDates(ranges) };
|
||||||
}
|
}
|
||||||
|
|
||||||
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
|
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
|
||||||
@@ -255,8 +228,6 @@ export type VacDay = {
|
|||||||
/** Legacy balances omit this value; the planner then uses the horizon start. */
|
/** Legacy balances omit this value; the planner then uses the horizon start. */
|
||||||
availableDate?: Date;
|
availableDate?: Date;
|
||||||
expiryDate: Date;
|
expiryDate: Date;
|
||||||
classification?: RemainingClassification;
|
|
||||||
reason?: PlanningReason;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Week = (Date | null)[];
|
export type Week = (Date | null)[];
|
||||||
@@ -285,16 +256,6 @@ const dayTypeForLeaveType: Record<LeaveType, DayType> = {
|
|||||||
other: DayType.OTHER,
|
other: DayType.OTHER,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
|
|
||||||
[DayType.WEEKEND]: 'Weekend',
|
|
||||||
[DayType.CLOSED_DAY]: 'Jour Férié',
|
|
||||||
[DayType.RTT]: 'RTT',
|
|
||||||
[DayType.CP]: 'Congé Payé',
|
|
||||||
[DayType.OTHER]: 'Autre',
|
|
||||||
[DayType.TODAY]: "Aujourd'hui",
|
|
||||||
[SelectableDayType.ANY]: "N'importe",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const monthNames: string[] = [
|
export const monthNames: string[] = [
|
||||||
'Janvier',
|
'Janvier',
|
||||||
'Février',
|
'Février',
|
||||||
@@ -354,15 +315,6 @@ export class SelectedDate implements SelectedDateInterface {
|
|||||||
return returnType;
|
return returnType;
|
||||||
}
|
}
|
||||||
|
|
||||||
getTooltip(date: Date): string | null | undefined {
|
|
||||||
if (this.isSelected(date)) {
|
|
||||||
if (this.type) {
|
|
||||||
return tooltipTypeMapping[this.type];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getLengthInDays(): number {
|
getLengthInDays(): number {
|
||||||
if (this.range.start && this.range.end) {
|
if (this.range.start && this.range.end) {
|
||||||
return dayjs(this.range.end).diff(dayjs(this.range.start), 'day') + 1;
|
return dayjs(this.range.end).diff(dayjs(this.range.start), 'day') + 1;
|
||||||
@@ -372,113 +324,60 @@ export class SelectedDate implements SelectedDateInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SelectedDates implements SelectedDateInterface {
|
export class SelectedDates implements SelectedDateInterface {
|
||||||
_datesSelected: SelectedDate[] = [];
|
private readonly ranges: readonly SelectedDate[];
|
||||||
datesSelected: SelectedDate[] = [];
|
// Ranges plus synthetic null-type groups for connected ranges, so connected periods share boundaries.
|
||||||
|
private readonly grouped: readonly SelectedDate[];
|
||||||
|
|
||||||
private minDate: Date | null = null;
|
constructor(ranges: readonly SelectedDate[] = []) {
|
||||||
private maxDate: Date | null = null;
|
this.ranges = ranges.slice();
|
||||||
|
this.grouped = SelectedDates.group(this.ranges);
|
||||||
update(): void {
|
|
||||||
this.datesSelected = this._datesSelected.slice();
|
|
||||||
this.grouping();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
push(selectedDate: SelectedDate) {
|
private static group(ranges: readonly SelectedDate[]): SelectedDate[] {
|
||||||
this._datesSelected.push(selectedDate);
|
const result = ranges.slice();
|
||||||
this.datesSelected = this._datesSelected.slice();
|
if (ranges.length <= 1) return result;
|
||||||
this.updateMinMaxDates();
|
const sorted = [...ranges].sort(
|
||||||
this.grouping();
|
(a, b) => (a.range.start?.getTime() ?? 0) - (b.range.start?.getTime() ?? 0),
|
||||||
}
|
);
|
||||||
|
|
||||||
private updateMinMaxDates(): void {
|
|
||||||
if (this._datesSelected.length === 0) {
|
|
||||||
this.minDate = null;
|
|
||||||
this.maxDate = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.minDate = this._datesSelected.reduce((min, date) => {
|
|
||||||
if (!date.range.start) return min;
|
|
||||||
if (!min) return date.range.start;
|
|
||||||
return date.range.start < min ? date.range.start : min;
|
|
||||||
}, this._datesSelected[0].range.start);
|
|
||||||
|
|
||||||
this.maxDate = this._datesSelected.reduce((max, date) => {
|
|
||||||
if (!date.range.end) return max;
|
|
||||||
if (!max) return date.range.end;
|
|
||||||
return date.range.end > max ? date.range.end : max;
|
|
||||||
}, this._datesSelected[0].range.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
// make a "mother group" with type = null when two or more groups are adjacent or overlapping; keep the children groups
|
|
||||||
grouping(): void {
|
|
||||||
if (this._datesSelected.length <= 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort dates by start date
|
|
||||||
const sorted = [...this._datesSelected].sort((a, b) => {
|
|
||||||
const aStart = a.range.start?.getTime() ?? 0;
|
|
||||||
const bStart = b.range.start?.getTime() ?? 0;
|
|
||||||
return aStart - bStart;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Group adjacent or overlapping dates
|
|
||||||
const groups: SelectedDate[][] = [];
|
const groups: SelectedDate[][] = [];
|
||||||
let currentGroup: SelectedDate[] = [sorted[0]];
|
let current: SelectedDate[] = [sorted[0]];
|
||||||
let currentGroupMaxEnd = sorted[0].range.end?.getTime() ?? 0;
|
let maxEnd = sorted[0].range.end?.getTime() ?? 0;
|
||||||
|
for (const next of sorted.slice(1)) {
|
||||||
for (let i = 1; i < sorted.length; i++) {
|
const nextDay = new Date(maxEnd);
|
||||||
const current = sorted[i];
|
|
||||||
const currStart = current.range.start?.getTime() ?? 0;
|
|
||||||
const currEnd = current.range.end?.getTime() ?? 0;
|
|
||||||
|
|
||||||
// Check if current date is adjacent or overlapping with the maximum end of the current group
|
|
||||||
const nextDay = new Date(currentGroupMaxEnd);
|
|
||||||
nextDay.setDate(nextDay.getDate() + 1);
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
if (currStart <= nextDay.getTime()) {
|
if ((next.range.start?.getTime() ?? 0) > nextDay.getTime()) {
|
||||||
currentGroup.push(current);
|
if (current.length > 1) groups.push(current);
|
||||||
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
|
current = [];
|
||||||
} else {
|
|
||||||
if (currentGroup.length > 1) {
|
|
||||||
groups.push(currentGroup);
|
|
||||||
}
|
}
|
||||||
currentGroup = [current];
|
current.push(next);
|
||||||
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
|
maxEnd = Math.max(maxEnd, next.range.end?.getTime() ?? 0);
|
||||||
}
|
}
|
||||||
}
|
if (current.length > 1) groups.push(current);
|
||||||
|
|
||||||
// Add the last group if it has more than one element
|
|
||||||
if (currentGroup.length > 1) {
|
|
||||||
groups.push(currentGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create mother groups and add to result
|
|
||||||
const result: SelectedDate[] = [...this._datesSelected];
|
|
||||||
|
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
const groupStart = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity));
|
const start = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity));
|
||||||
const groupEnd = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0));
|
const end = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0));
|
||||||
|
|
||||||
result.push(
|
result.push(
|
||||||
new SelectedDate(
|
new SelectedDate(null, new DateRange<Date>(new Date(start), new Date(end))),
|
||||||
null,
|
|
||||||
new DateRange<Date>(new Date(groupStart), new Date(groupEnd)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
this.datesSelected = result;
|
/** Day types covering the date, in insertion order (today is never a selection). */
|
||||||
|
dayTypes(date: Date): readonly DayType[] {
|
||||||
|
return this.ranges
|
||||||
|
.filter((selected) => selected.type && selected.isSelected(date))
|
||||||
|
.map((selected) => selected.type!);
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelected(date: Date): boolean {
|
isSelected(date: Date): boolean {
|
||||||
return this.datesSelected.some((selectedDate) => selectedDate.isSelected(date));
|
return this.grouped.some((selectedDate) => selectedDate.isSelected(date));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRangeBoundaryCounts(date: Date): { starts: number; ends: number } {
|
private getRangeBoundaryCounts(date: Date): { starts: number; ends: number } {
|
||||||
let starts = 0;
|
let starts = 0;
|
||||||
let ends = 0;
|
let ends = 0;
|
||||||
for (const selected of this.datesSelected) {
|
for (const selected of this.grouped) {
|
||||||
if (selected.range.start && date.getTime() === selected.range.start.getTime()) {
|
if (selected.range.start && date.getTime() === selected.range.start.getTime()) {
|
||||||
starts++;
|
starts++;
|
||||||
}
|
}
|
||||||
@@ -501,27 +400,9 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
return ends >= starts;
|
return ends >= starts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// True when the date lies strictly inside at least one selected range.
|
||||||
* Check whether a given date is selected and lies strictly inside a selected range
|
|
||||||
* (i.e., it is part of at least one selected range but is not the start or end of that range).
|
|
||||||
*
|
|
||||||
* This method returns true only if:
|
|
||||||
* - `this.isSelected(dateTest)` is true (the date is considered selected), and
|
|
||||||
* - there exists at least one element in `this.datesSelected` for which
|
|
||||||
* `dateSelected.isStart(dateTest)` and `dateSelected.isEnd(dateTest)` are both false.
|
|
||||||
*
|
|
||||||
* Notes:
|
|
||||||
* - The exact equality/comparison semantics are delegated to `isSelected`, `isStart`
|
|
||||||
* and `isEnd` implementations on the respective objects.
|
|
||||||
* - `dateTest` is expected to be a valid Date. Behavior for null/undefined/invalid Date
|
|
||||||
* values is not defined here.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @param dateTest - The date to test.
|
|
||||||
* @returns True if the date is an interior day of at least one selected range; otherwise false.
|
|
||||||
*/
|
|
||||||
private existInARangeButNotABoundary(dateTest: Date): boolean {
|
private existInARangeButNotABoundary(dateTest: Date): boolean {
|
||||||
return this.datesSelected.some(
|
return this.grouped.some(
|
||||||
(dateSelected) =>
|
(dateSelected) =>
|
||||||
dateSelected.isSelected(dateTest) &&
|
dateSelected.isSelected(dateTest) &&
|
||||||
!dateSelected.isStart(dateTest) &&
|
!dateSelected.isStart(dateTest) &&
|
||||||
@@ -530,13 +411,6 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getClasses(date: Date): string {
|
getClasses(date: Date): string {
|
||||||
return this.datesSelected.map((dateSelected) => dateSelected.getClasses(date)).join(' ');
|
return this.grouped.map((dateSelected) => dateSelected.getClasses(date)).join(' ');
|
||||||
}
|
|
||||||
|
|
||||||
getTooltip(date: Date): string | null | undefined {
|
|
||||||
return this.datesSelected
|
|
||||||
.map((dateSelected) => dateSelected.getTooltip(date))
|
|
||||||
.filter((tooltip) => tooltip)
|
|
||||||
.join(' + ');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { CalendarSettingsService } from './calendar-settings-service';
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
|
|
||||||
describe('CalendarSettingsService', () => {
|
describe('CalendarSettingsService', () => {
|
||||||
let service: CalendarSettingsService;
|
let service: CalendarSettingsService;
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { afterAll, describe, expect, it } from 'bun:test';
|
||||||
|
import { createOptimizationPresetRules } from './rule-service';
|
||||||
|
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
|
||||||
|
import { OPTIMIZATION_CATALOG } from './planning-objectives';
|
||||||
|
import {
|
||||||
|
calculatePlanningAsyncInternal,
|
||||||
|
calculatePlanningInternal,
|
||||||
|
type PlanningArtifacts,
|
||||||
|
type PlanningOutcome,
|
||||||
|
} from './leave-planner.core';
|
||||||
|
import {
|
||||||
|
calculatePlanningAsyncInternal as frozenCalculateAsync,
|
||||||
|
calculatePlanningInternal as frozenCalculate,
|
||||||
|
} from './frozen-engine/leave-planner.core.frozen.mjs';
|
||||||
|
|
||||||
|
/** Rebuild instructions for the frozen engine: docs/benchmark/README.md. */
|
||||||
|
const BASE_SEED = 280_000;
|
||||||
|
const CASES = 5000;
|
||||||
|
const CHUNK = 200;
|
||||||
|
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const weekday = (ms: number) => ![0, 6].includes(new Date(ms).getUTCDay());
|
||||||
|
const iso = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
type Req = Record<string, unknown> & { rules: Record<string, unknown>[] };
|
||||||
|
|
||||||
|
function generateRequest(seed: number): Req {
|
||||||
|
const rnd = mulberry32(seed);
|
||||||
|
const int = (min: number, max: number) => min + Math.floor(rnd() * (max - min + 1));
|
||||||
|
const pick = <T>(items: readonly T[]): T => items[int(0, items.length - 1)];
|
||||||
|
const chance = (p: number) => rnd() < p;
|
||||||
|
const shuffle = <T>(items: T[]): T[] => {
|
||||||
|
for (let i = items.length - 1; i > 0; i--) {
|
||||||
|
const j = int(0, i);
|
||||||
|
[items[i], items[j]] = [items[j], items[i]];
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const long = chance(0.1);
|
||||||
|
const mode = rnd();
|
||||||
|
let startMs = Date.UTC(2025, 0, 1) + int(0, 1094) * DAY;
|
||||||
|
if (mode < 0.15) startMs = Date.UTC(pick([2025, 2026]), 11, int(8, 31));
|
||||||
|
else if (mode < 0.3) {
|
||||||
|
const year = pick([2025, 2026, 2027]);
|
||||||
|
const holidays = [...frenchPublicHolidaysForYear(year)].filter((d) =>
|
||||||
|
weekday(Date.parse(d)),
|
||||||
|
);
|
||||||
|
startMs = Date.parse(pick(holidays)) - int(1, 10) * DAY;
|
||||||
|
}
|
||||||
|
const days = long ? int(29, 70) : int(7, 28);
|
||||||
|
const start = iso(startMs);
|
||||||
|
const date = (offset: number) => iso(startMs + offset * DAY);
|
||||||
|
const types = ['cp', 'rtt', 'other'] as const;
|
||||||
|
|
||||||
|
const balances = Array.from({ length: int(1, 3) }, (_, i) => ({
|
||||||
|
id: `b${i}`,
|
||||||
|
type: pick(types),
|
||||||
|
quantity: int(0, long ? 4 : 3),
|
||||||
|
...(chance(0.3) ? { availableFrom: date(int(1, days)) } : {}),
|
||||||
|
expiryDate: date(int(-3, days + 40)),
|
||||||
|
})).map((b) =>
|
||||||
|
b.availableFrom && b.availableFrom > b.expiryDate
|
||||||
|
? { ...b, expiryDate: b.availableFrom }
|
||||||
|
: b,
|
||||||
|
);
|
||||||
|
|
||||||
|
const declaredDays: { date: string; type: string }[] = [];
|
||||||
|
for (let n = int(0, 4); n > 0; n--) {
|
||||||
|
const ms = startMs + int(0, days) * DAY;
|
||||||
|
const d = iso(ms);
|
||||||
|
if (isWorkingPlanningDate(d) && !declaredDays.some((x) => x.date === d))
|
||||||
|
declaredDays.push({ date: d, type: pick(types) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectors = ['cp', 'rtt', 'other', 'any'] as const;
|
||||||
|
const rules: Record<string, unknown>[] = [];
|
||||||
|
for (let n = long ? int(0, 1) : int(0, 2); n > 0; n--) {
|
||||||
|
const from = int(0, days - 1);
|
||||||
|
rules.push({
|
||||||
|
id: `tw${rules.length}`,
|
||||||
|
type: chance(0.5) ? 'time_window' : 'TIME_WINDOW',
|
||||||
|
force: pick(['obligation', 'preference']),
|
||||||
|
startDate: date(from),
|
||||||
|
endDate: date(from + int(0, 10)),
|
||||||
|
placeDays: int(1, 4),
|
||||||
|
placeType: pick(selectors),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let n = long && rules.length ? 0 : long ? int(0, 1) : int(0, 2); n > 0; n--) {
|
||||||
|
rules.push({
|
||||||
|
id: `it${rules.length}`,
|
||||||
|
type: chance(0.5) ? 'if_then' : 'IF_THEN',
|
||||||
|
force: pick(['obligation', 'preference']),
|
||||||
|
dayOfWeek: int(1, 5),
|
||||||
|
placeDays: int(1, 3),
|
||||||
|
placeType: pick(selectors),
|
||||||
|
placement: pick(['before', 'after', 'around', 'auto']),
|
||||||
|
rewardDays: int(0, 2),
|
||||||
|
rewardType: pick(types),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const presets: Record<string, unknown>[] = shuffle(
|
||||||
|
createOptimizationPresetRules() as unknown as Record<string, unknown>[],
|
||||||
|
).slice(0, long ? int(1, 4) : int(0, 3));
|
||||||
|
rules.push(...presets);
|
||||||
|
if (!long && chance(0.3)) {
|
||||||
|
const row = pick(OPTIMIZATION_CATALOG);
|
||||||
|
const conditions = (row.conditions as readonly string[]).filter(() => chance(0.5));
|
||||||
|
rules.push({
|
||||||
|
id: 'composed',
|
||||||
|
type: 'optimization',
|
||||||
|
force: 'preference',
|
||||||
|
configuration: {
|
||||||
|
elements: row.elements,
|
||||||
|
conditions,
|
||||||
|
measure: row.measure,
|
||||||
|
aggregation: pick(row.aggregations),
|
||||||
|
direction: pick(['minimize', 'maximize']),
|
||||||
|
...(row.measure === 'work-sequence-deficit' ? { thresholdDays: int(1, 8) } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const rule of shuffle(rules)) if (chance(0.08)) rule['enabled'] = false;
|
||||||
|
|
||||||
|
const request: Req = {
|
||||||
|
calculationDate: start,
|
||||||
|
horizon: { start, end: date(days) },
|
||||||
|
balances,
|
||||||
|
declaredDays,
|
||||||
|
rules,
|
||||||
|
};
|
||||||
|
if (chance(0.04)) {
|
||||||
|
const breakIt = int(0, 3);
|
||||||
|
if (breakIt === 0) balances[0].quantity = -1;
|
||||||
|
else if (breakIt === 1) request['horizon'] = { start: date(days), end: start };
|
||||||
|
else if (breakIt === 2) rules.push({ id: 'bad', type: 'unknown_type' });
|
||||||
|
else declaredDays.push({ date: start, type: 'nope' });
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Run {
|
||||||
|
outcome: PlanningOutcome;
|
||||||
|
artifacts?: Pick<PlanningArtifacts, 'explanation' | 'finalState' | 'explanationError'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(
|
||||||
|
calculate: (
|
||||||
|
request: unknown,
|
||||||
|
c?: undefined,
|
||||||
|
a?: (artifacts: PlanningArtifacts) => void,
|
||||||
|
) => PlanningOutcome,
|
||||||
|
request: Req,
|
||||||
|
): Run {
|
||||||
|
let artifacts: Run['artifacts'];
|
||||||
|
const outcome = calculate(structuredClone(request), undefined, (a) => {
|
||||||
|
artifacts = {
|
||||||
|
explanation: a.explanation,
|
||||||
|
finalState: a.finalState,
|
||||||
|
explanationError: a.explanationError,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { outcome, artifacts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const kinds = new Map<string, number>();
|
||||||
|
const ruleTypes = new Set<string>();
|
||||||
|
const count = (key: string) => kinds.set(key, (kinds.get(key) ?? 0) + 1);
|
||||||
|
|
||||||
|
let slowest = { seed: 0, ms: 0 };
|
||||||
|
|
||||||
|
function crossesYearAndHoliday(request: Req): void {
|
||||||
|
const { start, end } = request['horizon'] as { start: string; end: string };
|
||||||
|
if (start.slice(0, 4) !== end.slice(0, 4)) count('crosses-year');
|
||||||
|
for (let ms = Date.parse(start); ms <= Date.parse(end); ms += DAY)
|
||||||
|
if (
|
||||||
|
weekday(ms) &&
|
||||||
|
frenchPublicHolidaysForYear(new Date(ms).getUTCFullYear()).has(iso(ms))
|
||||||
|
) {
|
||||||
|
count('holiday-week');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSameAsFrozen(seed: number): void {
|
||||||
|
const request = generateRequest(seed);
|
||||||
|
const t0 = performance.now();
|
||||||
|
const current = run(calculatePlanningInternal, request);
|
||||||
|
const frozen = run(frozenCalculate, request);
|
||||||
|
const ms = performance.now() - t0;
|
||||||
|
if (ms > slowest.ms) slowest = { seed, ms: Math.round(ms) };
|
||||||
|
crossesYearAndHoliday(request);
|
||||||
|
count(current.outcome.kind);
|
||||||
|
for (const rule of request.rules) ruleTypes.add(String(rule['type']).toLowerCase());
|
||||||
|
if ((request['declaredDays'] as unknown[]).length) count('declared');
|
||||||
|
if (request.rules.some((r) => r['rewardDays'])) count('reward');
|
||||||
|
try {
|
||||||
|
expect(current).toStrictEqual(frozen);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Engine differs from frozen engine. seed=${seed}\nrequest=${JSON.stringify(request)}\n${
|
||||||
|
(error as Error).message
|
||||||
|
}`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AsyncEngine = typeof calculatePlanningAsyncInternal;
|
||||||
|
|
||||||
|
/** Cancels after a seed-derived step count: during preparation, mid-search, or never. */
|
||||||
|
function cancelAfter(seed: number, total: number): number {
|
||||||
|
const rnd = mulberry32(seed ^ 0x5bd1e995);
|
||||||
|
const roll = rnd();
|
||||||
|
if (roll < 0.1) return Math.floor(rnd() * total);
|
||||||
|
if (roll < 0.25) return Number.POSITIVE_INFINITY;
|
||||||
|
// Preparation is most of the steps; spend the rest of the budget on the search.
|
||||||
|
return Math.floor(total * (0.9 + rnd() * 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCancelled(engine: AsyncEngine, request: Req, limit: number) {
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
const artifacts: unknown[] = [];
|
||||||
|
const explanations: unknown[] = [];
|
||||||
|
const outcomes: unknown[] = [];
|
||||||
|
let polls = 0;
|
||||||
|
const outcome = await engine(
|
||||||
|
structuredClone(request),
|
||||||
|
(plan, state) => calls.push({ plan, state }),
|
||||||
|
({ timings: _timings, ...rest }) => artifacts.push(rest),
|
||||||
|
(o) => outcomes.push(o),
|
||||||
|
(...args) => explanations.push(args),
|
||||||
|
() => ++polls > limit,
|
||||||
|
);
|
||||||
|
return { outcome, calls, artifacts, explanations, outcomes, polls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelledKinds = new Map<string, number>();
|
||||||
|
let incumbents = 0;
|
||||||
|
|
||||||
|
async function assertCancelledSameAsFrozen(seed: number): Promise<void> {
|
||||||
|
const request = generateRequest(seed);
|
||||||
|
const total = (await runCancelled(calculatePlanningAsyncInternal, request, Infinity)).polls;
|
||||||
|
const limit = cancelAfter(seed, total);
|
||||||
|
const current = await runCancelled(calculatePlanningAsyncInternal, request, limit);
|
||||||
|
const frozen = await runCancelled(frozenCalculateAsync, request, limit);
|
||||||
|
const kind = current.outcome.kind;
|
||||||
|
cancelledKinds.set(kind, (cancelledKinds.get(kind) ?? 0) + 1);
|
||||||
|
if (kind === 'PlanningIncomplete' && current.calls.length) incumbents++;
|
||||||
|
try {
|
||||||
|
expect(current).toStrictEqual(frozen);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Cancelled run differs from frozen engine. seed=${seed} limit=${limit}\nrequest=${JSON.stringify(request)}\n${
|
||||||
|
(error as Error).message
|
||||||
|
}`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ONLY_SEED = process.env['FROZEN_ENGINE_SEED'];
|
||||||
|
|
||||||
|
describe('frozen engine differential check', () => {
|
||||||
|
if (ONLY_SEED) {
|
||||||
|
it(`matches the frozen engine for seed ${ONLY_SEED}`, async () => {
|
||||||
|
assertSameAsFrozen(Number(ONLY_SEED));
|
||||||
|
await assertCancelledSameAsFrozen(Number(ONLY_SEED));
|
||||||
|
}, 120_000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let from = 0; from < CASES; from += CHUNK) {
|
||||||
|
it(`matches the frozen engine for seeds ${BASE_SEED + from}..${BASE_SEED + from + CHUNK - 1}`, () => {
|
||||||
|
for (let i = from; i < from + CHUNK; i++) assertSameAsFrozen(BASE_SEED + i);
|
||||||
|
}, 120_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let from = 0; from < CASES; from += CHUNK * 2) {
|
||||||
|
it(`matches the frozen engine when cancelled, seeds ${BASE_SEED + from}.. every 10th`, async () => {
|
||||||
|
for (let i = from; i < from + CHUNK * 2; i += 10)
|
||||||
|
await assertCancelledSameAsFrozen(BASE_SEED + i);
|
||||||
|
}, 300_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('retains incumbents of cancelled calculations', () => {
|
||||||
|
console.info('cancelled coverage', Object.fromEntries(cancelledKinds), { incumbents });
|
||||||
|
expect(cancelledKinds.get('PlanningIncomplete') ?? 0).toBeGreaterThan(20);
|
||||||
|
expect(incumbents).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
console.info(
|
||||||
|
'frozen-engine coverage of generated cases',
|
||||||
|
Object.fromEntries(kinds),
|
||||||
|
[...ruleTypes],
|
||||||
|
'slowest case',
|
||||||
|
slowest,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates every outcome kind and rule type', () => {
|
||||||
|
for (const kind of [
|
||||||
|
'Plan',
|
||||||
|
'PlanningConflict',
|
||||||
|
'InvalidInput',
|
||||||
|
'declared',
|
||||||
|
'reward',
|
||||||
|
'crosses-year',
|
||||||
|
'holiday-week',
|
||||||
|
])
|
||||||
|
expect(kinds.get(kind) ?? 0, kind).toBeGreaterThan(20);
|
||||||
|
expect(['if_then', 'optimization', 'time_window'].filter((t) => !ruleTypes.has(t))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type {
|
||||||
|
PlanningArtifactsCallback,
|
||||||
|
PlanningCheckpoint,
|
||||||
|
PlanningExplanationCallback,
|
||||||
|
PlanningOutcome,
|
||||||
|
PlanningOutcomeCallback,
|
||||||
|
} from '../leave-planner.core';
|
||||||
|
|
||||||
|
export function calculatePlanningInternal(
|
||||||
|
request: unknown,
|
||||||
|
checkpointCallback?: undefined,
|
||||||
|
artifactsCallback?: PlanningArtifactsCallback,
|
||||||
|
): PlanningOutcome;
|
||||||
|
|
||||||
|
export function calculatePlanningAsyncInternal(
|
||||||
|
request: unknown,
|
||||||
|
checkpointCallback?: PlanningCheckpoint,
|
||||||
|
artifactsCallback?: PlanningArtifactsCallback,
|
||||||
|
outcomeCallback?: PlanningOutcomeCallback,
|
||||||
|
explanationCallback?: PlanningExplanationCallback,
|
||||||
|
isCancelled?: () => boolean,
|
||||||
|
): Promise<PlanningOutcome>;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
analyzeCounterfactualInternal,
|
analyzeCounterfactualInternal,
|
||||||
calculatePlanningInternal,
|
calculatePlanningInternal,
|
||||||
@@ -334,6 +334,43 @@ describe('composed calendar objectives', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.each(['obligation', 'preference'] as const)(
|
||||||
|
'keeps a lower %s only when required while completing a holiday week',
|
||||||
|
(force) => {
|
||||||
|
const { plan } = calculate({
|
||||||
|
...concurrent,
|
||||||
|
rules: [
|
||||||
|
holiday,
|
||||||
|
{
|
||||||
|
id: 'monday',
|
||||||
|
type: 'time_window',
|
||||||
|
force,
|
||||||
|
startDate: '2024-04-29',
|
||||||
|
endDate: '2024-04-29',
|
||||||
|
placeDays: 1,
|
||||||
|
placeType: 'cp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (force === 'obligation') {
|
||||||
|
expect(plan.placements).toMatchObject([
|
||||||
|
{ date: '2024-04-29', type: 'cp', ruleIds: ['monday'] },
|
||||||
|
]);
|
||||||
|
expect(plan.metrics.holidayWeekValue).toBe(0);
|
||||||
|
} else {
|
||||||
|
expect(plan.placements.map(({ date }) => date)).toEqual([
|
||||||
|
'2024-05-06',
|
||||||
|
'2024-05-07',
|
||||||
|
'2024-05-10',
|
||||||
|
]);
|
||||||
|
expect(plan.metrics.holidayWeekValue).toBe(5);
|
||||||
|
expect(plan.placements.every(({ ruleIds }) => !ruleIds.includes('monday'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('adds no residual loss-prevention or calendar placements when their rules are disabled', () => {
|
it('adds no residual loss-prevention or calendar placements when their rules are disabled', () => {
|
||||||
const request: PlanningRequest = {
|
const request: PlanningRequest = {
|
||||||
horizon: { start: '2024-04-29', end: '2024-05-04' },
|
horizon: { start: '2024-04-29', end: '2024-05-04' },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createOptimizationPresetRules } from './rule-service';
|
import { createOptimizationPresetRules } from './rule-service';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { createPlanningCalculation, type PlanningIncompleteOutcome } from './leave-planner.service';
|
import { createPlanningCalculation, type PlanningIncompleteOutcome } from './leave-planner.service';
|
||||||
|
|
||||||
const request = {
|
const request = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createOptimizationPresetRules } from './rule-service';
|
import { createOptimizationPresetRules } from './rule-service';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { createPlanningCalculation } from './leave-planner.service';
|
import { createPlanningCalculation } from './leave-planner.service';
|
||||||
|
|
||||||
const balance = (
|
const balance = (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createOptimizationPresetRules } from './rule-service';
|
import { createOptimizationPresetRules } from './rule-service';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { calculatePlanningInternal, type PlanningArtifacts } from './leave-planner.core';
|
import { calculatePlanningInternal, type PlanningArtifacts } from './leave-planner.core';
|
||||||
import {
|
import {
|
||||||
evaluateOptimization,
|
evaluateOptimization,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import {
|
import {
|
||||||
calculatePlanningAsyncInternal,
|
calculatePlanningAsyncInternal,
|
||||||
calculatePlanningInternal,
|
calculatePlanningInternal,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import {
|
import {
|
||||||
calculatePlanningInternal,
|
calculatePlanningInternal,
|
||||||
type OptimizationPlanningRule,
|
type OptimizationPlanningRule,
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { vi } from '../../testing/vitest-compat';
|
||||||
|
import {
|
||||||
|
createPlanningCalculation,
|
||||||
|
type LeavePlacement,
|
||||||
|
type PlanningCalculationContext,
|
||||||
|
} from './leave-planner.service';
|
||||||
|
|
||||||
|
const request = { horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [], rules: [] };
|
||||||
|
const plan = {
|
||||||
|
kind: 'Plan',
|
||||||
|
placements: [{ date: '2024-01-05', balanceId: 'cp' } as unknown as LeavePlacement],
|
||||||
|
completedStages: [],
|
||||||
|
};
|
||||||
|
const state = { marker: 'state' };
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
class FakeWorker {
|
||||||
|
static all: FakeWorker[] = [];
|
||||||
|
static constructThrows = false;
|
||||||
|
static postThrows = false;
|
||||||
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
terminate = vi.fn();
|
||||||
|
postMessage = vi.fn(() => {
|
||||||
|
if (this.throwOnPost) throw new Error('post failed');
|
||||||
|
});
|
||||||
|
private throwOnPost = FakeWorker.postThrows;
|
||||||
|
constructor() {
|
||||||
|
if (FakeWorker.constructThrows) throw new Error('no worker');
|
||||||
|
FakeWorker.all.push(this);
|
||||||
|
}
|
||||||
|
emit(data: unknown) {
|
||||||
|
this.onmessage?.({ data } as MessageEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Started {
|
||||||
|
worker?: FakeWorker;
|
||||||
|
succeeded: Promise<boolean>;
|
||||||
|
deliver: () => void;
|
||||||
|
late: () => void;
|
||||||
|
}
|
||||||
|
type Flags = { construct?: boolean; post?: boolean };
|
||||||
|
const arm = (flags: Flags) => {
|
||||||
|
FakeWorker.constructThrows = !!flags.construct;
|
||||||
|
FakeWorker.postThrows = !!flags.post;
|
||||||
|
};
|
||||||
|
|
||||||
|
const jobs: Record<string, (flags: Flags) => Promise<Started>> = {
|
||||||
|
plan: async (flags) => {
|
||||||
|
arm(flags);
|
||||||
|
const progress = vi.fn();
|
||||||
|
const calculation = createPlanningCalculation({ ...request, progress });
|
||||||
|
const worker = FakeWorker.all[0];
|
||||||
|
return {
|
||||||
|
worker,
|
||||||
|
succeeded: calculation.outcome.then((outcome) => outcome.kind === 'Plan'),
|
||||||
|
deliver: () => {
|
||||||
|
worker?.emit({ type: 'result', outcome: plan });
|
||||||
|
worker?.emit({ type: 'artifacts', artifacts: {} });
|
||||||
|
},
|
||||||
|
late: () => {
|
||||||
|
worker?.emit({ type: 'progress', stage: 'late' });
|
||||||
|
expect(progress).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
counterfactual: async (flags) => {
|
||||||
|
const calculation = createPlanningCalculation(request);
|
||||||
|
FakeWorker.all[0].emit({ type: 'result', outcome: plan });
|
||||||
|
FakeWorker.all[0].emit({ type: 'artifacts', artifacts: {} });
|
||||||
|
await calculation.timings;
|
||||||
|
arm(flags);
|
||||||
|
const analysis = (calculation as PlanningCalculationContext).analyzeCounterfactual(
|
||||||
|
plan.placements[0],
|
||||||
|
);
|
||||||
|
const succeeded = analysis.then(
|
||||||
|
(result) => result.kind !== 'CounterfactualStale',
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
const worker = FakeWorker.all[1];
|
||||||
|
return {
|
||||||
|
worker,
|
||||||
|
succeeded,
|
||||||
|
deliver: () =>
|
||||||
|
worker?.emit({ type: 'counterfactual-result', analysis: { results: [] } }),
|
||||||
|
late: () => worker?.emit({ type: 'error', message: 'late' }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
'explain-checkpoint': async (flags) => {
|
||||||
|
const calculation = createPlanningCalculation(request);
|
||||||
|
const first = FakeWorker.all[0];
|
||||||
|
first.emit({ type: 'checkpoint', plan, state });
|
||||||
|
arm(flags);
|
||||||
|
first.emit({ type: 'error', message: 'planner crashed' });
|
||||||
|
const worker = FakeWorker.all[1];
|
||||||
|
return {
|
||||||
|
worker,
|
||||||
|
succeeded: calculation.explanation.then((explanation) => explanation !== undefined),
|
||||||
|
deliver: () =>
|
||||||
|
worker?.emit({
|
||||||
|
type: 'explanation',
|
||||||
|
explanation: { kind: 'IncompleteExplanation' },
|
||||||
|
}),
|
||||||
|
late: () => worker?.emit({ type: 'error', message: 'late' }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('worker job lifetime', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeWorker.all = [];
|
||||||
|
arm({});
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
vi.stubGlobal('Worker', FakeWorker);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
arm({});
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each(Object.keys(jobs))('%s job', (name) => {
|
||||||
|
const start = jobs[name];
|
||||||
|
const released = (worker: FakeWorker) => {
|
||||||
|
expect(worker.terminate).toHaveBeenCalledOnce();
|
||||||
|
expect(worker.onmessage).toBeNull();
|
||||||
|
expect(worker.onerror).toBeNull();
|
||||||
|
};
|
||||||
|
|
||||||
|
it('releases the worker once on success and leaves no handler for late messages', async () => {
|
||||||
|
const job = await start({});
|
||||||
|
job.deliver();
|
||||||
|
expect(await job.succeeded).toBe(true);
|
||||||
|
released(job.worker!);
|
||||||
|
job.late();
|
||||||
|
job.deliver();
|
||||||
|
released(job.worker!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails without a worker when construction throws', async () => {
|
||||||
|
const job = await start({ construct: true });
|
||||||
|
expect(job.worker).toBeUndefined();
|
||||||
|
expect(await job.succeeded).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the worker once when postMessage throws', async () => {
|
||||||
|
const job = await start({ post: true });
|
||||||
|
expect(await job.succeeded).toBe(false);
|
||||||
|
released(job.worker!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the worker once on a runtime error', async () => {
|
||||||
|
const job = await start({});
|
||||||
|
job.worker!.onerror!();
|
||||||
|
expect(await job.succeeded).toBe(false);
|
||||||
|
released(job.worker!);
|
||||||
|
job.deliver();
|
||||||
|
expect(await job.succeeded).toBe(false);
|
||||||
|
released(job.worker!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the worker once when a newer calculation supersedes it', async () => {
|
||||||
|
const job = await start({});
|
||||||
|
createPlanningCalculation(request);
|
||||||
|
expect(await job.succeeded).toBe(false);
|
||||||
|
released(job.worker!);
|
||||||
|
job.deliver();
|
||||||
|
released(job.worker!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,24 @@ import {
|
|||||||
DeclaredNonWorkingDay,
|
DeclaredNonWorkingDay,
|
||||||
parseDeclaredDays,
|
parseDeclaredDays,
|
||||||
frenchPublicHolidaysForYear,
|
frenchPublicHolidaysForYear,
|
||||||
isWorkingPlanningDate,
|
|
||||||
} from './declared-non-working-days';
|
} from './declared-non-working-days';
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
compareDate,
|
||||||
|
dateRange,
|
||||||
|
evaluateStateOptimization,
|
||||||
|
expiryTieKey,
|
||||||
|
isAtRisk,
|
||||||
|
optimizationBalanceObservations,
|
||||||
|
optimizationObservations,
|
||||||
|
periodData,
|
||||||
|
derivePlacements,
|
||||||
|
notePlacementAdded,
|
||||||
|
notePlacementRemoved,
|
||||||
|
placementTieKey,
|
||||||
|
utcDate,
|
||||||
|
workSequenceDates,
|
||||||
|
} from './plan-observations';
|
||||||
import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date';
|
import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date';
|
||||||
import {
|
import {
|
||||||
compareObjectiveLists,
|
compareObjectiveLists,
|
||||||
@@ -501,22 +517,6 @@ const occurrenceCache = new WeakMap<SearchContext, Map<string, string[]>>();
|
|||||||
const calendarPatternCache = new WeakMap<SearchContext, Map<string, string[][]>>();
|
const calendarPatternCache = new WeakMap<SearchContext, Map<string, string[][]>>();
|
||||||
const preferencePatternCache = new WeakMap<SearchContext, Map<string, string[][][]>>();
|
const preferencePatternCache = new WeakMap<SearchContext, Map<string, string[][][]>>();
|
||||||
const declaredWindowCache = new WeakMap<SearchContext, Map<string, DeclaredNonWorkingDay[]>>();
|
const declaredWindowCache = new WeakMap<SearchContext, Map<string, DeclaredNonWorkingDay[]>>();
|
||||||
type CalendarOptimizationObservations = Pick<
|
|
||||||
OptimizationObservations,
|
|
||||||
'publicHolidayWeeks' | 'timeOffPeriods' | 'workSequences'
|
|
||||||
>;
|
|
||||||
const calendarObservationCache = new WeakMap<
|
|
||||||
SearchContext,
|
|
||||||
Map<string, CalendarOptimizationObservations>
|
|
||||||
>();
|
|
||||||
interface CalendarPeriod {
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
duration: number;
|
|
||||||
nonWorkingDates: readonly string[];
|
|
||||||
declaredDates: readonly string[];
|
|
||||||
}
|
|
||||||
const calendarPeriodCache = new WeakMap<SearchContext, Map<string, CalendarPeriod>>();
|
|
||||||
|
|
||||||
interface SearchContext {
|
interface SearchContext {
|
||||||
declaredDays: readonly DeclaredNonWorkingDay[];
|
declaredDays: readonly DeclaredNonWorkingDay[];
|
||||||
@@ -588,7 +588,6 @@ class UserCancelled extends Error {}
|
|||||||
// A true value asks the asynchronous driver to pause immediately at a phase boundary.
|
// A true value asks the asynchronous driver to pause immediately at a phase boundary.
|
||||||
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
|
type PlanningSteps<T> = Generator<boolean | undefined, T, void>;
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
|
||||||
// ponytail: cap the number of feasible obligation branches previewed for a
|
// ponytail: cap the number of feasible obligation branches previewed for a
|
||||||
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
|
// loaded year; replace with bounded branch-and-bound if mobile evidence shows
|
||||||
// that this ceiling misses a better lexicographic assignment.
|
// that this ceiling misses a better lexicographic assignment.
|
||||||
@@ -636,29 +635,10 @@ function recordValue(value: unknown): RuntimeRecord | undefined {
|
|||||||
return typeof value === 'object' && value !== null ? (value as RuntimeRecord) : undefined;
|
return typeof value === 'object' && value !== null ? (value as RuntimeRecord) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function utcDate(key: string): Date {
|
|
||||||
return new Date(`${key}T00:00:00Z`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDays(key: string, days: number): string {
|
|
||||||
return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareDate(a: string, b: string): number {
|
|
||||||
return a < b ? -1 : a > b ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareText(a: string, b: string): number {
|
function compareText(a: string, b: string): number {
|
||||||
return a < b ? -1 : a > b ? 1 : 0;
|
return a < b ? -1 : a > b ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateRange(start: string, endExclusive: string): string[] {
|
|
||||||
const result: string[] = [];
|
|
||||||
for (let date = start; compareDate(date, endExclusive) < 0; date = addDays(date, 1))
|
|
||||||
result.push(date);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultHorizon(start: string): { start: string; end: string } {
|
function defaultHorizon(start: string): { start: string; end: string } {
|
||||||
return { start, end: addPlanningYears(start, 1) };
|
return { start, end: addPlanningYears(start, 1) };
|
||||||
}
|
}
|
||||||
@@ -720,10 +700,10 @@ function reason(code: string, message: string, ruleIds?: string[]): PlanningReas
|
|||||||
return ruleIds?.length ? { code, message, ruleIds } : { code, message };
|
return ruleIds?.length ? { code, message, ruleIds } : { code, message };
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneState(state: State): State {
|
function cloneState(state: State, placements?: State['placements']): State {
|
||||||
return {
|
const clonedPlacements =
|
||||||
balances: state.balances.map((balance) => ({ ...balance })),
|
placements ??
|
||||||
placements: new Map(
|
new Map(
|
||||||
[...state.placements.entries()].map(([date, placement]) => [
|
[...state.placements.entries()].map(([date, placement]) => [
|
||||||
date,
|
date,
|
||||||
{
|
{
|
||||||
@@ -731,7 +711,11 @@ function cloneState(state: State): State {
|
|||||||
ruleIds: new Set(placement.ruleIds),
|
ruleIds: new Set(placement.ruleIds),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
),
|
);
|
||||||
|
derivePlacements(state.placements, clonedPlacements);
|
||||||
|
return {
|
||||||
|
balances: state.balances.map((balance) => ({ ...balance })),
|
||||||
|
placements: clonedPlacements,
|
||||||
earnedActivations: state.earnedActivations.map((activation) => ({
|
earnedActivations: state.earnedActivations.map((activation) => ({
|
||||||
...activation,
|
...activation,
|
||||||
dates: [...activation.dates],
|
dates: [...activation.dates],
|
||||||
@@ -1481,6 +1465,7 @@ function assignDateVariants(
|
|||||||
const result = cloneState(state);
|
const result = cloneState(state);
|
||||||
const target = result.balances.find((item) => item.id === balance.id)!;
|
const target = result.balances.find((item) => item.id === balance.id)!;
|
||||||
target.remaining--;
|
target.remaining--;
|
||||||
|
notePlacementAdded(state.placements, result.placements, date);
|
||||||
result.placements.set(date, {
|
result.placements.set(date, {
|
||||||
date,
|
date,
|
||||||
balanceId: target.id,
|
balanceId: target.id,
|
||||||
@@ -1513,9 +1498,13 @@ function assignDateToBalance(
|
|||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const result = cloneState(state);
|
// Reject impossible mandatory moves before copying; validate complete patterns later.
|
||||||
|
if (!mandatoryRelocationDate(date, balance, ruleIds, context)) return null;
|
||||||
|
// Existing placements stay read-only; this assignment owns only its new placement.
|
||||||
|
const result = cloneState(state, new Map(state.placements));
|
||||||
const target = result.balances.find((item) => item.id === balanceId)!;
|
const target = result.balances.find((item) => item.id === balanceId)!;
|
||||||
target.remaining--;
|
target.remaining--;
|
||||||
|
notePlacementAdded(state.placements, result.placements, date);
|
||||||
result.placements.set(date, {
|
result.placements.set(date, {
|
||||||
date,
|
date,
|
||||||
balanceId,
|
balanceId,
|
||||||
@@ -1856,6 +1845,7 @@ function releasePlacement(state: State, date: string): State {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const placement = result.placements.get(date)!;
|
const placement = result.placements.get(date)!;
|
||||||
|
notePlacementRemoved(state.placements, result.placements, date);
|
||||||
result.placements.delete(date);
|
result.placements.delete(date);
|
||||||
result.balances.find((balance) => balance.id === placement.balanceId)!.remaining++;
|
result.balances.find((balance) => balance.id === placement.balanceId)!.remaining++;
|
||||||
return result;
|
return result;
|
||||||
@@ -2105,8 +2095,10 @@ function finalizeRuleCandidate(state: State, context: SearchContext, before: Sta
|
|||||||
const patternCredits = new Map<string, Set<string>>();
|
const patternCredits = new Map<string, Set<string>>();
|
||||||
for (const placement of state.placements.values()) {
|
for (const placement of state.placements.values()) {
|
||||||
let lostCalendarRequest = false;
|
let lostCalendarRequest = false;
|
||||||
|
let hasCalendarRequest = false;
|
||||||
for (const id of placement.ruleIds) {
|
for (const id of placement.ruleIds) {
|
||||||
const rule = context.rules.find((item) => item.id === id);
|
const rule = context.rules.find((item) => item.id === id);
|
||||||
|
if (rule?.type === 'optimization') continue;
|
||||||
const valid = counterfactualRuleValid(
|
const valid = counterfactualRuleValid(
|
||||||
placement.date,
|
placement.date,
|
||||||
placement.type,
|
placement.type,
|
||||||
@@ -2135,18 +2127,16 @@ function finalizeRuleCandidate(state: State, context: SearchContext, before: Sta
|
|||||||
withinRequest = patternCredits.get(id)!.has(placement.date);
|
withinRequest = patternCredits.get(id)!.has(placement.date);
|
||||||
}
|
}
|
||||||
if (valid && withinRequest) {
|
if (valid && withinRequest) {
|
||||||
|
hasCalendarRequest = true;
|
||||||
if (rule?.type === 'time_window') windowCredits.set(id, credited + 1);
|
if (rule?.type === 'time_window') windowCredits.set(id, credited + 1);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!valid && rule?.force === 'obligation') return null;
|
if (!valid && rule?.force === 'obligation') return null;
|
||||||
if (result === state) result = cloneState(state);
|
if (result === state) result = cloneState(state);
|
||||||
result.placements.get(placement.date)!.ruleIds.delete(id);
|
result.placements.get(placement.date)!.ruleIds.delete(id);
|
||||||
lostCalendarRequest ||= rule?.type !== 'optimization';
|
lostCalendarRequest = true;
|
||||||
}
|
}
|
||||||
const current = result.placements.get(placement.date)!;
|
const current = result.placements.get(placement.date)!;
|
||||||
const hasCalendarRequest = [...current.ruleIds].some((id) =>
|
|
||||||
context.rules.some((rule) => rule.id === id && rule.type !== 'optimization'),
|
|
||||||
);
|
|
||||||
const previous = before.placements.get(placement.date);
|
const previous = before.placements.get(placement.date);
|
||||||
let supporting = current.supporting;
|
let supporting = current.supporting;
|
||||||
if (hasCalendarRequest) supporting = false;
|
if (hasCalendarRequest) supporting = false;
|
||||||
@@ -2272,7 +2262,8 @@ function* calendarPlacementCandidates(
|
|||||||
if (index === missing.length) {
|
if (index === missing.length) {
|
||||||
const benefit = supportingCalendarBenefit(before, current, context);
|
const benefit = supportingCalendarBenefit(before, current, context);
|
||||||
for (const date of additions) {
|
for (const date of additions) {
|
||||||
const placement = current.placements.get(date)!;
|
const placement = { ...current.placements.get(date)! };
|
||||||
|
current.placements.set(date, placement);
|
||||||
const balance = current.balances.find((item) => item.id === placement.balanceId)!;
|
const balance = current.balances.find((item) => item.id === placement.balanceId)!;
|
||||||
placement.supporting = benefit && !isAtRisk(balance, context);
|
placement.supporting = benefit && !isAtRisk(balance, context);
|
||||||
}
|
}
|
||||||
@@ -2359,6 +2350,71 @@ function* timeOffPeriodPlacementCandidates(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FinalizeInvariance = 'exact' | 'without-earning' | 'unknown';
|
||||||
|
const finalizeInvarianceCache = new WeakMap<SearchContext, FinalizeInvariance[]>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalizing never moves or retypes placements; it only edits ruleIds, supporting, and earned
|
||||||
|
* activations/balances. Preference results and the calendar optimizations (public-holiday-weeks,
|
||||||
|
* time-off-periods, work-sequences) read only placement dates and types, so the pre-finalize
|
||||||
|
* value is already the final one. leave-balances is exact only when nothing can be earned (no
|
||||||
|
* rewarding if_then rule and no earned activation on the candidate). leave-placements reads
|
||||||
|
* `supporting`, which finalizing rewrites. Entries follow activeObjectives order.
|
||||||
|
*/
|
||||||
|
function finalizeInvariance(context: SearchContext): FinalizeInvariance[] {
|
||||||
|
let entries = finalizeInvarianceCache.get(context);
|
||||||
|
if (!entries) {
|
||||||
|
const earning = context.rules.some(
|
||||||
|
(rule) => rule.enabled && rule.type === 'if_then' && rule.rewardDays,
|
||||||
|
);
|
||||||
|
entries = context.rules
|
||||||
|
.filter((rule) => rule.enabled && rule.force === 'preference')
|
||||||
|
.map((rule) => {
|
||||||
|
if (rule.type !== 'optimization') return 'exact';
|
||||||
|
switch (rule.configuration.elements) {
|
||||||
|
case 'public-holiday-weeks':
|
||||||
|
case 'time-off-periods':
|
||||||
|
case 'work-sequences':
|
||||||
|
return 'exact';
|
||||||
|
case 'leave-balances':
|
||||||
|
return earning ? 'unknown' : 'without-earning';
|
||||||
|
default:
|
||||||
|
// New element kinds stay unpruned until their bound is argued.
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
finalizeInvarianceCache.set(context, entries);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the first differing finalize-invariant protected objective is already worse than the baseline. */
|
||||||
|
function provablyWorseThanProtectedPrefix(
|
||||||
|
candidate: State,
|
||||||
|
context: SearchContext,
|
||||||
|
throughIndex: number,
|
||||||
|
baseline: readonly PlanningObjective[],
|
||||||
|
): boolean {
|
||||||
|
const invariance = finalizeInvariance(context);
|
||||||
|
let index = 0;
|
||||||
|
for (const objective of activeObjectives(candidate, context, throughIndex)) {
|
||||||
|
const kind = invariance[index];
|
||||||
|
if (
|
||||||
|
kind === 'unknown' ||
|
||||||
|
(kind === 'without-earning' && candidate.earnedActivations.length)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
const comparison = compareObjectiveValues(
|
||||||
|
objective.direction,
|
||||||
|
objective.value,
|
||||||
|
baseline[index].value,
|
||||||
|
);
|
||||||
|
if (comparison !== 0) return comparison > 0;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function evaluateRuleCandidate(
|
function evaluateRuleCandidate(
|
||||||
before: State,
|
before: State,
|
||||||
candidate: State | null,
|
candidate: State | null,
|
||||||
@@ -2367,18 +2423,42 @@ function evaluateRuleCandidate(
|
|||||||
addition = false,
|
addition = false,
|
||||||
baseline = activeObjectiveVector(before, context, throughIndex),
|
baseline = activeObjectiveVector(before, context, throughIndex),
|
||||||
incumbentVector = objectiveVector(before, context),
|
incumbentVector = objectiveVector(before, context),
|
||||||
): { state: State; vector: PlanningObjectiveVector } | undefined {
|
): { state: State; vector?: PlanningObjectiveVector } | undefined {
|
||||||
if (!candidate) return undefined;
|
if (!candidate) return undefined;
|
||||||
|
if (provablyWorseThanProtectedPrefix(candidate, context, throughIndex, baseline))
|
||||||
|
return undefined;
|
||||||
const state = finalizeRuleCandidate(candidate, context, before);
|
const state = finalizeRuleCandidate(candidate, context, before);
|
||||||
if (!state) return undefined;
|
if (!state) return undefined;
|
||||||
const active = activeObjectiveVector(state, context);
|
const active: PlanningObjective[] = [];
|
||||||
const comparison = compareObjectiveLists(active.slice(0, throughIndex + 1), baseline);
|
let comparison = 0;
|
||||||
|
let incumbentComparison = 0;
|
||||||
|
for (const objective of activeObjectives(state, context)) {
|
||||||
|
const index = active.length;
|
||||||
|
active.push(objective);
|
||||||
|
if (index <= throughIndex && comparison === 0) {
|
||||||
|
comparison = compareObjectiveValues(
|
||||||
|
objective.direction,
|
||||||
|
objective.value,
|
||||||
|
baseline[index].value,
|
||||||
|
);
|
||||||
|
// A worse protected prefix cannot improve the jointly valid incumbent.
|
||||||
|
if (comparison > 0) return undefined;
|
||||||
|
}
|
||||||
|
if (incumbentComparison === 0)
|
||||||
|
incumbentComparison = compareObjectiveValues(
|
||||||
|
objective.direction,
|
||||||
|
objective.value,
|
||||||
|
incumbentVector.objectives[index + 1].value,
|
||||||
|
);
|
||||||
|
if (incumbentComparison > 0 && (comparison < 0 || index >= throughIndex))
|
||||||
|
return addition && comparison === 0 ? undefined : { state };
|
||||||
|
}
|
||||||
const vector = objectiveVector(state, context, active);
|
const vector = objectiveVector(state, context, active);
|
||||||
// Preserve the best valid candidate even if the active prefix rejects it.
|
// Preserve the best valid candidate even if the active prefix rejects it.
|
||||||
if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0)
|
if (compareObjectiveLists(vector.objectives, incumbentVector.objectives) < 0)
|
||||||
context.retain?.(state, undefined, vector);
|
context.retain?.(state, undefined, vector);
|
||||||
// shortcut: single additions cannot cross equal-value plateaus; add coordinated balance bundles when a measured configuration needs them.
|
// shortcut: single additions cannot cross equal-value plateaus; add coordinated balance bundles when a measured configuration needs them.
|
||||||
if (comparison > 0 || (addition && comparison === 0)) return undefined;
|
if (addition && comparison === 0) return undefined;
|
||||||
return { state, vector };
|
return { state, vector };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2622,6 +2702,7 @@ function* optimizeRulePlacements(
|
|||||||
bestVector,
|
bestVector,
|
||||||
);
|
);
|
||||||
if (!evaluated) return false;
|
if (!evaluated) return false;
|
||||||
|
if (!evaluated.vector) return true;
|
||||||
const compared = compareObjectiveLists(
|
const compared = compareObjectiveLists(
|
||||||
evaluated.vector.objectives,
|
evaluated.vector.objectives,
|
||||||
bestVector.objectives,
|
bestVector.objectives,
|
||||||
@@ -2805,7 +2886,7 @@ function* optimizeRulePlacements(
|
|||||||
moved &&
|
moved &&
|
||||||
dateInvariantObjectives &&
|
dateInvariantObjectives &&
|
||||||
compareObjectiveLists(
|
compareObjectiveLists(
|
||||||
tieBreakObjectives(moved),
|
tieBreakObjectives(moved, context),
|
||||||
beforeVector.objectives.slice(-2),
|
beforeVector.objectives.slice(-2),
|
||||||
) >= 0
|
) >= 0
|
||||||
) {
|
) {
|
||||||
@@ -2842,8 +2923,14 @@ function* optimizeRulePlacements(
|
|||||||
(candidate) => {
|
(candidate) => {
|
||||||
if (rule?.type === 'optimization' && !isAtRisk(balance, context)) {
|
if (rule?.type === 'optimization' && !isAtRisk(balance, context)) {
|
||||||
beforeCalendar ??= optimizationObservations(result, context);
|
beforeCalendar ??= optimizationObservations(result, context);
|
||||||
candidate.placements.get(date)!.supporting =
|
candidate.placements.set(date, {
|
||||||
supportingCalendarBenefit(beforeCalendar, candidate, context);
|
...candidate.placements.get(date)!,
|
||||||
|
supporting: supportingCalendarBenefit(
|
||||||
|
beforeCalendar,
|
||||||
|
candidate,
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return consider(candidate, true);
|
return consider(candidate, true);
|
||||||
},
|
},
|
||||||
@@ -3284,71 +3371,57 @@ function preferenceStateKey(state: State): string {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function placementTieKey(state: State): string {
|
|
||||||
return [...state.placements.values()]
|
|
||||||
.map((placement) => placement.date)
|
|
||||||
.sort(compareDate)
|
|
||||||
.join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
function expiryTieKey(state: State): string {
|
|
||||||
return [...state.placements.values()]
|
|
||||||
.map(
|
|
||||||
(placement) =>
|
|
||||||
state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? '',
|
|
||||||
)
|
|
||||||
.sort(compareDate)
|
|
||||||
.join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeObjectiveVector(
|
function activeObjectiveVector(
|
||||||
state: State,
|
state: State,
|
||||||
context: SearchContext,
|
context: SearchContext,
|
||||||
throughIndex = Number.POSITIVE_INFINITY,
|
throughIndex = Number.POSITIVE_INFINITY,
|
||||||
): PlanningObjective[] {
|
): PlanningObjective[] {
|
||||||
const preferences = preferenceResultsForState(state, context);
|
return [...activeObjectives(state, context, throughIndex)];
|
||||||
let observations: OptimizationObservations | undefined;
|
}
|
||||||
return context.rules
|
|
||||||
.filter((rule) => rule.enabled && rule.force === 'preference')
|
function* activeObjectives(
|
||||||
.slice(0, throughIndex + 1)
|
state: State,
|
||||||
.map((rule) => {
|
context: SearchContext,
|
||||||
|
throughIndex = Number.POSITIVE_INFINITY,
|
||||||
|
): Generator<PlanningObjective> {
|
||||||
|
let preferences: PreferenceResult[] | undefined;
|
||||||
|
let balances: OptimizationObservations | undefined;
|
||||||
|
let calendar: OptimizationObservations | undefined;
|
||||||
|
let index = 0;
|
||||||
|
for (const rule of context.rules) {
|
||||||
|
if (!rule.enabled || rule.force !== 'preference') continue;
|
||||||
|
if (index++ > throughIndex) return;
|
||||||
if (rule.type === 'optimization') {
|
if (rule.type === 'optimization') {
|
||||||
observations ??= context.rules.some(
|
const observations =
|
||||||
(item) =>
|
rule.configuration.elements === 'leave-balances'
|
||||||
item.type === 'optimization' &&
|
? (balances ??= {
|
||||||
item.configuration.elements !== 'leave-balances',
|
|
||||||
)
|
|
||||||
? optimizationObservations(state, context)
|
|
||||||
: {
|
|
||||||
leaveBalances: optimizationBalanceObservations(state, context),
|
leaveBalances: optimizationBalanceObservations(state, context),
|
||||||
publicHolidayWeeks: [],
|
publicHolidayWeeks: [],
|
||||||
timeOffPeriods: [],
|
timeOffPeriods: [],
|
||||||
workSequences: [],
|
workSequences: [],
|
||||||
leavePlacements: [],
|
leavePlacements: [],
|
||||||
};
|
})
|
||||||
return {
|
: (calendar ??= optimizationObservations(state, context));
|
||||||
id: `optimization:${rule.id}` as const,
|
yield {
|
||||||
direction: rule.configuration!.direction,
|
id: `optimization:${rule.id}`,
|
||||||
value: evaluateStateOptimization(
|
direction: rule.configuration.direction,
|
||||||
rule.configuration!,
|
value: evaluateStateOptimization(rule.configuration, state, context, observations),
|
||||||
state,
|
|
||||||
context,
|
|
||||||
observations,
|
|
||||||
),
|
|
||||||
label: rule.name,
|
label: rule.name,
|
||||||
};
|
};
|
||||||
}
|
} else {
|
||||||
return {
|
preferences ??= preferenceResultsForState(state, context);
|
||||||
id: `preference:${rule.id}` as const,
|
yield {
|
||||||
direction: 'maximize' as const,
|
id: `preference:${rule.id}`,
|
||||||
|
direction: 'maximize',
|
||||||
value:
|
value:
|
||||||
preferences.find((preference) => preference.ruleId === rule.id)?.satisfied ?? 0,
|
preferences.find((preference) => preference.ruleId === rule.id)?.satisfied ?? 0,
|
||||||
label: rule.name,
|
label: rule.name,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function tieBreakObjectives(state: State): PlanningObjective[] {
|
function tieBreakObjectives(state: State, context: SearchContext): PlanningObjective[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'balance-expiry',
|
id: 'balance-expiry',
|
||||||
@@ -3359,7 +3432,7 @@ function tieBreakObjectives(state: State): PlanningObjective[] {
|
|||||||
{
|
{
|
||||||
id: 'placement-date',
|
id: 'placement-date',
|
||||||
direction: 'minimize',
|
direction: 'minimize',
|
||||||
value: placementTieKey(state),
|
value: placementTieKey(state, context),
|
||||||
label: 'Placement date tie-break',
|
label: 'Placement date tie-break',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -3369,7 +3442,10 @@ function obligationPreviewObjectives(
|
|||||||
preview: { state: State },
|
preview: { state: State },
|
||||||
context: SearchContext,
|
context: SearchContext,
|
||||||
): PlanningObjective[] {
|
): PlanningObjective[] {
|
||||||
return [...activeObjectiveVector(preview.state, context), ...tieBreakObjectives(preview.state)];
|
return [
|
||||||
|
...activeObjectiveVector(preview.state, context),
|
||||||
|
...tieBreakObjectives(preview.state, context),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compare complete previews so obligation DFS does not commit to its first feasible branch. */
|
/** Compare complete previews so obligation DFS does not commit to its first feasible branch. */
|
||||||
@@ -3384,210 +3460,6 @@ function compareObligationPreviews(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAtRisk(balance: InternalBalance, context: SearchContext): boolean {
|
|
||||||
return (
|
|
||||||
compareDate(balance.expiryDate, context.horizon.start) >= 0 &&
|
|
||||||
compareDate(balance.expiryDate, context.horizon.end) <= 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNaturalNonWorkingDate(date: string, context: SearchContext): boolean {
|
|
||||||
return context.calendarIndex.has(date)
|
|
||||||
? !context.workingCalendar.has(date)
|
|
||||||
: !isWorkingPlanningDate(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
function calendarDateAt(index: number, context: SearchContext): string {
|
|
||||||
return context.calendarDays[index] ?? addDays(context.calendarDays[0], index);
|
|
||||||
}
|
|
||||||
|
|
||||||
function periodBounds(state: State, context: SearchContext) {
|
|
||||||
const dates = [...new Set([...state.placements.keys(), ...context.declaredDates])]
|
|
||||||
.filter((date) => date >= context.horizon.start && date < context.horizon.end)
|
|
||||||
.sort(compareDate);
|
|
||||||
if (!dates.length) return [];
|
|
||||||
const groups: string[][] = [];
|
|
||||||
let group = [dates[0]];
|
|
||||||
for (let i = 1; i < dates.length; i++) {
|
|
||||||
context.check();
|
|
||||||
const previousIndex = context.calendarIndex.get(dates[i - 1])!;
|
|
||||||
const currentIndex = context.calendarIndex.get(dates[i])!;
|
|
||||||
const workingInGap =
|
|
||||||
context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1];
|
|
||||||
if (workingInGap === 0) group.push(dates[i]);
|
|
||||||
else {
|
|
||||||
groups.push(group);
|
|
||||||
group = [dates[i]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
groups.push(group);
|
|
||||||
return groups.map((anchors) => {
|
|
||||||
context.check();
|
|
||||||
let startIndex = context.calendarIndex.get(anchors[0])!;
|
|
||||||
let endIndex = context.calendarIndex.get(anchors[anchors.length - 1])!;
|
|
||||||
while (
|
|
||||||
context.declaredDates.has(calendarDateAt(startIndex - 1, context)) ||
|
|
||||||
isNaturalNonWorkingDate(calendarDateAt(startIndex - 1, context), context)
|
|
||||||
) {
|
|
||||||
context.check();
|
|
||||||
startIndex--;
|
|
||||||
}
|
|
||||||
while (
|
|
||||||
context.declaredDates.has(calendarDateAt(endIndex + 1, context)) ||
|
|
||||||
isNaturalNonWorkingDate(calendarDateAt(endIndex + 1, context), context)
|
|
||||||
) {
|
|
||||||
context.check();
|
|
||||||
endIndex++;
|
|
||||||
}
|
|
||||||
return { anchors, startIndex, endIndex };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function calendarPeriod(
|
|
||||||
startIndex: number,
|
|
||||||
endIndex: number,
|
|
||||||
context: SearchContext,
|
|
||||||
): CalendarPeriod {
|
|
||||||
let periods = calendarPeriodCache.get(context);
|
|
||||||
if (!periods) {
|
|
||||||
periods = new Map();
|
|
||||||
calendarPeriodCache.set(context, periods);
|
|
||||||
}
|
|
||||||
const key = `${startIndex}:${endIndex}`;
|
|
||||||
let period = periods.get(key);
|
|
||||||
if (period) periods.delete(key);
|
|
||||||
else {
|
|
||||||
const start = calendarDateAt(startIndex, context);
|
|
||||||
const end = calendarDateAt(endIndex, context);
|
|
||||||
const dates =
|
|
||||||
startIndex >= 0 && endIndex < context.calendarDays.length
|
|
||||||
? context.calendarDays.slice(startIndex, endIndex + 1)
|
|
||||||
: dateRange(start, addDays(end, 1));
|
|
||||||
period = Object.freeze({
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
duration: dates.length,
|
|
||||||
nonWorkingDates: Object.freeze(
|
|
||||||
dates.filter((date) => isNaturalNonWorkingDate(date, context)),
|
|
||||||
),
|
|
||||||
declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date))),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
periods.set(key, period);
|
|
||||||
if (periods.size > 4096) periods.delete(periods.keys().next().value!);
|
|
||||||
return period;
|
|
||||||
}
|
|
||||||
|
|
||||||
function periodData(state: State, context: SearchContext): TimeOffPeriod[] {
|
|
||||||
return periodBounds(state, context).map(({ anchors, startIndex, endIndex }) => {
|
|
||||||
const period = calendarPeriod(startIndex, endIndex, context);
|
|
||||||
return {
|
|
||||||
start: period.start,
|
|
||||||
end: period.end,
|
|
||||||
duration: period.duration,
|
|
||||||
placementDates: anchors.filter((date) => state.placements.has(date)),
|
|
||||||
...(period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}),
|
|
||||||
nonWorkingDates: [...period.nonWorkingDates],
|
|
||||||
nonWorkingDayGain: period.nonWorkingDates.length,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function optimizationBalanceObservations(state: State, context: SearchContext) {
|
|
||||||
return state.balances.map((balance) => ({
|
|
||||||
unplacedQuantity: balance.remaining,
|
|
||||||
atRisk: isAtRisk(balance, context),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function optimizationObservations(
|
|
||||||
state: State,
|
|
||||||
context: SearchContext,
|
|
||||||
periods?: TimeOffPeriod[],
|
|
||||||
): OptimizationObservations {
|
|
||||||
// Calendar facts depend only on occupied dates within this immutable request.
|
|
||||||
// Balance quantities, provenance and supporting status stay outside this cache.
|
|
||||||
const key = placementTieKey(state);
|
|
||||||
let byDates = calendarObservationCache.get(context);
|
|
||||||
if (!byDates) {
|
|
||||||
byDates = new Map();
|
|
||||||
calendarObservationCache.set(context, byDates);
|
|
||||||
}
|
|
||||||
let calendar = byDates.get(key);
|
|
||||||
if (calendar) byDates.delete(key);
|
|
||||||
else {
|
|
||||||
calendar = Object.freeze({
|
|
||||||
publicHolidayWeeks: Object.freeze(
|
|
||||||
context.holidayWeeks.map((week) =>
|
|
||||||
Object.freeze({
|
|
||||||
value: week.value,
|
|
||||||
complete: week.dates.every(
|
|
||||||
(date) => state.placements.has(date) || context.declaredDates.has(date),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
timeOffPeriods: Object.freeze(
|
|
||||||
periods
|
|
||||||
? periods.map((period) =>
|
|
||||||
Object.freeze({
|
|
||||||
nonWorkingDates: Object.freeze([...period.nonWorkingDates]),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: periodBounds(state, context).map(({ startIndex, endIndex }) =>
|
|
||||||
Object.freeze({
|
|
||||||
nonWorkingDates: calendarPeriod(startIndex, endIndex, context)
|
|
||||||
.nonWorkingDates,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
workSequences: Object.freeze(
|
|
||||||
workSequenceDates(state, context).map(({ dates, internal }) =>
|
|
||||||
Object.freeze({ length: dates.length, internal }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (byDates.size >= 4096) byDates.delete(byDates.keys().next().value!);
|
|
||||||
}
|
|
||||||
byDates.set(key, calendar);
|
|
||||||
return {
|
|
||||||
leaveBalances: optimizationBalanceObservations(state, context),
|
|
||||||
...calendar,
|
|
||||||
leavePlacements: [...state.placements.values()],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function workSequenceDates(state: State, context: SearchContext) {
|
|
||||||
const sequences: string[][] = [];
|
|
||||||
let current: string[] = [];
|
|
||||||
let inTimeOffPeriod = false;
|
|
||||||
for (const date of context.workingDays) {
|
|
||||||
context.check();
|
|
||||||
if (state.placements.has(date) || context.declaredDates.has(date)) {
|
|
||||||
if (!inTimeOffPeriod) sequences.push(current);
|
|
||||||
current = [];
|
|
||||||
inTimeOffPeriod = true;
|
|
||||||
} else {
|
|
||||||
current.push(date);
|
|
||||||
inTimeOffPeriod = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (context.workingDays.length) sequences.push(current);
|
|
||||||
return sequences.map((dates, index) => ({
|
|
||||||
dates,
|
|
||||||
internal: index > 0 && index < sequences.length - 1,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluateStateOptimization(
|
|
||||||
configuration: OptimizationConfiguration,
|
|
||||||
state: State,
|
|
||||||
context: SearchContext,
|
|
||||||
observations = optimizationObservations(state, context),
|
|
||||||
): number {
|
|
||||||
return evaluateOptimization(configuration, observations);
|
|
||||||
}
|
|
||||||
|
|
||||||
function qualityMetrics(
|
function qualityMetrics(
|
||||||
state: State,
|
state: State,
|
||||||
context: SearchContext,
|
context: SearchContext,
|
||||||
@@ -3759,7 +3631,7 @@ function objectiveVector(
|
|||||||
label: 'Unsatisfied obligations',
|
label: 'Unsatisfied obligations',
|
||||||
},
|
},
|
||||||
...active,
|
...active,
|
||||||
...tieBreakObjectives(state),
|
...tieBreakObjectives(state, context),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3898,6 +3770,7 @@ function planExplanation(
|
|||||||
compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0
|
compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0
|
||||||
: false;
|
: false;
|
||||||
const withoutPlacement = cloneState(state);
|
const withoutPlacement = cloneState(state);
|
||||||
|
notePlacementRemoved(state.placements, withoutPlacement.placements, placement.date);
|
||||||
withoutPlacement.placements.delete(placement.date);
|
withoutPlacement.placements.delete(placement.date);
|
||||||
const released = withoutPlacement.balances.find(
|
const released = withoutPlacement.balances.find(
|
||||||
(candidate) => candidate.id === placement.balanceId,
|
(candidate) => candidate.id === placement.balanceId,
|
||||||
@@ -5181,7 +5054,10 @@ export function analyzeCounterfactualInternal(
|
|||||||
const baseline = cloneState(finalState);
|
const baseline = cloneState(finalState);
|
||||||
const removeDate = selected.date;
|
const removeDate = selected.date;
|
||||||
const removed = removeDate ? baseline.placements.get(removeDate) : undefined;
|
const removed = removeDate ? baseline.placements.get(removeDate) : undefined;
|
||||||
if (removeDate) baseline.placements.delete(removeDate);
|
if (removeDate) {
|
||||||
|
notePlacementRemoved(finalState.placements, baseline.placements, removeDate);
|
||||||
|
baseline.placements.delete(removeDate);
|
||||||
|
}
|
||||||
const released = baseline.balances.find((balance) => balance.id === selected.balanceId);
|
const released = baseline.balances.find((balance) => balance.id === selected.balanceId);
|
||||||
if (released && removed?.balanceId === selected.balanceId) released.remaining++;
|
if (released && removed?.balanceId === selected.balanceId) released.remaining++;
|
||||||
const beforeState = baseline;
|
const beforeState = baseline;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createOptimizationPresetRules } from './rule-service';
|
import { createOptimizationPresetRules } from './rule-service';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import {
|
import {
|
||||||
PlanningOutcome,
|
PlanningOutcome,
|
||||||
PlanningRequest,
|
PlanningRequest,
|
||||||
@@ -1562,7 +1562,11 @@ describe('planLeave', () => {
|
|||||||
const { isWorkingPlanningDate } = await import('./declared-non-working-days');
|
const { isWorkingPlanningDate } = await import('./declared-non-working-days');
|
||||||
const { addPlanningDays } = await import('./planning-date');
|
const { addPlanningDays } = await import('./planning-date');
|
||||||
const declaredDays: NonNullable<PlanningRequest['declaredDays']>[number][] = [];
|
const declaredDays: NonNullable<PlanningRequest['declaredDays']>[number][] = [];
|
||||||
for (let date = declaredStart; date < declaredEnd; date = addPlanningDays(date, 1))
|
for (
|
||||||
|
let date: string = declaredStart;
|
||||||
|
date < declaredEnd;
|
||||||
|
date = addPlanningDays(date, 1)
|
||||||
|
)
|
||||||
if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'other' });
|
if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'other' });
|
||||||
const result = isPlan(
|
const result = isPlan(
|
||||||
await runInNonBrowser({ horizon, balances: [], rules: [], declaredDays }),
|
await runInNonBrowser({ horizon, balances: [], rules: [], declaredDays }),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
StaleCounterfactualAnalysis,
|
StaleCounterfactualAnalysis,
|
||||||
} from './leave-planner.core';
|
} from './leave-planner.core';
|
||||||
import { planningDateInParis } from './planning-date';
|
import { planningDateInParis } from './planning-date';
|
||||||
|
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
IfThenPlanningRule,
|
IfThenPlanningRule,
|
||||||
@@ -278,20 +279,6 @@ function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | unde
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlannerWorkerResponse =
|
|
||||||
| { type: 'heartbeat' }
|
|
||||||
| { type: 'progress'; stage: string }
|
|
||||||
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
|
|
||||||
| { type: 'result'; outcome: PlanningOutcome }
|
|
||||||
| { type: 'artifacts'; artifacts: PlanningArtifacts }
|
|
||||||
| {
|
|
||||||
type: 'explanation';
|
|
||||||
explanation?: PlanningExplanation;
|
|
||||||
explanationError?: PlanningReason;
|
|
||||||
finalState?: PlanningStateSnapshot;
|
|
||||||
}
|
|
||||||
| { type: 'error'; code?: string; message: string };
|
|
||||||
|
|
||||||
interface PlanningRun {
|
interface PlanningRun {
|
||||||
outcome: Promise<PlanningOutcome>;
|
outcome: Promise<PlanningOutcome>;
|
||||||
explanation: Promise<PlanningExplanation | undefined>;
|
explanation: Promise<PlanningExplanation | undefined>;
|
||||||
@@ -320,6 +307,47 @@ function interruptedOutcome(
|
|||||||
const latestWorkerCancels = new Set<() => void>();
|
const latestWorkerCancels = new Set<() => void>();
|
||||||
let latestCounterfactualCancel: (() => void) | undefined;
|
let latestCounterfactualCancel: (() => void) | undefined;
|
||||||
|
|
||||||
|
interface WorkerJob {
|
||||||
|
post(message: WorkerRequest, onFailure: (error: unknown) => void): void;
|
||||||
|
release(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single owner of a worker's lifetime: creation, handlers, cancellation registration and an idempotent release.
|
||||||
|
function startWorkerJob(
|
||||||
|
supersede: () => void,
|
||||||
|
onMessage: (message: WorkerResponse) => void,
|
||||||
|
onError: () => void,
|
||||||
|
): WorkerJob {
|
||||||
|
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
||||||
|
type: 'module',
|
||||||
|
});
|
||||||
|
let released = false;
|
||||||
|
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||||
|
if (event.data) onMessage(event.data);
|
||||||
|
};
|
||||||
|
worker.onerror = () => {
|
||||||
|
onError();
|
||||||
|
};
|
||||||
|
latestWorkerCancels.add(supersede);
|
||||||
|
return {
|
||||||
|
post(message, onFailure) {
|
||||||
|
try {
|
||||||
|
worker.postMessage(message);
|
||||||
|
} catch (error) {
|
||||||
|
onFailure(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
release() {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
worker.onmessage = null;
|
||||||
|
worker.onerror = null;
|
||||||
|
worker.terminate();
|
||||||
|
latestWorkerCancels.delete(supersede);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function cancelLatestWorkers(): void {
|
function cancelLatestWorkers(): void {
|
||||||
for (const cancel of [...latestWorkerCancels]) cancel();
|
for (const cancel of [...latestWorkerCancels]) cancel();
|
||||||
latestWorkerCancels.clear();
|
latestWorkerCancels.clear();
|
||||||
@@ -349,9 +377,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
});
|
});
|
||||||
let cancel: (() => void) | undefined;
|
let cancel: (() => void) | undefined;
|
||||||
try {
|
try {
|
||||||
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
const job = startWorkerJob(
|
||||||
type: 'module',
|
() => supersede(),
|
||||||
});
|
(message) => handleMessage(message),
|
||||||
|
() => fail('worker-error', 'Planning worker failed before returning explanation data.'),
|
||||||
|
);
|
||||||
let outcomeSettled = false;
|
let outcomeSettled = false;
|
||||||
let cancellationRequested = false;
|
let cancellationRequested = false;
|
||||||
let cancelledRace: PlanningIncompleteOutcome | undefined;
|
let cancelledRace: PlanningIncompleteOutcome | undefined;
|
||||||
@@ -364,9 +394,7 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
let receivedFinalState: PlanningStateSnapshot | undefined;
|
let receivedFinalState: PlanningStateSnapshot | undefined;
|
||||||
let livenessWatchdog: ReturnType<typeof setTimeout>;
|
let livenessWatchdog: ReturnType<typeof setTimeout>;
|
||||||
const terminate = () => {
|
const terminate = () => {
|
||||||
worker.onmessage = null;
|
job.release();
|
||||||
worker.onerror = null;
|
|
||||||
worker.terminate();
|
|
||||||
clearTimeout(livenessWatchdog);
|
clearTimeout(livenessWatchdog);
|
||||||
};
|
};
|
||||||
const finishOutcome = (result: PlanningOutcome) => {
|
const finishOutcome = (result: PlanningOutcome) => {
|
||||||
@@ -434,7 +462,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
resolveArtifacts(complete);
|
resolveArtifacts(complete);
|
||||||
resolveTimings(complete.timings ?? {});
|
resolveTimings(complete.timings ?? {});
|
||||||
terminate();
|
terminate();
|
||||||
latestWorkerCancels.delete(supersede);
|
|
||||||
};
|
};
|
||||||
const supersede = () => {
|
const supersede = () => {
|
||||||
if (artifactsSettled) return;
|
if (artifactsSettled) return;
|
||||||
@@ -456,7 +483,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
finalState: receivedFinalState ?? checkpointState,
|
finalState: receivedFinalState ?? checkpointState,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
latestWorkerCancels.add(supersede);
|
|
||||||
const fail = (code: string, message: string) => {
|
const fail = (code: string, message: string) => {
|
||||||
const reason = { code, message };
|
const reason = { code, message };
|
||||||
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
|
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
|
||||||
@@ -508,15 +534,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
cancel = () => {
|
cancel = () => {
|
||||||
if (outcomeSettled || cancellationRequested) return;
|
if (outcomeSettled || cancellationRequested) return;
|
||||||
cancellationRequested = true;
|
cancellationRequested = true;
|
||||||
try {
|
job.post({ type: 'cancel' }, () =>
|
||||||
worker.postMessage({ type: 'cancel' });
|
fail('worker-error', 'The cancellation request could not reach the worker.'),
|
||||||
} catch {
|
);
|
||||||
fail('worker-error', 'The cancellation request could not reach the worker.');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
const handleMessage = (message: WorkerResponse) => {
|
||||||
const message = event.data;
|
|
||||||
if (!message) return;
|
|
||||||
if (message.type === 'heartbeat') {
|
if (message.type === 'heartbeat') {
|
||||||
scheduleLivenessWatchdog();
|
scheduleLivenessWatchdog();
|
||||||
return;
|
return;
|
||||||
@@ -552,13 +574,9 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
|
|||||||
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
|
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
|
||||||
else fail('worker-error', 'Planning worker returned an unexpected response.');
|
else fail('worker-error', 'Planning worker returned an unexpected response.');
|
||||||
};
|
};
|
||||||
worker.onerror = () =>
|
job.post({ type: 'plan', request: { ...snapshot, progress: undefined } }, () =>
|
||||||
fail('worker-error', 'Planning worker failed before returning explanation data.');
|
fail('worker-error', 'Planning input could not be sent to the isolated worker.'),
|
||||||
try {
|
);
|
||||||
worker.postMessage({ type: 'plan', request: { ...snapshot, progress: undefined } });
|
|
||||||
} catch {
|
|
||||||
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
const result = interruptedOutcome(
|
const result = interruptedOutcome(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -677,62 +695,43 @@ function counterfactualInWorker(
|
|||||||
): Promise<CounterfactualAnalysis> {
|
): Promise<CounterfactualAnalysis> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
latestCounterfactualCancel?.();
|
latestCounterfactualCancel?.();
|
||||||
let worker: Worker;
|
let job: WorkerJob;
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
job.release();
|
||||||
|
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
let settled = false;
|
||||||
|
const cancel = () =>
|
||||||
|
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
|
||||||
try {
|
try {
|
||||||
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
job = startWorkerJob(
|
||||||
type: 'module',
|
cancel,
|
||||||
});
|
(message) => {
|
||||||
|
if (message.type === 'counterfactual-result' && message.analysis)
|
||||||
|
finish(() => resolve(message.analysis));
|
||||||
|
else if (message.type === 'error')
|
||||||
|
finish(() =>
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
message.message ??
|
||||||
|
`Counterfactual analysis failed (${message.code ?? 'worker-error'}).`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
() => finish(() => reject(new Error('Counterfactual worker failed.'))),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let settled = false;
|
|
||||||
const finish = (callback: () => void) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
worker.onmessage = null;
|
|
||||||
worker.onerror = null;
|
|
||||||
worker.terminate();
|
|
||||||
if (cancel) latestWorkerCancels.delete(cancel);
|
|
||||||
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
|
|
||||||
callback();
|
|
||||||
};
|
|
||||||
const cancel = () =>
|
|
||||||
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
|
|
||||||
latestCounterfactualCancel = cancel;
|
latestCounterfactualCancel = cancel;
|
||||||
latestWorkerCancels.add(cancel);
|
job.post({ type: 'counterfactual', request: snapshot, plan, placement, state }, (error) =>
|
||||||
worker.onmessage = (
|
finish(() => reject(error)),
|
||||||
event: MessageEvent<{
|
|
||||||
type: string;
|
|
||||||
analysis?: CounterfactualAnalysis;
|
|
||||||
code?: string;
|
|
||||||
message?: string;
|
|
||||||
}>,
|
|
||||||
) => {
|
|
||||||
if (event.data?.type === 'counterfactual-result' && event.data.analysis)
|
|
||||||
finish(() => resolve(event.data.analysis!));
|
|
||||||
else if (event.data?.type === 'error')
|
|
||||||
finish(() =>
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
event.data.message ??
|
|
||||||
`Counterfactual analysis failed (${event.data.code ?? 'worker-error'}).`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
};
|
|
||||||
worker.onerror = () => finish(() => reject(new Error('Counterfactual worker failed.')));
|
|
||||||
try {
|
|
||||||
worker.postMessage({
|
|
||||||
type: 'counterfactual',
|
|
||||||
request: snapshot,
|
|
||||||
plan,
|
|
||||||
placement,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
finish(() => reject(error));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,31 +742,19 @@ function explainCheckpointInWorker(
|
|||||||
interruption: PlanningReason,
|
interruption: PlanningReason,
|
||||||
): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> {
|
): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let worker: Worker;
|
let job: WorkerJob;
|
||||||
try {
|
|
||||||
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
|
||||||
type: 'module',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = (callback: () => void) => {
|
const finish = (callback: () => void) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
worker.onmessage = null;
|
job.release();
|
||||||
worker.onerror = null;
|
|
||||||
worker.terminate();
|
|
||||||
if (cancel) latestWorkerCancels.delete(cancel);
|
|
||||||
callback();
|
callback();
|
||||||
};
|
};
|
||||||
const cancel = () =>
|
try {
|
||||||
finish(() => reject(new Error('Checkpoint explanation was superseded.')));
|
job = startWorkerJob(
|
||||||
latestWorkerCancels.add(cancel);
|
() => finish(() => reject(new Error('Checkpoint explanation was superseded.'))),
|
||||||
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
(response) => {
|
||||||
const response = event.data;
|
if (response.type === 'explanation')
|
||||||
if (response?.type === 'explanation')
|
|
||||||
finish(() =>
|
finish(() =>
|
||||||
resolve({
|
resolve({
|
||||||
explanation: response.explanation,
|
explanation: response.explanation,
|
||||||
@@ -775,21 +762,19 @@ function explainCheckpointInWorker(
|
|||||||
finalState: response.finalState,
|
finalState: response.finalState,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
else if (response?.type === 'error') finish(() => reject(new Error(response.message)));
|
else if (response.type === 'error')
|
||||||
};
|
finish(() => reject(new Error(response.message)));
|
||||||
worker.onerror = () =>
|
},
|
||||||
finish(() => reject(new Error('Checkpoint explanation worker failed.')));
|
() => finish(() => reject(new Error('Checkpoint explanation worker failed.'))),
|
||||||
try {
|
);
|
||||||
worker.postMessage({
|
|
||||||
type: 'explain-checkpoint',
|
|
||||||
request: snapshot,
|
|
||||||
plan,
|
|
||||||
state,
|
|
||||||
interruption,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
finish(() => reject(error));
|
reject(error);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
job.post(
|
||||||
|
{ type: 'explain-checkpoint', request: snapshot, plan, state, interruption },
|
||||||
|
(error) => finish(() => reject(error)),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type {
|
||||||
|
CounterfactualAnalysis,
|
||||||
|
LeavePlacement,
|
||||||
|
PlanOutcome,
|
||||||
|
PlanningExplanation,
|
||||||
|
PlanningOutcome,
|
||||||
|
PlanningReason,
|
||||||
|
PlanningSnapshot,
|
||||||
|
PlanningStateSnapshot,
|
||||||
|
} from './leave-planner.service';
|
||||||
|
import type { PlanningArtifacts } from './leave-planner.core';
|
||||||
|
|
||||||
|
// Message contract shared by the planning coordinator and its worker; types only.
|
||||||
|
export type WorkerPlanningRequest = {
|
||||||
|
calculationDate?: string;
|
||||||
|
declaredDays?: unknown;
|
||||||
|
horizon?: { start?: unknown; end?: unknown };
|
||||||
|
balances?: unknown;
|
||||||
|
rules?: unknown;
|
||||||
|
identities?: unknown;
|
||||||
|
labels?: unknown;
|
||||||
|
deadlineMs?: unknown;
|
||||||
|
progress?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerRequest =
|
||||||
|
| {
|
||||||
|
type: 'plan';
|
||||||
|
request: WorkerPlanningRequest;
|
||||||
|
}
|
||||||
|
| { type: 'cancel' }
|
||||||
|
| {
|
||||||
|
type: 'counterfactual';
|
||||||
|
request: PlanningSnapshot;
|
||||||
|
plan: PlanOutcome;
|
||||||
|
placement: LeavePlacement;
|
||||||
|
state?: PlanningStateSnapshot;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'explain-checkpoint';
|
||||||
|
request: PlanningSnapshot;
|
||||||
|
plan: PlanOutcome;
|
||||||
|
state: PlanningStateSnapshot;
|
||||||
|
interruption: PlanningReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkerResponse =
|
||||||
|
| { type: 'heartbeat' }
|
||||||
|
| { type: 'progress'; stage: string }
|
||||||
|
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
|
||||||
|
| { type: 'result'; outcome: PlanningOutcome }
|
||||||
|
| { type: 'artifacts'; artifacts: PlanningArtifacts }
|
||||||
|
| {
|
||||||
|
type: 'explanation';
|
||||||
|
explanation?: PlanningExplanation;
|
||||||
|
explanationError?: PlanningReason;
|
||||||
|
finalState?: PlanningStateSnapshot;
|
||||||
|
}
|
||||||
|
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
|
||||||
|
| { type: 'error'; code?: string; message: string };
|
||||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { Worker as NodeWorker } from 'node:worker_threads';
|
import { Worker as NodeWorker } from 'node:worker_threads';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import {
|
import {
|
||||||
createPlanningCalculation,
|
createPlanningCalculation,
|
||||||
type PlanningCalculationContext,
|
type PlanningCalculationContext,
|
||||||
@@ -31,6 +31,7 @@ describe('real planning worker cancellation', () => {
|
|||||||
let holdArtifacts = false;
|
let holdArtifacts = false;
|
||||||
const heldArtifactMessages: (() => void)[] = [];
|
const heldArtifactMessages: (() => void)[] = [];
|
||||||
let simulateLongCalculation = false;
|
let simulateLongCalculation = false;
|
||||||
|
let holdForCancelAtStage: string | undefined;
|
||||||
let simulatedElapsed = 0;
|
let simulatedElapsed = 0;
|
||||||
const threads = new Set<NodeWorker>();
|
const threads = new Set<NodeWorker>();
|
||||||
const sentTypes: string[] = [];
|
const sentTypes: string[] = [];
|
||||||
@@ -43,8 +44,9 @@ describe('real planning worker cancellation', () => {
|
|||||||
const entry = join(directory, 'entry.ts');
|
const entry = join(directory, 'entry.ts');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
entry,
|
entry,
|
||||||
`import { parentPort, workerData } from 'node:worker_threads';
|
`import { parentPort, receiveMessageOnPort, workerData } from 'node:worker_threads';
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
|
const pause = new Int32Array(new SharedArrayBuffer(4));
|
||||||
const originalNow = performance.now.bind(performance);
|
const originalNow = performance.now.bind(performance);
|
||||||
if (workerData.simulateLongCalculation)
|
if (workerData.simulateLongCalculation)
|
||||||
Object.defineProperty(performance, 'now', { value: () => originalNow() + elapsed });
|
Object.defineProperty(performance, 'now', { value: () => originalNow() + elapsed });
|
||||||
@@ -52,12 +54,28 @@ globalThis.self = { postMessage: (message) => {
|
|||||||
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'optimization:default-unplaced-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);
|
setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0);
|
||||||
parentPort.postMessage(message);
|
parentPort.postMessage(message);
|
||||||
|
// The worker reads its queue only between event-loop turns, so a stage that is shorter than the
|
||||||
|
// host round trip finishes first. Hold the stage until the host's cancel arrives, then deliver it.
|
||||||
|
if (message.type === 'progress' && message.stage === workerData.holdForCancelAtStage) {
|
||||||
|
let next;
|
||||||
|
for (let waits = 0; !(next = receiveMessageOnPort(parentPort)); waits++) {
|
||||||
|
if (waits > 5000) throw new Error('The host never sent the cancel request.');
|
||||||
|
Atomics.wait(pause, 0, 0, 1);
|
||||||
|
}
|
||||||
|
globalThis.self.onmessage({ data: next.message });
|
||||||
|
}
|
||||||
} };
|
} };
|
||||||
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
|
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
|
||||||
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
|
await promisify(execFile)(process.execPath, [
|
||||||
|
'build',
|
||||||
|
entry,
|
||||||
|
'--target=node',
|
||||||
|
'--outfile',
|
||||||
|
bundle,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -67,6 +85,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
heartbeats = 0;
|
heartbeats = 0;
|
||||||
simulateLongCalculation = false;
|
simulateLongCalculation = false;
|
||||||
|
holdForCancelAtStage = undefined;
|
||||||
simulatedElapsed = 0;
|
simulatedElapsed = 0;
|
||||||
sentTypes.length = 0;
|
sentTypes.length = 0;
|
||||||
receivedTypes.length = 0;
|
receivedTypes.length = 0;
|
||||||
@@ -74,7 +93,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
class RealWorker {
|
class RealWorker {
|
||||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
onerror: (() => void) | null = null;
|
onerror: (() => void) | null = null;
|
||||||
private thread = new NodeWorker(bundle, { workerData: { simulateLongCalculation } });
|
private thread = new NodeWorker(bundle, {
|
||||||
|
workerData: { simulateLongCalculation, holdForCancelAtStage },
|
||||||
|
});
|
||||||
constructor() {
|
constructor() {
|
||||||
threads.add(this.thread);
|
threads.add(this.thread);
|
||||||
this.thread.on('message', (data) => {
|
this.thread.on('message', (data) => {
|
||||||
@@ -671,6 +692,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('preserves balances and provenance after a valid checkpoint', async () => {
|
it('preserves balances and provenance after a valid checkpoint', async () => {
|
||||||
|
const cancelStage = 'optimization:default-non-working-day-gain';
|
||||||
|
holdForCancelAtStage = cancelStage;
|
||||||
const calculation = createPlanningCalculation({
|
const calculation = createPlanningCalculation({
|
||||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
|
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
|
||||||
@@ -690,7 +713,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
...presets.slice(1),
|
...presets.slice(1),
|
||||||
],
|
],
|
||||||
progress: (stage) => {
|
progress: (stage) => {
|
||||||
if (stage === 'optimization:default-non-working-day-gain') calculation.cancel();
|
if (stage === cancelStage) calculation.cancel();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const outcome = await calculation.outcome;
|
const outcome = await calculation.outcome;
|
||||||
@@ -698,7 +721,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
if (outcome.kind !== 'PlanningIncomplete') return;
|
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||||
expect(outcome.reasons[0].code).toBe('user-cancelled');
|
expect(outcome.reasons[0].code).toBe('user-cancelled');
|
||||||
expect(outcome.completedStages).toContain('optimization:default-holiday-week-value');
|
expect(outcome.completedStages).toContain('optimization:default-holiday-week-value');
|
||||||
expect(outcome.completedStages).not.toContain('optimization:default-non-working-day-gain');
|
expect(outcome.completedStages).not.toContain(cancelStage);
|
||||||
const incumbent = outcome.incumbent!;
|
const incumbent = outcome.incumbent!;
|
||||||
expect(incumbent.placements).toHaveLength(3);
|
expect(incumbent.placements).toHaveLength(3);
|
||||||
expect(incumbent.placements).toContainEqual({
|
expect(incumbent.placements).toContainEqual({
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
import type {
|
import type { PlanningOutcome } from './leave-planner.service';
|
||||||
CounterfactualAnalysis,
|
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
|
||||||
LeavePlacement,
|
|
||||||
PlanOutcome,
|
|
||||||
PlanningExplanation,
|
|
||||||
PlanningOutcome,
|
|
||||||
PlanningReason,
|
|
||||||
PlanningSnapshot,
|
|
||||||
PlanningStateSnapshot,
|
|
||||||
} from './leave-planner.service';
|
|
||||||
import {
|
import {
|
||||||
analyzeCounterfactualInternal,
|
analyzeCounterfactualInternal,
|
||||||
calculatePlanningAsyncInternal,
|
calculatePlanningAsyncInternal,
|
||||||
@@ -17,54 +9,6 @@ import {
|
|||||||
type PlanningArtifacts,
|
type PlanningArtifacts,
|
||||||
} from './leave-planner.core';
|
} from './leave-planner.core';
|
||||||
|
|
||||||
type WorkerPlanningRequest = {
|
|
||||||
calculationDate?: string;
|
|
||||||
declaredDays?: unknown;
|
|
||||||
horizon?: { start?: unknown; end?: unknown };
|
|
||||||
balances?: unknown;
|
|
||||||
rules?: unknown;
|
|
||||||
identities?: unknown;
|
|
||||||
labels?: unknown;
|
|
||||||
deadlineMs?: unknown;
|
|
||||||
progress?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkerRequest =
|
|
||||||
| {
|
|
||||||
type: 'plan';
|
|
||||||
request: WorkerPlanningRequest;
|
|
||||||
}
|
|
||||||
| { type: 'cancel' }
|
|
||||||
| {
|
|
||||||
type: 'counterfactual';
|
|
||||||
request: PlanningSnapshot;
|
|
||||||
plan: PlanOutcome;
|
|
||||||
placement: LeavePlacement;
|
|
||||||
state?: PlanningStateSnapshot;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'explain-checkpoint';
|
|
||||||
request: PlanningSnapshot;
|
|
||||||
plan: PlanOutcome;
|
|
||||||
state: PlanningStateSnapshot;
|
|
||||||
interruption: PlanningReason;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkerResponse =
|
|
||||||
| { type: 'heartbeat' }
|
|
||||||
| { type: 'progress'; stage: string }
|
|
||||||
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
|
|
||||||
| { type: 'result'; outcome: PlanningOutcome }
|
|
||||||
| { type: 'artifacts'; artifacts: PlanningArtifacts }
|
|
||||||
| {
|
|
||||||
type: 'explanation';
|
|
||||||
explanation?: PlanningExplanation;
|
|
||||||
explanationError?: PlanningReason;
|
|
||||||
finalState?: PlanningStateSnapshot;
|
|
||||||
}
|
|
||||||
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
|
|
||||||
| { type: 'error'; code?: string; message: string };
|
|
||||||
|
|
||||||
const worker = self as DedicatedWorkerGlobalScope;
|
const worker = self as DedicatedWorkerGlobalScope;
|
||||||
let activeCalculation: { cancelled: boolean } | undefined;
|
let activeCalculation: { cancelled: boolean } | undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
calculatePlanningInternal,
|
calculatePlanningInternal,
|
||||||
type PlanningArtifacts,
|
type PlanningArtifacts,
|
||||||
@@ -71,6 +71,64 @@ describe('integrated optimization and earned leave', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'holiday value first',
|
||||||
|
rules: [presets[1], presets[0], ...presets.slice(2)],
|
||||||
|
dates: ['2024-05-06', '2024-05-07', '2024-05-10'],
|
||||||
|
values: [0, 5, 0, 6, 5, 0, 0],
|
||||||
|
decisive: 'optimization:default-holiday-week-value',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-working-day gain first',
|
||||||
|
rules: [presets[2], presets[0], presets[1], ...presets.slice(3)],
|
||||||
|
dates: ['2024-05-03', '2024-05-10', '2024-05-17'],
|
||||||
|
values: [0, 9, 0, 0, 4, 4, 0],
|
||||||
|
decisive: 'optimization:default-non-working-day-gain',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'maximum holiday value after loss prevention',
|
||||||
|
rules: [
|
||||||
|
presets[0],
|
||||||
|
{
|
||||||
|
...presets[1],
|
||||||
|
configuration: {
|
||||||
|
...presets[1].configuration,
|
||||||
|
aggregation: 'maximum' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...presets.slice(2),
|
||||||
|
],
|
||||||
|
dates: ['2024-05-06', '2024-05-07', '2024-05-10'],
|
||||||
|
values: [0, 0, 5, 6, 5, 0, 0],
|
||||||
|
decisive: 'optimization:default-unplaced-at-risk',
|
||||||
|
},
|
||||||
|
])('preserves complete vectors and placement explanations with $name', (fixture) => {
|
||||||
|
let artifacts: PlanningArtifacts = {};
|
||||||
|
const outcome = calculatePlanningInternal(
|
||||||
|
{
|
||||||
|
...composedRequest,
|
||||||
|
horizon: { start: '2024-04-29', end: '2024-05-18' },
|
||||||
|
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-05-17' }],
|
||||||
|
rules: fixture.rules,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(value) => (artifacts = value),
|
||||||
|
);
|
||||||
|
if (outcome.kind !== 'Plan' || artifacts.explanation?.kind !== 'PlanExplanation')
|
||||||
|
throw new Error('Expected a complete plan and explanation.');
|
||||||
|
expect(outcome.placements.map(({ date }) => date)).toEqual(fixture.dates);
|
||||||
|
for (const placement of artifacts.explanation.placements) {
|
||||||
|
expect(placement.objectiveVector.objectives.map(({ value }) => value)).toEqual([
|
||||||
|
...fixture.values,
|
||||||
|
'2024-05-17|2024-05-17|2024-05-17',
|
||||||
|
fixture.dates.join('|'),
|
||||||
|
]);
|
||||||
|
expect(placement.firstDecisiveObjective).toBe(fixture.decisive);
|
||||||
|
expect(placement.ruleIds).toEqual([fixture.decisive.slice('optimization:'.length)]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves the first equivalent funder while evaluating typed window alternatives', () => {
|
it('preserves the first equivalent funder while evaluating typed window alternatives', () => {
|
||||||
const outcome = calculatePlanningInternal({
|
const outcome = calculatePlanningInternal({
|
||||||
...composedRequest,
|
...composedRequest,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { calculatePlanningInternal } from './leave-planner.core';
|
import { calculatePlanningInternal } from './leave-planner.core';
|
||||||
import { createPlanningCalculation } from './leave-planner.service';
|
import { createPlanningCalculation } from './leave-planner.service';
|
||||||
import type { PlanningRequest } from './leave-planner.service';
|
import type { PlanningRequest } from './leave-planner.service';
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import fc from 'fast-check';
|
||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
compareDate,
|
||||||
|
derivePlacements,
|
||||||
|
notePlacementAdded,
|
||||||
|
notePlacementRemoved,
|
||||||
|
observationsFromScratch,
|
||||||
|
optimizationObservations,
|
||||||
|
periodData,
|
||||||
|
placementTieKey,
|
||||||
|
setObservationShadowMode,
|
||||||
|
utcDate,
|
||||||
|
type ObservationCalendar,
|
||||||
|
type ObservedPlacement,
|
||||||
|
type ObservedPlan,
|
||||||
|
} from './plan-observations';
|
||||||
|
|
||||||
|
function calendar(start: string, length: number, declared: readonly string[]): ObservationCalendar {
|
||||||
|
const end = addDays(start, length);
|
||||||
|
const first = addDays(start, -400);
|
||||||
|
const last = addDays(end, 400);
|
||||||
|
const holidays = new Set<string>();
|
||||||
|
for (let year = Number(first.slice(0, 4)); year <= Number(last.slice(0, 4)); year++)
|
||||||
|
for (const holiday of frenchPublicHolidaysForYear(year)) holidays.add(holiday);
|
||||||
|
const isWorking = (date: string) =>
|
||||||
|
![0, 6].includes(utcDate(date).getUTCDay()) && !holidays.has(date);
|
||||||
|
const calendarDays: string[] = [];
|
||||||
|
const calendarIndex = new Map<string, number>();
|
||||||
|
const workingCalendar = new Set<string>();
|
||||||
|
const workingDays: string[] = [];
|
||||||
|
const workingPrefix = [0];
|
||||||
|
for (let date = first; compareDate(date, last) < 0; date = addDays(date, 1)) {
|
||||||
|
calendarIndex.set(date, calendarDays.length);
|
||||||
|
calendarDays.push(date);
|
||||||
|
const working = isWorking(date);
|
||||||
|
if (working) {
|
||||||
|
workingCalendar.add(date);
|
||||||
|
if (date >= start && date < end) workingDays.push(date);
|
||||||
|
}
|
||||||
|
workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(working));
|
||||||
|
}
|
||||||
|
const weeks = new Map<string, { dates: string[]; value: number }>();
|
||||||
|
for (const holiday of holidays) {
|
||||||
|
const weekday = utcDate(holiday).getUTCDay();
|
||||||
|
if (holiday < start || holiday >= end || weekday < 1 || weekday > 5) continue;
|
||||||
|
const monday = addDays(holiday, 1 - weekday);
|
||||||
|
const dates = [0, 1, 2, 3, 4].map((i) => addDays(monday, i)).filter(isWorking);
|
||||||
|
weeks.set(monday, { dates, value: 1 + (weekday % 3) });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
horizon: { start, end },
|
||||||
|
declaredDates: new Set(declared),
|
||||||
|
holidayWeeks: [...weeks.values()],
|
||||||
|
calendarDays,
|
||||||
|
calendarIndex,
|
||||||
|
workingCalendar,
|
||||||
|
workingPrefix,
|
||||||
|
workingDays,
|
||||||
|
check: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Op = { kind: 'add' | 'remove' | 'retype'; pick: number };
|
||||||
|
const scenario = fc.record({
|
||||||
|
// Horizons around year boundaries, Easter Monday, 1 May and Ascension 2024.
|
||||||
|
start: fc.constantFrom('2023-12-18', '2024-03-28', '2024-04-24', '2024-05-06', '2024-12-23'),
|
||||||
|
length: fc.integer({ min: 1, max: 45 }),
|
||||||
|
declared: fc.array(fc.integer({ min: -3, max: 48 }), { maxLength: 8 }),
|
||||||
|
ops: fc.array(
|
||||||
|
fc.record({
|
||||||
|
kind: fc.constantFrom<Op['kind']>('add', 'add', 'remove', 'retype'),
|
||||||
|
pick: fc.nat(1000),
|
||||||
|
}),
|
||||||
|
{ maxLength: 40 },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
function observe(plan: ObservedPlan, context: ObservationCalendar) {
|
||||||
|
const observations = optimizationObservations(plan, context);
|
||||||
|
return {
|
||||||
|
tieKey: placementTieKey(plan, context),
|
||||||
|
periods: periodData(plan, context),
|
||||||
|
publicHolidayWeeks: observations.publicHolidayWeeks,
|
||||||
|
timeOffPeriods: observations.timeOffPeriods,
|
||||||
|
workSequences: observations.workSequences,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectScratch(plan: ObservedPlan, context: ObservationCalendar) {
|
||||||
|
const scratch = observationsFromScratch(plan, context);
|
||||||
|
expect(observe(plan, context)).toEqual({
|
||||||
|
tieKey: scratch.tieKey,
|
||||||
|
periods: scratch.periods,
|
||||||
|
publicHolidayWeeks: scratch.publicHolidayWeeks,
|
||||||
|
timeOffPeriods: scratch.timeOffPeriods,
|
||||||
|
workSequences: scratch.workSequences,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const placement = (date: string, balanceId: string, supporting = false): ObservedPlacement => ({
|
||||||
|
date,
|
||||||
|
balanceId,
|
||||||
|
supporting,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('incremental plan observations', () => {
|
||||||
|
it('match the from-scratch computation after every command', () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(scenario, ({ start, length, declared, ops }) => {
|
||||||
|
const context = calendar(
|
||||||
|
start,
|
||||||
|
length,
|
||||||
|
declared.map((offset) => addDays(start, offset)),
|
||||||
|
);
|
||||||
|
let plan: ObservedPlan = { balances: [], placements: new Map() };
|
||||||
|
expectScratch(plan, context);
|
||||||
|
ops.forEach(({ kind, pick }, step) => {
|
||||||
|
const parent = plan.placements as Map<string, ObservedPlacement>;
|
||||||
|
// Every fourth add may hit a weekend, holiday, declared date or a date up to three
|
||||||
|
// days outside the horizon, as snapshots can.
|
||||||
|
const free = (
|
||||||
|
pick % 4
|
||||||
|
? context.workingDays.filter((date) => !context.declaredDates.has(date))
|
||||||
|
: context.calendarDays.filter(
|
||||||
|
(date) =>
|
||||||
|
date >= addDays(context.horizon.start, -3) &&
|
||||||
|
date < addDays(context.horizon.end, 3),
|
||||||
|
)
|
||||||
|
).filter((date) => !parent.has(date));
|
||||||
|
const used = [...parent.keys()];
|
||||||
|
const child = new Map(parent);
|
||||||
|
if (kind === 'add' && free.length) {
|
||||||
|
const date = free[pick % free.length];
|
||||||
|
notePlacementAdded(parent, child, date);
|
||||||
|
child.set(date, placement(date, `b${step}`));
|
||||||
|
} else if (kind === 'remove' && used.length) {
|
||||||
|
const date = used[pick % used.length];
|
||||||
|
notePlacementRemoved(parent, child, date);
|
||||||
|
child.delete(date);
|
||||||
|
} else if (kind === 'retype' && used.length) {
|
||||||
|
const date = used[pick % used.length];
|
||||||
|
derivePlacements(parent, child);
|
||||||
|
child.set(date, placement(date, `r${step}`, true));
|
||||||
|
} else return;
|
||||||
|
plan = { balances: [], placements: child };
|
||||||
|
expectScratch(plan, context);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
{ numRuns: 300 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a chain of unobserved derivations', () => {
|
||||||
|
const context = calendar('2024-03-28', 20, ['2024-04-04']);
|
||||||
|
const root = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
|
||||||
|
expectScratch({ balances: [], placements: root }, context);
|
||||||
|
let parent = root;
|
||||||
|
for (const date of ['2024-04-03', '2024-04-05', '2024-04-08']) {
|
||||||
|
const child = new Map(parent);
|
||||||
|
notePlacementAdded(parent, child, date);
|
||||||
|
child.set(date, placement(date, 'b'));
|
||||||
|
parent = child;
|
||||||
|
}
|
||||||
|
const child = new Map(parent);
|
||||||
|
notePlacementRemoved(parent, child, '2024-04-03');
|
||||||
|
child.delete('2024-04-03');
|
||||||
|
expectScratch({ balances: [], placements: child }, context);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives a child from an observed parent', () => {
|
||||||
|
const context = calendar('2024-03-28', 20, []);
|
||||||
|
const parent = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
|
||||||
|
expectScratch({ balances: [], placements: parent }, context);
|
||||||
|
const child = new Map(parent);
|
||||||
|
notePlacementAdded(parent, child, '2024-04-03');
|
||||||
|
child.set('2024-04-03', placement('2024-04-03', 'b'));
|
||||||
|
expectScratch({ balances: [], placements: child }, context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('observation shadow mode', () => {
|
||||||
|
it('throws when a placements Map is mutated in place after being observed', () => {
|
||||||
|
const context = calendar('2024-03-28', 20, []);
|
||||||
|
const placements = new Map([['2024-04-02', placement('2024-04-02', 'a')]]);
|
||||||
|
const plan: ObservedPlan = { balances: [], placements };
|
||||||
|
setObservationShadowMode(true);
|
||||||
|
try {
|
||||||
|
optimizationObservations(plan, context);
|
||||||
|
placements.set('2024-04-10', placement('2024-04-10', 'b'));
|
||||||
|
expect(() => optimizationObservations(plan, context)).toThrow(/shadow mismatch/);
|
||||||
|
} finally {
|
||||||
|
setObservationShadowMode(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
import { isWorkingPlanningDate } from './declared-non-working-days';
|
||||||
|
import type { TimeOffPeriod } from './leave-planner.core';
|
||||||
|
import {
|
||||||
|
evaluateOptimization,
|
||||||
|
type OptimizationConfiguration,
|
||||||
|
type OptimizationObservations,
|
||||||
|
} from './planning-objectives';
|
||||||
|
|
||||||
|
// Everything planning objectives observe of a plan: periods, work sequences, holiday weeks, balances, placements and tie keys.
|
||||||
|
|
||||||
|
export interface ObservedBalance {
|
||||||
|
id: string;
|
||||||
|
remaining: number;
|
||||||
|
expiryDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservedPlacement {
|
||||||
|
date: string;
|
||||||
|
balanceId: string;
|
||||||
|
supporting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservedPlan<P extends ObservedPlacement = ObservedPlacement> {
|
||||||
|
balances: readonly ObservedBalance[];
|
||||||
|
placements: ReadonlyMap<string, P>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObservationCalendar {
|
||||||
|
horizon: { start: string; end: string };
|
||||||
|
declaredDates: ReadonlySet<string>;
|
||||||
|
holidayWeeks: readonly { dates: readonly string[]; value: number }[];
|
||||||
|
calendarDays: readonly string[];
|
||||||
|
calendarIndex: ReadonlyMap<string, number>;
|
||||||
|
workingCalendar: ReadonlySet<string>;
|
||||||
|
workingPrefix: readonly number[];
|
||||||
|
workingDays: readonly string[];
|
||||||
|
check: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
export function utcDate(key: string): Date {
|
||||||
|
return new Date(`${key}T00:00:00Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(key: string, days: number): string {
|
||||||
|
return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareDate(a: string, b: string): number {
|
||||||
|
return a < b ? -1 : a > b ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateRange(start: string, endExclusive: string): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (let date = start; compareDate(date, endExclusive) < 0; date = addDays(date, 1))
|
||||||
|
result.push(date);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarOptimizationObservations = Partial<
|
||||||
|
Pick<OptimizationObservations, 'publicHolidayWeeks' | 'timeOffPeriods' | 'workSequences'>
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface CalendarPeriod {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
duration: number;
|
||||||
|
nonWorkingDates: readonly string[];
|
||||||
|
declaredDates: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarPeriodCache = new WeakMap<ObservationCalendar, Map<number, CalendarPeriod>>();
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
anchors: readonly string[];
|
||||||
|
startIndex: number;
|
||||||
|
endIndex: number;
|
||||||
|
period?: CalendarPeriod;
|
||||||
|
timeOff?: { readonly nonWorkingDates: readonly string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immutable view of one placements Map's key set, valid for one calendar.
|
||||||
|
interface Occupancy {
|
||||||
|
calendar: ObservationCalendar;
|
||||||
|
dates: readonly string[];
|
||||||
|
groups: readonly Group[];
|
||||||
|
tieKey?: string;
|
||||||
|
observed: CalendarOptimizationObservations;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Derivation {
|
||||||
|
parent: ReadonlyMap<string, unknown>;
|
||||||
|
op: 'same' | 'add' | 'remove';
|
||||||
|
date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const occupancies = new WeakMap<ReadonlyMap<string, unknown>, Occupancy>();
|
||||||
|
const derivations = new WeakMap<ReadonlyMap<string, unknown>, Derivation>();
|
||||||
|
|
||||||
|
let shadowMode =
|
||||||
|
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[
|
||||||
|
'VAC_OBSERVATION_SHADOW'
|
||||||
|
] === '1';
|
||||||
|
|
||||||
|
export function setObservationShadowMode(enabled: boolean): void {
|
||||||
|
shadowMode = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function note(
|
||||||
|
parent: ReadonlyMap<string, unknown>,
|
||||||
|
child: ReadonlyMap<string, unknown>,
|
||||||
|
op: Derivation['op'],
|
||||||
|
date?: string,
|
||||||
|
): void {
|
||||||
|
// A stale occupancy of the child would describe its key set before this change.
|
||||||
|
occupancies.delete(child);
|
||||||
|
if (parent === child) return;
|
||||||
|
derivations.set(child, { parent, op, date });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `child` is a copy of `parent` with the same key set (placement objects may differ). */
|
||||||
|
export function derivePlacements(
|
||||||
|
parent: ReadonlyMap<string, unknown>,
|
||||||
|
child: ReadonlyMap<string, unknown>,
|
||||||
|
): void {
|
||||||
|
note(parent, child, 'same');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call before `child.set(date, ...)` where `child` is a fresh copy of `parent` lacking `date`. */
|
||||||
|
export function notePlacementAdded(
|
||||||
|
parent: ReadonlyMap<string, unknown>,
|
||||||
|
child: ReadonlyMap<string, unknown>,
|
||||||
|
date: string,
|
||||||
|
): void {
|
||||||
|
note(parent, child, 'add', date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call before `child.delete(date)` where `child` is a fresh copy of `parent` holding `date`. */
|
||||||
|
export function notePlacementRemoved(
|
||||||
|
parent: ReadonlyMap<string, unknown>,
|
||||||
|
child: ReadonlyMap<string, unknown>,
|
||||||
|
date: string,
|
||||||
|
): void {
|
||||||
|
note(parent, child, 'remove', date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertionPoint(sorted: readonly string[], date: string): number {
|
||||||
|
let low = 0;
|
||||||
|
let high = sorted.length;
|
||||||
|
while (low < high) {
|
||||||
|
const mid = (low + high) >> 1;
|
||||||
|
if (sorted[mid] < date) low = mid + 1;
|
||||||
|
else high = mid;
|
||||||
|
}
|
||||||
|
return low;
|
||||||
|
}
|
||||||
|
|
||||||
|
function occupancyFromScratch(
|
||||||
|
placements: ReadonlyMap<string, unknown>,
|
||||||
|
context: ObservationCalendar,
|
||||||
|
): Occupancy {
|
||||||
|
const dates = [...placements.keys()].sort();
|
||||||
|
return { calendar: context, dates, groups: groupsFromScratch(dates, context), observed: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveOccupancy(
|
||||||
|
parent: Occupancy,
|
||||||
|
{ op, date }: Derivation,
|
||||||
|
context: ObservationCalendar,
|
||||||
|
): Occupancy | undefined {
|
||||||
|
if (op === 'same') return parent;
|
||||||
|
const at = insertionPoint(parent.dates, date!);
|
||||||
|
const present = parent.dates[at] === date;
|
||||||
|
if (present === (op === 'add')) return undefined;
|
||||||
|
const dates = parent.dates.slice();
|
||||||
|
if (op === 'add') dates.splice(at, 0, date!);
|
||||||
|
else dates.splice(at, 1);
|
||||||
|
const touchesGroups =
|
||||||
|
date! >= context.horizon.start &&
|
||||||
|
date! < context.horizon.end &&
|
||||||
|
!context.declaredDates.has(date!);
|
||||||
|
// Group merging and splitting assume a working day; snapshots may hold any date.
|
||||||
|
if (touchesGroups && !context.workingCalendar.has(date!)) return undefined;
|
||||||
|
const groups = !touchesGroups
|
||||||
|
? parent.groups
|
||||||
|
: op === 'add'
|
||||||
|
? groupsWithDate(parent.groups, date!, context)
|
||||||
|
: groupsWithoutDate(parent.groups, date!, context);
|
||||||
|
if (!groups) return undefined;
|
||||||
|
const observed: CalendarOptimizationObservations = {};
|
||||||
|
if (parent.observed.publicHolidayWeeks)
|
||||||
|
observed.publicHolidayWeeks = derivedHolidayWeeks(
|
||||||
|
parent.observed.publicHolidayWeeks,
|
||||||
|
dates,
|
||||||
|
date!,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
return { calendar: context, dates, groups, observed };
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeksByDate = new WeakMap<ObservationCalendar, Map<string, number[]>>();
|
||||||
|
|
||||||
|
function derivedHolidayWeeks(
|
||||||
|
weeks: NonNullable<CalendarOptimizationObservations['publicHolidayWeeks']>,
|
||||||
|
dates: readonly string[],
|
||||||
|
date: string,
|
||||||
|
context: ObservationCalendar,
|
||||||
|
) {
|
||||||
|
let index = weeksByDate.get(context);
|
||||||
|
if (!index) {
|
||||||
|
index = new Map();
|
||||||
|
context.holidayWeeks.forEach((week, i) =>
|
||||||
|
week.dates.forEach((d) => {
|
||||||
|
const list = index!.get(d);
|
||||||
|
if (list) list.push(i);
|
||||||
|
else index!.set(d, [i]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
weeksByDate.set(context, index);
|
||||||
|
}
|
||||||
|
const affected = index.get(date);
|
||||||
|
if (!affected) return weeks;
|
||||||
|
const next = weeks.slice();
|
||||||
|
for (const i of affected) {
|
||||||
|
const week = context.holidayWeeks[i];
|
||||||
|
const complete = week.dates.every((d) => {
|
||||||
|
const at = insertionPoint(dates, d);
|
||||||
|
return dates[at] === d || context.declaredDates.has(d);
|
||||||
|
});
|
||||||
|
if (complete !== weeks[i].complete)
|
||||||
|
next[i] = Object.freeze({ value: week.value, complete });
|
||||||
|
}
|
||||||
|
return Object.freeze(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupsWithDate(
|
||||||
|
groups: readonly Group[],
|
||||||
|
date: string,
|
||||||
|
context: ObservationCalendar,
|
||||||
|
): readonly Group[] {
|
||||||
|
let low = 0;
|
||||||
|
let high = groups.length;
|
||||||
|
while (low < high) {
|
||||||
|
const mid = (low + high) >> 1;
|
||||||
|
if (groups[mid].anchors[0] < date) low = mid + 1;
|
||||||
|
else high = mid;
|
||||||
|
}
|
||||||
|
const previous = low > 0 ? groups[low - 1] : undefined;
|
||||||
|
const next = low < groups.length ? groups[low] : undefined;
|
||||||
|
const index = context.calendarIndex.get(date)!;
|
||||||
|
const joinsPrevious =
|
||||||
|
!!previous &&
|
||||||
|
context.workingPrefix[index] -
|
||||||
|
context.workingPrefix[
|
||||||
|
context.calendarIndex.get(previous.anchors[previous.anchors.length - 1])! + 1
|
||||||
|
] ===
|
||||||
|
0;
|
||||||
|
const joinsNext =
|
||||||
|
!!next &&
|
||||||
|
context.workingPrefix[context.calendarIndex.get(next.anchors[0])!] -
|
||||||
|
context.workingPrefix[index + 1] ===
|
||||||
|
0;
|
||||||
|
const merged: Group = {
|
||||||
|
anchors: [
|
||||||
|
...(joinsPrevious ? previous!.anchors : []),
|
||||||
|
date,
|
||||||
|
...(joinsNext ? next!.anchors : []),
|
||||||
|
],
|
||||||
|
startIndex: joinsPrevious ? previous!.startIndex : extendStart(index, context),
|
||||||
|
endIndex: joinsNext ? next!.endIndex : extendEnd(index, context),
|
||||||
|
};
|
||||||
|
const from = joinsPrevious ? low - 1 : low;
|
||||||
|
return [...groups.slice(0, from), merged, ...groups.slice(joinsNext ? low + 1 : low)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupsWithoutDate(
|
||||||
|
groups: readonly Group[],
|
||||||
|
date: string,
|
||||||
|
context: ObservationCalendar,
|
||||||
|
): readonly Group[] | undefined {
|
||||||
|
let low = 0;
|
||||||
|
let high = groups.length;
|
||||||
|
while (low < high) {
|
||||||
|
const mid = (low + high) >> 1;
|
||||||
|
if (groups[mid].anchors[0] <= date) low = mid + 1;
|
||||||
|
else high = mid;
|
||||||
|
}
|
||||||
|
const group = groups[low - 1];
|
||||||
|
if (!group) return undefined;
|
||||||
|
const cut = insertionPoint(group.anchors, date);
|
||||||
|
if (group.anchors[cut] !== date) return undefined;
|
||||||
|
const leftAnchors = group.anchors.slice(0, cut);
|
||||||
|
const rightAnchors = group.anchors.slice(cut + 1);
|
||||||
|
const split: Group[] = [];
|
||||||
|
if (leftAnchors.length)
|
||||||
|
split.push({
|
||||||
|
anchors: leftAnchors,
|
||||||
|
startIndex: group.startIndex,
|
||||||
|
endIndex: extendEnd(
|
||||||
|
context.calendarIndex.get(leftAnchors[leftAnchors.length - 1])!,
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (rightAnchors.length)
|
||||||
|
split.push({
|
||||||
|
anchors: rightAnchors,
|
||||||
|
startIndex: extendStart(context.calendarIndex.get(rightAnchors[0])!, context),
|
||||||
|
endIndex: group.endIndex,
|
||||||
|
});
|
||||||
|
return [...groups.slice(0, low - 1), ...split, ...groups.slice(low)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function occupancyOf(
|
||||||
|
placements: ReadonlyMap<string, unknown>,
|
||||||
|
context: ObservationCalendar,
|
||||||
|
): Occupancy {
|
||||||
|
// Walk the record chain up to the nearest materialized ancestor, then derive back down.
|
||||||
|
const chain: [ReadonlyMap<string, unknown>, Derivation][] = [];
|
||||||
|
let current = placements;
|
||||||
|
let occupancy: Occupancy | undefined;
|
||||||
|
for (;;) {
|
||||||
|
const known = occupancies.get(current);
|
||||||
|
if (known?.calendar === context) {
|
||||||
|
occupancy = known;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const derivation = derivations.get(current);
|
||||||
|
if (!derivation) break;
|
||||||
|
chain.push([current, derivation]);
|
||||||
|
current = derivation.parent;
|
||||||
|
}
|
||||||
|
if (!occupancy) {
|
||||||
|
occupancy = occupancyFromScratch(current, context);
|
||||||
|
occupancies.set(current, occupancy);
|
||||||
|
}
|
||||||
|
for (let i = chain.length - 1; i >= 0; i--) {
|
||||||
|
const [map, derivation] = chain[i];
|
||||||
|
derivations.delete(map);
|
||||||
|
occupancy =
|
||||||
|
deriveOccupancy(occupancy, derivation, context) ?? occupancyFromScratch(map, context);
|
||||||
|
occupancies.set(map, occupancy);
|
||||||
|
}
|
||||||
|
return occupancy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tieKeyOf(occupancy: Occupancy): string {
|
||||||
|
return (occupancy.tieKey ??= occupancy.dates.join('|'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placementTieKey(state: ObservedPlan, context: ObservationCalendar): string {
|
||||||
|
const occupancy = occupancyOf(state.placements, context);
|
||||||
|
if (shadowMode) verifyOccupancy(state, context, occupancy);
|
||||||
|
return tieKeyOf(occupancy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expiryTieKey(state: ObservedPlan): string {
|
||||||
|
return [...state.placements.values()]
|
||||||
|
.map(
|
||||||
|
(placement) =>
|
||||||
|
state.balances.find((item) => item.id === placement.balanceId)?.expiryDate ?? '',
|
||||||
|
)
|
||||||
|
.sort(compareDate)
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAtRisk(balance: ObservedBalance, context: ObservationCalendar): boolean {
|
||||||
|
return (
|
||||||
|
compareDate(balance.expiryDate, context.horizon.start) >= 0 &&
|
||||||
|
compareDate(balance.expiryDate, context.horizon.end) <= 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNaturalNonWorkingDate(date: string, context: ObservationCalendar): boolean {
|
||||||
|
return context.calendarIndex.has(date)
|
||||||
|
? !context.workingCalendar.has(date)
|
||||||
|
: !isWorkingPlanningDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarDateAt(index: number, context: ObservationCalendar): string {
|
||||||
|
return context.calendarDays[index] ?? addDays(context.calendarDays[0], index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extendStart(index: number, context: ObservationCalendar): number {
|
||||||
|
while (
|
||||||
|
context.declaredDates.has(calendarDateAt(index - 1, context)) ||
|
||||||
|
isNaturalNonWorkingDate(calendarDateAt(index - 1, context), context)
|
||||||
|
) {
|
||||||
|
context.check();
|
||||||
|
index--;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extendEnd(index: number, context: ObservationCalendar): number {
|
||||||
|
while (
|
||||||
|
context.declaredDates.has(calendarDateAt(index + 1, context)) ||
|
||||||
|
isNaturalNonWorkingDate(calendarDateAt(index + 1, context), context)
|
||||||
|
) {
|
||||||
|
context.check();
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupsFromScratch(
|
||||||
|
placementDates: Iterable<string>,
|
||||||
|
context: ObservationCalendar,
|
||||||
|
): Group[] {
|
||||||
|
const dates = [...new Set([...placementDates, ...context.declaredDates])]
|
||||||
|
.filter((date) => date >= context.horizon.start && date < context.horizon.end)
|
||||||
|
.sort(compareDate);
|
||||||
|
if (!dates.length) return [];
|
||||||
|
const groups: string[][] = [];
|
||||||
|
let group = [dates[0]];
|
||||||
|
for (let i = 1; i < dates.length; i++) {
|
||||||
|
context.check();
|
||||||
|
const previousIndex = context.calendarIndex.get(dates[i - 1])!;
|
||||||
|
const currentIndex = context.calendarIndex.get(dates[i])!;
|
||||||
|
const workingInGap =
|
||||||
|
context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1];
|
||||||
|
if (workingInGap === 0) group.push(dates[i]);
|
||||||
|
else {
|
||||||
|
groups.push(group);
|
||||||
|
group = [dates[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups.push(group);
|
||||||
|
return groups.map((anchors) => {
|
||||||
|
context.check();
|
||||||
|
return {
|
||||||
|
anchors,
|
||||||
|
startIndex: extendStart(context.calendarIndex.get(anchors[0])!, context),
|
||||||
|
endIndex: extendEnd(context.calendarIndex.get(anchors[anchors.length - 1])!, context),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarPeriod(
|
||||||
|
startIndex: number,
|
||||||
|
endIndex: number,
|
||||||
|
context: ObservationCalendar,
|
||||||
|
): CalendarPeriod {
|
||||||
|
let periods = calendarPeriodCache.get(context);
|
||||||
|
if (!periods) {
|
||||||
|
periods = new Map();
|
||||||
|
calendarPeriodCache.set(context, periods);
|
||||||
|
}
|
||||||
|
const key = startIndex * 1_048_576 + endIndex;
|
||||||
|
let period = periods.get(key);
|
||||||
|
if (period) periods.delete(key);
|
||||||
|
else {
|
||||||
|
const start = calendarDateAt(startIndex, context);
|
||||||
|
const end = calendarDateAt(endIndex, context);
|
||||||
|
const dates =
|
||||||
|
startIndex >= 0 && endIndex < context.calendarDays.length
|
||||||
|
? context.calendarDays.slice(startIndex, endIndex + 1)
|
||||||
|
: dateRange(start, addDays(end, 1));
|
||||||
|
period = Object.freeze({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
duration: dates.length,
|
||||||
|
nonWorkingDates: Object.freeze(
|
||||||
|
dates.filter((date) => isNaturalNonWorkingDate(date, context)),
|
||||||
|
),
|
||||||
|
declaredDates: Object.freeze(dates.filter((date) => context.declaredDates.has(date))),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
periods.set(key, period);
|
||||||
|
if (periods.size > 4096) periods.delete(periods.keys().next().value!);
|
||||||
|
return period;
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodsOf(
|
||||||
|
state: ObservedPlan,
|
||||||
|
groups: readonly Group[],
|
||||||
|
context: ObservationCalendar,
|
||||||
|
): TimeOffPeriod[] {
|
||||||
|
return groups.map((group) => {
|
||||||
|
const period = (group.period ??= calendarPeriod(group.startIndex, group.endIndex, context));
|
||||||
|
return {
|
||||||
|
start: period.start,
|
||||||
|
end: period.end,
|
||||||
|
duration: period.duration,
|
||||||
|
placementDates: group.anchors.filter((date) => state.placements.has(date)),
|
||||||
|
...(period.declaredDates.length ? { declaredDates: [...period.declaredDates] } : {}),
|
||||||
|
nonWorkingDates: [...period.nonWorkingDates],
|
||||||
|
nonWorkingDayGain: period.nonWorkingDates.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodData(state: ObservedPlan, context: ObservationCalendar): TimeOffPeriod[] {
|
||||||
|
const occupancy = occupancyOf(state.placements, context);
|
||||||
|
if (shadowMode) verifyOccupancy(state, context, occupancy);
|
||||||
|
return periodsOf(state, occupancy.groups, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reference computation, ignoring every cache: the test oracle and the shadow-mode baseline. */
|
||||||
|
export function observationsFromScratch(state: ObservedPlan, context: ObservationCalendar) {
|
||||||
|
const dates = [...state.placements.keys()].sort();
|
||||||
|
const groups = groupsFromScratch(dates, context);
|
||||||
|
return {
|
||||||
|
tieKey: dates.join('|'),
|
||||||
|
groups: groups.map(({ anchors, startIndex, endIndex }) => ({
|
||||||
|
anchors,
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
})),
|
||||||
|
periods: periodsOf(state, groups, context),
|
||||||
|
...calendarObservations(state, groups, context),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function holidayWeeksFor(state: ObservedPlan, context: ObservationCalendar) {
|
||||||
|
return Object.freeze(
|
||||||
|
context.holidayWeeks.map((week) =>
|
||||||
|
Object.freeze({
|
||||||
|
value: week.value,
|
||||||
|
complete: week.dates.every(
|
||||||
|
(date) => state.placements.has(date) || context.declaredDates.has(date),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeOffPeriodsFor(
|
||||||
|
groups: readonly Group[],
|
||||||
|
context: ObservationCalendar,
|
||||||
|
periods?: TimeOffPeriod[],
|
||||||
|
) {
|
||||||
|
return Object.freeze(
|
||||||
|
periods
|
||||||
|
? periods.map((period) =>
|
||||||
|
Object.freeze({ nonWorkingDates: Object.freeze([...period.nonWorkingDates]) }),
|
||||||
|
)
|
||||||
|
: groups.map(
|
||||||
|
(group) =>
|
||||||
|
(group.timeOff ??= Object.freeze({
|
||||||
|
nonWorkingDates: (group.period ??= calendarPeriod(
|
||||||
|
group.startIndex,
|
||||||
|
group.endIndex,
|
||||||
|
context,
|
||||||
|
)).nonWorkingDates,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workSequencesFor(groups: readonly Group[], context: ObservationCalendar) {
|
||||||
|
return Object.freeze(
|
||||||
|
workSequenceLengths(groups, context).map((sequence) => Object.freeze(sequence)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarObservations(
|
||||||
|
state: ObservedPlan,
|
||||||
|
groups: readonly Group[],
|
||||||
|
context: ObservationCalendar,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
publicHolidayWeeks: holidayWeeksFor(state, context),
|
||||||
|
timeOffPeriods: timeOffPeriodsFor(groups, context),
|
||||||
|
workSequences: workSequencesFor(groups, context),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyOccupancy(
|
||||||
|
state: ObservedPlan,
|
||||||
|
context: ObservationCalendar,
|
||||||
|
occupancy: Occupancy,
|
||||||
|
): void {
|
||||||
|
const expected = observationsFromScratch(state, context);
|
||||||
|
const fail = (field: string, got: unknown, want: unknown): never => {
|
||||||
|
throw new Error(
|
||||||
|
`Observation shadow mismatch on ${field} for placements [${[...state.placements.keys()].sort().join(', ')}]: got ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (tieKeyOf(occupancy) !== expected.tieKey)
|
||||||
|
fail('tieKey', tieKeyOf(occupancy), expected.tieKey);
|
||||||
|
const groups = occupancy.groups.map(({ anchors, startIndex, endIndex }) => ({
|
||||||
|
anchors,
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
}));
|
||||||
|
if (JSON.stringify(groups) !== JSON.stringify(expected.groups))
|
||||||
|
fail('groups', groups, expected.groups);
|
||||||
|
for (const field of ['publicHolidayWeeks', 'timeOffPeriods', 'workSequences'] as const) {
|
||||||
|
const cached = occupancy.observed[field];
|
||||||
|
// Caller-supplied periods are never compared: they are not derived from the key set.
|
||||||
|
if (cached && JSON.stringify(cached) !== JSON.stringify(expected[field]))
|
||||||
|
fail(field, cached, expected[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optimizationBalanceObservations(state: ObservedPlan, context: ObservationCalendar) {
|
||||||
|
return state.balances.map((balance) => ({
|
||||||
|
unplacedQuantity: balance.remaining,
|
||||||
|
atRisk: isAtRisk(balance, context),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optimizationObservations(
|
||||||
|
state: ObservedPlan,
|
||||||
|
context: ObservationCalendar,
|
||||||
|
periods?: TimeOffPeriod[],
|
||||||
|
): OptimizationObservations {
|
||||||
|
// Calendar facts depend only on occupied dates within this immutable request.
|
||||||
|
// Balance quantities, provenance and supporting status stay outside this cache.
|
||||||
|
const occupancy = occupancyOf(state.placements, context);
|
||||||
|
const calendar = occupancy.observed;
|
||||||
|
if (shadowMode) {
|
||||||
|
verifyOccupancy(state, context, occupancy);
|
||||||
|
calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context);
|
||||||
|
calendar.workSequences ??= workSequencesFor(occupancy.groups, context);
|
||||||
|
if (!periods) calendar.timeOffPeriods ??= timeOffPeriodsFor(occupancy.groups, context);
|
||||||
|
verifyOccupancy(state, context, occupancy);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
leaveBalances: optimizationBalanceObservations(state, context),
|
||||||
|
get publicHolidayWeeks() {
|
||||||
|
return (calendar.publicHolidayWeeks ??= holidayWeeksFor(state, context));
|
||||||
|
},
|
||||||
|
get timeOffPeriods() {
|
||||||
|
return (calendar.timeOffPeriods ??= timeOffPeriodsFor(
|
||||||
|
occupancy.groups,
|
||||||
|
context,
|
||||||
|
periods,
|
||||||
|
));
|
||||||
|
},
|
||||||
|
get workSequences() {
|
||||||
|
return (calendar.workSequences ??= workSequencesFor(occupancy.groups, context));
|
||||||
|
},
|
||||||
|
leavePlacements: [...state.placements.values()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workSequenceLengths(
|
||||||
|
bounds: readonly Group[],
|
||||||
|
context: ObservationCalendar,
|
||||||
|
): OptimizationObservations['workSequences'] {
|
||||||
|
if (!context.workingDays.length) return [];
|
||||||
|
let nextIndex = context.calendarIndex.get(context.horizon.start)!;
|
||||||
|
const sequences = bounds.map(({ anchors }, index) => {
|
||||||
|
const previousIndex = nextIndex;
|
||||||
|
nextIndex = context.calendarIndex.get(anchors[anchors.length - 1])! + 1;
|
||||||
|
return {
|
||||||
|
length:
|
||||||
|
context.workingPrefix[context.calendarIndex.get(anchors[0])!] -
|
||||||
|
context.workingPrefix[previousIndex],
|
||||||
|
internal: index > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
sequences.push({
|
||||||
|
length:
|
||||||
|
context.workingPrefix[context.calendarIndex.get(context.horizon.end)!] -
|
||||||
|
context.workingPrefix[nextIndex],
|
||||||
|
internal: false,
|
||||||
|
});
|
||||||
|
return sequences;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workSequenceDates(state: ObservedPlan, context: ObservationCalendar) {
|
||||||
|
const sequences: string[][] = [];
|
||||||
|
let current: string[] = [];
|
||||||
|
let inTimeOffPeriod = false;
|
||||||
|
for (const date of context.workingDays) {
|
||||||
|
context.check();
|
||||||
|
if (state.placements.has(date) || context.declaredDates.has(date)) {
|
||||||
|
if (!inTimeOffPeriod) sequences.push(current);
|
||||||
|
current = [];
|
||||||
|
inTimeOffPeriod = true;
|
||||||
|
} else {
|
||||||
|
current.push(date);
|
||||||
|
inTimeOffPeriod = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (context.workingDays.length) sequences.push(current);
|
||||||
|
return sequences.map((dates, index) => ({
|
||||||
|
dates,
|
||||||
|
internal: index > 0 && index < sequences.length - 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateStateOptimization(
|
||||||
|
configuration: OptimizationConfiguration,
|
||||||
|
state: ObservedPlan,
|
||||||
|
context: ObservationCalendar,
|
||||||
|
observations = optimizationObservations(state, context),
|
||||||
|
): number {
|
||||||
|
return evaluateOptimization(configuration, observations);
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from '../../testing/vitest-compat';
|
||||||
|
import { CalendarService } from './calendar-service';
|
||||||
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
|
import type {
|
||||||
|
PlanOutcome,
|
||||||
|
PlanningCalculationContext,
|
||||||
|
PlanningExplanation,
|
||||||
|
PlanningOutcome,
|
||||||
|
PlanningReason,
|
||||||
|
} from './leave-planner.service';
|
||||||
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
|
import { UserInputService } from './user-input-service';
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function planFor(start: string, end: string): PlanOutcome {
|
||||||
|
return {
|
||||||
|
kind: 'Plan',
|
||||||
|
horizon: { start, end },
|
||||||
|
declaredDays: [],
|
||||||
|
placements: [],
|
||||||
|
timeOffPeriods: [],
|
||||||
|
preferences: [],
|
||||||
|
remainingBalances: [],
|
||||||
|
completedStages: [],
|
||||||
|
} as unknown as PlanOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explained = { kind: 'PlanExplanation', placements: [] } as unknown as PlanningExplanation;
|
||||||
|
|
||||||
|
function fakeCalculation(id: string) {
|
||||||
|
const outcome = deferred<PlanningOutcome>();
|
||||||
|
const explanation = deferred<PlanningExplanation | undefined>();
|
||||||
|
const explanationError = deferred<PlanningReason | undefined>();
|
||||||
|
const cancel = vi.fn();
|
||||||
|
const context = {
|
||||||
|
id,
|
||||||
|
cancel,
|
||||||
|
snapshot: { rules: [], labels: {}, declaredDays: [] },
|
||||||
|
outcome: outcome.promise,
|
||||||
|
explanation: explanation.promise,
|
||||||
|
explanationError: explanationError.promise,
|
||||||
|
analyzeCounterfactual: vi.fn(),
|
||||||
|
} as unknown as PlanningCalculationContext;
|
||||||
|
return { context, outcome, explanation, explanationError, cancel };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Fake = ReturnType<typeof fakeCalculation>;
|
||||||
|
|
||||||
|
const flush = () => new Promise<void>((resolve) => setTimeout(resolve));
|
||||||
|
|
||||||
|
describe('PlanningCalculationService', () => {
|
||||||
|
let create: ReturnType<typeof vi.fn>;
|
||||||
|
let service: PlanningCalculationService;
|
||||||
|
|
||||||
|
function queue(...fakes: Fake[]): void {
|
||||||
|
for (const fake of fakes) create.mockReturnValueOnce(fake.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
create = vi.fn();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: { createPlanningCalculation: create } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
|
||||||
|
service = TestBed.inject(PlanningCalculationService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not calculate until started and ignores input edits afterwards', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-02', 'cp');
|
||||||
|
expect(create).not.toHaveBeenCalled();
|
||||||
|
expect(service.accepted()).toBeNull();
|
||||||
|
service.start();
|
||||||
|
expect(create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.isCalculating()).toBe(true);
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
const accepted = service.accepted()!;
|
||||||
|
expect(accepted.successful).toBe(true);
|
||||||
|
expect(service.isCalculating()).toBe(false);
|
||||||
|
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-03', 'cp');
|
||||||
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-01', '2024-03-01');
|
||||||
|
await flush();
|
||||||
|
expect(create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.accepted()).toBe(accepted);
|
||||||
|
expect(service.displayedHorizon()).toEqual({ start: '2024-01-01', end: '2025-01-01' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands the submission translated labels and leaves declaration entry', () => {
|
||||||
|
queue(fakeCalculation('first'));
|
||||||
|
const input = TestBed.inject(UserInputService);
|
||||||
|
input.declarationEntrySignal.set(true);
|
||||||
|
service.start();
|
||||||
|
expect(input.declarationEntrySignal()).toBe(false);
|
||||||
|
const submission = create.mock.calls[0][0];
|
||||||
|
expect(submission.horizon).toEqual(service.request()!.horizon);
|
||||||
|
expect(submission.balances.every((b: { label: string }) => !!b.label)).toBe(true);
|
||||||
|
expect(submission.labels).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports progress of the latest calculation only', () => {
|
||||||
|
queue(fakeCalculation('old'), fakeCalculation('latest'));
|
||||||
|
service.start();
|
||||||
|
const oldProgress = create.mock.calls[0][0].progress;
|
||||||
|
service.start();
|
||||||
|
const latestProgress = create.mock.calls[1][0].progress;
|
||||||
|
oldProgress('obligations');
|
||||||
|
expect(service.progress()).toBe(0);
|
||||||
|
latestProgress('at-risk');
|
||||||
|
expect(service.progress()).toBe(35);
|
||||||
|
latestProgress('unknown');
|
||||||
|
expect(service.progress()).toBe(35);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
const stale = fakeCalculation('stale');
|
||||||
|
const latest = fakeCalculation('latest');
|
||||||
|
queue(first, stale, latest);
|
||||||
|
service.start();
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
const previous = service.accepted()!;
|
||||||
|
service.start();
|
||||||
|
service.start();
|
||||||
|
expect(service.accepted()).toBe(previous);
|
||||||
|
expect(service.displayedPlan()).toBe(previous.outcome);
|
||||||
|
stale.outcome.resolve(planFor('2030-01-01', '2031-01-01'));
|
||||||
|
await flush();
|
||||||
|
expect(service.accepted()).toBe(previous);
|
||||||
|
expect(service.isCalculating()).toBe(true);
|
||||||
|
latest.outcome.resolve(planFor('2026-01-01', '2027-01-01'));
|
||||||
|
await flush();
|
||||||
|
expect(service.accepted()!.calculation).toBe(latest.context);
|
||||||
|
expect(service.displayedHorizon()).toEqual({ start: '2026-01-01', end: '2027-01-01' });
|
||||||
|
expect(service.isCalculating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes the outcome usable before its explanation and ignores stale explanations', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
const second = fakeCalculation('second');
|
||||||
|
queue(first, second);
|
||||||
|
service.start();
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
expect(service.outcome()?.kind).toBe('Plan');
|
||||||
|
expect(service.explanation()).toBeNull();
|
||||||
|
service.start();
|
||||||
|
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
first.explanation.resolve(explained);
|
||||||
|
await flush();
|
||||||
|
expect(service.explanation()).toBeNull();
|
||||||
|
second.explanation.resolve(undefined);
|
||||||
|
second.explanationError.resolve(undefined);
|
||||||
|
await flush();
|
||||||
|
expect(service.outcome()?.kind).toBe('Plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an explanation that arrives after a replacement was started and accepted', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
const second = fakeCalculation('second');
|
||||||
|
queue(first, second);
|
||||||
|
service.start();
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
service.start();
|
||||||
|
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
first.explanationError.resolve({ code: 'old', message: 'old' });
|
||||||
|
await flush();
|
||||||
|
expect(service.explanationError()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the outcome when the explanation fails', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
service.start();
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
first.explanation.reject(new Error('no trace'));
|
||||||
|
first.explanationError.resolve({ code: 'worker-error', message: 'broken' });
|
||||||
|
await flush();
|
||||||
|
expect(service.outcome()?.kind).toBe('Plan');
|
||||||
|
expect(service.explanationError()?.message).toBeTruthy();
|
||||||
|
expect(service.explanation()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the outcome when the explanation error promise rejects', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
service.start();
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
first.explanationError.reject(new Error('lost'));
|
||||||
|
await flush();
|
||||||
|
expect(service.outcome()?.kind).toBe('Plan');
|
||||||
|
expect(service.explanationError()).toEqual({ code: 'explanation-error', message: 'lost' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns an outcome rejection into an accepted planner error', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
service.start();
|
||||||
|
first.outcome.reject(new Error('worker died'));
|
||||||
|
await flush();
|
||||||
|
const accepted = service.accepted()!;
|
||||||
|
expect(accepted.successful).toBe(false);
|
||||||
|
expect(accepted.outcome).toEqual({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
completedStages: [],
|
||||||
|
reasons: [{ code: 'planner-error', message: 'worker died' }],
|
||||||
|
});
|
||||||
|
expect(service.isCalculating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a construction failure into an accepted planner error with the requested horizon', () => {
|
||||||
|
create.mockImplementation(() => {
|
||||||
|
throw new Error('cannot start');
|
||||||
|
});
|
||||||
|
service.start();
|
||||||
|
const reason = { code: 'planner-error', message: 'cannot start' };
|
||||||
|
expect(service.outcome()).toEqual({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
completedStages: [],
|
||||||
|
reasons: [reason],
|
||||||
|
});
|
||||||
|
expect(service.explanationError()).toEqual(reason);
|
||||||
|
expect(service.accepted()!.calculation).toBeNull();
|
||||||
|
expect(service.displayedHorizon()).toEqual(service.request()!.horizon);
|
||||||
|
expect(service.isCalculating()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels cooperatively once, and not when nothing is running', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
service.cancel();
|
||||||
|
expect(first.cancel).not.toHaveBeenCalled();
|
||||||
|
service.start();
|
||||||
|
service.cancel();
|
||||||
|
service.cancel();
|
||||||
|
expect(first.cancel).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service.isCancelling()).toBe(true);
|
||||||
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
||||||
|
await flush();
|
||||||
|
expect(service.isCancelling()).toBe(false);
|
||||||
|
expect(service.accepted()!.successful).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a cancelled calculation without an incumbent as incomplete, without inventing a conflict', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
service.start();
|
||||||
|
service.cancel();
|
||||||
|
first.outcome.resolve({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
completedStages: [],
|
||||||
|
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(service.outcome()?.kind).toBe('PlanningIncomplete');
|
||||||
|
expect(service.displayedPlan()).toBeUndefined();
|
||||||
|
expect(service.accepted()!.successful).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the incumbent of a cancelled calculation', async () => {
|
||||||
|
const first = fakeCalculation('first');
|
||||||
|
queue(first);
|
||||||
|
const incumbent = planFor('2024-01-01', '2025-01-01') as PlanOutcome;
|
||||||
|
service.start();
|
||||||
|
service.cancel();
|
||||||
|
first.outcome.resolve({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
incumbent,
|
||||||
|
completedStages: [],
|
||||||
|
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
expect(service.displayedPlan()).toBe(incumbent);
|
||||||
|
expect(service.accepted()!.successful).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { CalendarService } from './calendar-service';
|
||||||
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
|
import type {
|
||||||
|
DeclaredNonWorkingDay,
|
||||||
|
PlanningCalculationContext,
|
||||||
|
PlanningExplanation,
|
||||||
|
PlanningOutcome,
|
||||||
|
PlanningReason,
|
||||||
|
PlanningSnapshot,
|
||||||
|
} from './leave-planner.service';
|
||||||
|
import { RuleService } from './rule-service';
|
||||||
|
import { TranslationService } from './translation-service';
|
||||||
|
import { UserInputService } from './user-input-service';
|
||||||
|
|
||||||
|
type Horizon = { start: string; end: string };
|
||||||
|
|
||||||
|
interface PendingCalculation {
|
||||||
|
id: number;
|
||||||
|
horizon: Horizon;
|
||||||
|
progress: number;
|
||||||
|
cancelling: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptedCalculation {
|
||||||
|
/** Null when the planner could not even be created. */
|
||||||
|
calculation: PlanningCalculationContext | null;
|
||||||
|
outcome: PlanningOutcome;
|
||||||
|
snapshot: PlanningSnapshot | null;
|
||||||
|
horizon: Horizon;
|
||||||
|
declaredDays: readonly DeclaredNonWorkingDay[];
|
||||||
|
requestedAt: number;
|
||||||
|
successful: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageProgress(stage: string, current: number): number {
|
||||||
|
if (stage === 'complete') return 100;
|
||||||
|
if (stage === 'validation') return 5;
|
||||||
|
if (stage === 'obligations') return 20;
|
||||||
|
if (stage === 'at-risk') return 35;
|
||||||
|
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;
|
||||||
|
if (stage === 'short-sequence-deficit') return 79;
|
||||||
|
if (stage === 'supporting-placements') return 86;
|
||||||
|
if (stage === 'expiry') return 93;
|
||||||
|
if (stage === 'dates') return 98;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerError(error: unknown): PlanningReason {
|
||||||
|
return {
|
||||||
|
code: 'planner-error',
|
||||||
|
message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function explanationFailure(error: unknown): PlanningReason {
|
||||||
|
return {
|
||||||
|
code: 'explanation-error',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single owner of planning calculations: submission, progress, cancellation and the accepted result. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PlanningCalculationService {
|
||||||
|
private readonly settings = inject(CalendarSettingsService);
|
||||||
|
private readonly userInput = inject(UserInputService);
|
||||||
|
private readonly rules = inject(RuleService);
|
||||||
|
private readonly calendar = inject(CalendarService);
|
||||||
|
private readonly translation = inject(TranslationService);
|
||||||
|
|
||||||
|
private version = 0;
|
||||||
|
private running: PlanningCalculationContext | null = null;
|
||||||
|
private readonly pendingState = signal<PendingCalculation | null>(null);
|
||||||
|
private readonly acceptedState = signal<AcceptedCalculation | null>(null);
|
||||||
|
private readonly explanationState = signal<PlanningExplanation | null>(null);
|
||||||
|
private readonly explanationErrorState = signal<PlanningReason | null>(null);
|
||||||
|
private readonly explanationRejectedState = signal(false);
|
||||||
|
|
||||||
|
readonly request = computed(() => {
|
||||||
|
const horizon = this.settings.planningHorizon();
|
||||||
|
if (!horizon) return null;
|
||||||
|
return {
|
||||||
|
horizon,
|
||||||
|
declaredDays: this.userInput.declaredDaysSignal(),
|
||||||
|
balances: this.userInput.vacationNumberSignal().map((balance) => ({
|
||||||
|
id: balance.id,
|
||||||
|
type: balance.type,
|
||||||
|
quantity: balance.numberOfDays,
|
||||||
|
availableFrom: balance.availableDate
|
||||||
|
? dayjs(balance.availableDate).format('YYYY-MM-DD')
|
||||||
|
: undefined,
|
||||||
|
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
||||||
|
})),
|
||||||
|
rules: this.rules.rules,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly accepted = this.acceptedState.asReadonly();
|
||||||
|
readonly explanation = this.explanationState.asReadonly();
|
||||||
|
readonly explanationError = this.explanationErrorState.asReadonly();
|
||||||
|
readonly explanationRejected = this.explanationRejectedState.asReadonly();
|
||||||
|
readonly pendingId = computed(() => this.pendingState()?.id ?? null);
|
||||||
|
readonly isCalculating = computed(() => this.pendingState() !== null);
|
||||||
|
readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
|
||||||
|
readonly progress = computed(() => this.pendingState()?.progress ?? 0);
|
||||||
|
readonly outcome = computed(() => this.acceptedState()?.outcome ?? null);
|
||||||
|
readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
|
||||||
|
readonly displayedPlan = computed(() => {
|
||||||
|
const outcome = this.outcome();
|
||||||
|
return outcome?.kind === 'Plan'
|
||||||
|
? outcome
|
||||||
|
: outcome?.kind === 'PlanningIncomplete'
|
||||||
|
? outcome.incumbent
|
||||||
|
: undefined;
|
||||||
|
});
|
||||||
|
readonly displayedHorizon = computed(
|
||||||
|
() => this.acceptedState()?.horizon ?? this.pendingState()?.horizon ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
const request = this.request();
|
||||||
|
if (!request) return;
|
||||||
|
const requestedAt = performance.now();
|
||||||
|
const version = ++this.version;
|
||||||
|
this.userInput.declarationEntrySignal.set(false);
|
||||||
|
this.pendingState.set({
|
||||||
|
id: version,
|
||||||
|
horizon: request.horizon,
|
||||||
|
progress: 0,
|
||||||
|
cancelling: false,
|
||||||
|
});
|
||||||
|
let calculation: PlanningCalculationContext;
|
||||||
|
try {
|
||||||
|
calculation = this.calendar.createPlanningCalculation({
|
||||||
|
...request,
|
||||||
|
balances: request.balances.map((balance) => ({
|
||||||
|
...balance,
|
||||||
|
label: this.translation.getDayTypeLabel(balance.type),
|
||||||
|
})),
|
||||||
|
labels: Object.fromEntries(
|
||||||
|
request.rules.map((rule) => [rule.id, rule.name ?? rule.id]),
|
||||||
|
),
|
||||||
|
progress: (stage) => {
|
||||||
|
if (version === this.version)
|
||||||
|
this.pendingState.update(
|
||||||
|
(pending) =>
|
||||||
|
pending && {
|
||||||
|
...pending,
|
||||||
|
progress: stageProgress(stage, pending.progress),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reason = plannerError(error);
|
||||||
|
this.running = null;
|
||||||
|
this.accept(null, this.incomplete(reason), request.horizon, [], requestedAt);
|
||||||
|
this.explanationErrorState.set(reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.running = calculation;
|
||||||
|
const accept = (outcome: PlanningOutcome) => {
|
||||||
|
if (version !== this.version) return false;
|
||||||
|
this.running = null;
|
||||||
|
this.accept(calculation, outcome, request.horizon, undefined, requestedAt);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
void calculation.outcome
|
||||||
|
.then((outcome) => accept(outcome))
|
||||||
|
.catch((error: unknown) => accept(this.incomplete(plannerError(error))))
|
||||||
|
.then((accepted) => {
|
||||||
|
if (accepted) this.loadArtifacts(calculation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
const pending = this.pendingState();
|
||||||
|
if (!pending || pending.cancelling || !this.running) return;
|
||||||
|
this.pendingState.set({ ...pending, cancelling: true });
|
||||||
|
this.running.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private incomplete(reason: PlanningReason): PlanningOutcome {
|
||||||
|
return { kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private accept(
|
||||||
|
calculation: PlanningCalculationContext | null,
|
||||||
|
outcome: PlanningOutcome,
|
||||||
|
requestHorizon: Horizon,
|
||||||
|
declaredDays: readonly DeclaredNonWorkingDay[] | undefined,
|
||||||
|
requestedAt: number,
|
||||||
|
): void {
|
||||||
|
const plan = outcome.kind === 'Plan' ? outcome : undefined;
|
||||||
|
const shown =
|
||||||
|
plan ?? (outcome.kind === 'PlanningIncomplete' ? outcome.incumbent : undefined);
|
||||||
|
const cancelling = this.pendingState()?.cancelling ?? false;
|
||||||
|
this.explanationState.set(null);
|
||||||
|
this.explanationErrorState.set(null);
|
||||||
|
this.explanationRejectedState.set(false);
|
||||||
|
this.pendingState.set(null);
|
||||||
|
this.acceptedState.set({
|
||||||
|
calculation,
|
||||||
|
outcome,
|
||||||
|
snapshot: calculation?.snapshot ?? null,
|
||||||
|
horizon: shown?.horizon ?? requestHorizon,
|
||||||
|
declaredDays:
|
||||||
|
shown?.declaredDays ?? declaredDays ?? calculation?.snapshot.declaredDays ?? [],
|
||||||
|
requestedAt,
|
||||||
|
successful: plan !== undefined && !cancelling,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadArtifacts(calculation: PlanningCalculationContext): void {
|
||||||
|
const current = () => this.acceptedState()?.calculation === calculation;
|
||||||
|
const fail = (error: unknown) => {
|
||||||
|
if (!current()) return;
|
||||||
|
this.explanationRejectedState.set(true);
|
||||||
|
this.explanationErrorState.set(explanationFailure(error));
|
||||||
|
};
|
||||||
|
calculation.explanation
|
||||||
|
.then((explanation) => current() && this.explanationState.set(explanation ?? null))
|
||||||
|
.catch(fail);
|
||||||
|
calculation.explanationError
|
||||||
|
.then((reason) => current() && this.explanationErrorState.set(reason ?? null))
|
||||||
|
.catch(fail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { RuleService, RuleType } from './rule-service';
|
|||||||
import { UserInputService } from './user-input-service';
|
import { UserInputService } from './user-input-service';
|
||||||
import { createPlanningCalculation } from './leave-planner.service';
|
import { createPlanningCalculation } from './leave-planner.service';
|
||||||
import { PlanningDurationService } from './planning-duration.service';
|
import { PlanningDurationService } from './planning-duration.service';
|
||||||
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||||
|
|
||||||
describe('Planning duration estimate', () => {
|
describe('Planning duration estimate', () => {
|
||||||
@@ -31,7 +32,8 @@ describe('Planning duration estimate', () => {
|
|||||||
it('offers a coarse seconds range on the first launch without calculating', () => {
|
it('offers a coarse seconds range on the first launch without calculating', () => {
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
|
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
|
||||||
expect(TestBed.inject(UserInputService).computeTriggerSignal()).toBe(0);
|
expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
|
||||||
|
expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
|
||||||
expect(TestBed.inject(RuleService).rules).toEqual([]);
|
expect(TestBed.inject(RuleService).rules).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ describe('Planning duration estimate', () => {
|
|||||||
});
|
});
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true);
|
expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true);
|
||||||
duration.recordCompletion(duration.request()!, 20_000);
|
duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, 20_000);
|
||||||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,19 +83,20 @@ describe('Planning duration estimate', () => {
|
|||||||
expect(duration.estimate()).toEqual(moreLeave);
|
expect(duration.estimate()).toEqual(moreLeave);
|
||||||
settings.setPlanningDates('2026-12-31', '2024-01-01');
|
settings.setPlanningDates('2026-12-31', '2024-01-01');
|
||||||
expect(duration.estimate()).toBeNull();
|
expect(duration.estimate()).toBeNull();
|
||||||
expect(input.computeTriggerSignal()).toBe(0);
|
expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
|
||||||
|
expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refines the submitted configuration using elapsed calendar time and displays minutes for a long wait', () => {
|
it('refines the submitted configuration using elapsed calendar time and displays minutes for a long wait', () => {
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const submitted = duration.request()!;
|
const submitted = TestBed.inject(PlanningCalculationService).request()!;
|
||||||
duration.recordCompletion(submitted, 120_000);
|
duration.recordCompletion(submitted, 120_000);
|
||||||
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 6, unit: 'minutes' });
|
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 6, unit: 'minutes' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calibrates against the submitted snapshot even when the form has since changed', () => {
|
it('calibrates against the submitted snapshot even when the form has since changed', () => {
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const submitted = duration.request()!;
|
const submitted = TestBed.inject(PlanningCalculationService).request()!;
|
||||||
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2026-12-31');
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2026-12-31');
|
||||||
duration.recordCompletion(submitted, 20_000);
|
duration.recordCompletion(submitted, 20_000);
|
||||||
expect(duration.estimate()!.maximum).toBeGreaterThan(60);
|
expect(duration.estimate()!.maximum).toBeGreaterThan(60);
|
||||||
@@ -114,14 +117,16 @@ describe('Planning duration estimate', () => {
|
|||||||
placeType: SelectableDayType.CP,
|
placeType: SelectableDayType.CP,
|
||||||
});
|
});
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const calculation = createPlanningCalculation(duration.request()!);
|
const calculation = createPlanningCalculation(
|
||||||
|
TestBed.inject(PlanningCalculationService).request()!,
|
||||||
|
);
|
||||||
duration.recordCompletion(calculation.snapshot, 20_000);
|
duration.recordCompletion(calculation.snapshot, 20_000);
|
||||||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses recent typical waits rather than an isolated slow calculation, and resets on a fresh visit', () => {
|
it('uses recent typical waits rather than an isolated slow calculation, and resets on a fresh visit', () => {
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const submitted = duration.request()!;
|
const submitted = TestBed.inject(PlanningCalculationService).request()!;
|
||||||
for (const elapsed of [20_000, 25_000, 900_000])
|
for (const elapsed of [20_000, 25_000, 900_000])
|
||||||
duration.recordCompletion(submitted, elapsed);
|
duration.recordCompletion(submitted, elapsed);
|
||||||
expect(duration.estimate()).toEqual({ minimum: 12, maximum: 75, unit: 'seconds' });
|
expect(duration.estimate()).toEqual({ minimum: 12, maximum: 75, unit: 'seconds' });
|
||||||
@@ -136,7 +141,7 @@ describe('Planning duration estimate', () => {
|
|||||||
it.each([0, -1, NaN, Infinity])('ignores unusable elapsed measurements: %s', (elapsed) => {
|
it.each([0, -1, NaN, Infinity])('ignores unusable elapsed measurements: %s', (elapsed) => {
|
||||||
const duration = TestBed.inject(PlanningDurationService);
|
const duration = TestBed.inject(PlanningDurationService);
|
||||||
const initial = duration.estimate();
|
const initial = duration.estimate();
|
||||||
duration.recordCompletion(duration.request()!, elapsed);
|
duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, elapsed);
|
||||||
expect(duration.estimate()).toEqual(initial);
|
expect(duration.estimate()).toEqual(initial);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { CalendarSettingsService } from './calendar-settings-service';
|
|
||||||
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
|
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
|
||||||
import { RuleService } from './rule-service';
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
import { UserInputService } from './user-input-service';
|
|
||||||
|
|
||||||
export interface PlanningDurationRange {
|
export interface PlanningDurationRange {
|
||||||
minimum: number;
|
minimum: number;
|
||||||
@@ -54,32 +52,11 @@ function initialDurationMs(request: PlanningRequest): number {
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PlanningDurationService {
|
export class PlanningDurationService {
|
||||||
private readonly settings = inject(CalendarSettingsService);
|
private readonly calculations = inject(PlanningCalculationService);
|
||||||
private readonly userInput = inject(UserInputService);
|
|
||||||
private readonly rules = inject(RuleService);
|
|
||||||
private readonly calibration = signal<readonly number[]>([]);
|
private readonly calibration = signal<readonly number[]>([]);
|
||||||
|
|
||||||
readonly request = computed(() => {
|
|
||||||
const horizon = this.settings.planningHorizon();
|
|
||||||
if (!horizon) return null;
|
|
||||||
return {
|
|
||||||
horizon,
|
|
||||||
declaredDays: this.userInput.declaredDaysSignal(),
|
|
||||||
balances: this.userInput.vacationNumberSignal().map((balance) => ({
|
|
||||||
id: balance.id,
|
|
||||||
type: balance.type,
|
|
||||||
quantity: balance.numberOfDays,
|
|
||||||
availableFrom: balance.availableDate
|
|
||||||
? dayjs(balance.availableDate).format('YYYY-MM-DD')
|
|
||||||
: undefined,
|
|
||||||
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
|
||||||
})),
|
|
||||||
rules: this.rules.rules,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly estimate = computed<PlanningDurationRange | null>(() => {
|
readonly estimate = computed<PlanningDurationRange | null>(() => {
|
||||||
const request = this.request();
|
const request = this.calculations.request();
|
||||||
if (!request) return null;
|
if (!request) return null;
|
||||||
const factors = [...this.calibration()].sort((a, b) => a - b);
|
const factors = [...this.calibration()].sort((a, b) => a - b);
|
||||||
const middle = Math.floor(factors.length / 2);
|
const middle = Math.floor(factors.length / 2);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
compareObjectiveLists,
|
compareObjectiveLists,
|
||||||
compareObjectiveValues,
|
compareObjectiveValues,
|
||||||
@@ -75,6 +75,29 @@ describe('composable planning objectives', () => {
|
|||||||
).toEqual([3, 7, 4, 6, 5, 2]);
|
).toEqual([3, 7, 4, 6, 5, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['sum', 'count', 'maximum'] as const)(
|
||||||
|
'keeps %s distinct-date scores current with frozen and mutable period dates',
|
||||||
|
(aggregation) => {
|
||||||
|
const mutableDates = ['2024-05-08'];
|
||||||
|
const observations: OptimizationObservations = {
|
||||||
|
...empty,
|
||||||
|
timeOffPeriods: Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
nonWorkingDates: Object.freeze(['2024-01-06', '2024-01-07', '2024-01-07']),
|
||||||
|
}),
|
||||||
|
Object.freeze({ nonWorkingDates: mutableDates }),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const configuration = { ...OPTIMIZATION_PRESETS.nonWorkingDayGain, aggregation };
|
||||||
|
const initial = { sum: 3, count: 2, maximum: 2 };
|
||||||
|
expect(evaluateOptimization(configuration, observations)).toBe(initial[aggregation]);
|
||||||
|
expect(evaluateOptimization(configuration, observations)).toBe(initial[aggregation]);
|
||||||
|
mutableDates.push('2024-05-09', '2024-05-09', '2024-05-11');
|
||||||
|
const updated = { sum: 5, count: 2, maximum: 3 };
|
||||||
|
expect(evaluateOptimization(configuration, observations)).toBe(updated[aggregation]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it.each(['sum', 'count', 'maximum'] as const)(
|
it.each(['sum', 'count', 'maximum'] as const)(
|
||||||
'defines empty %s as zero for every measure',
|
'defines empty %s as zero for every measure',
|
||||||
(aggregation) => {
|
(aggregation) => {
|
||||||
@@ -151,7 +174,8 @@ describe('composable planning objectives', () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
null,
|
null,
|
||||||
[],
|
[undefined],
|
||||||
|
[[]],
|
||||||
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, conditions: ['internal', 'internal'] },
|
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, conditions: ['internal', 'internal'] },
|
||||||
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, direction: 'up' },
|
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, direction: 'up' },
|
||||||
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: new Array(1) },
|
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: new Array(1) },
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ export interface OptimizationObservations {
|
|||||||
leavePlacements: readonly { supporting: boolean }[];
|
leavePlacements: readonly { supporting: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calendar geometry reuses frozen date arrays across candidate plans.
|
||||||
|
const nonWorkingDateCounts = new WeakMap<readonly string[], number>();
|
||||||
|
|
||||||
export function evaluateOptimization(
|
export function evaluateOptimization(
|
||||||
configuration: OptimizationConfiguration,
|
configuration: OptimizationConfiguration,
|
||||||
observations: OptimizationObservations,
|
observations: OptimizationObservations,
|
||||||
@@ -224,9 +227,14 @@ export function evaluateOptimization(
|
|||||||
.map((week) => week.value);
|
.map((week) => week.value);
|
||||||
break;
|
break;
|
||||||
case 'time-off-periods':
|
case 'time-off-periods':
|
||||||
values = observations.timeOffPeriods.map(
|
values = observations.timeOffPeriods.map(({ nonWorkingDates }) => {
|
||||||
(period) => new Set(period.nonWorkingDates).size,
|
const cached = nonWorkingDateCounts.get(nonWorkingDates);
|
||||||
);
|
if (cached !== undefined) return cached;
|
||||||
|
const count = new Set(nonWorkingDates).size;
|
||||||
|
if (Object.isFrozen(nonWorkingDates))
|
||||||
|
nonWorkingDateCounts.set(nonWorkingDates, count);
|
||||||
|
return count;
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case 'work-sequences':
|
case 'work-sequences':
|
||||||
values = observations.workSequences
|
values = observations.workSequences
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { RuleService, RuleType } from './rule-service';
|
import { RuleService, RuleType } from './rule-service';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { CalendarSettingsService } from './calendar-settings-service';
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
import { PlanningDurationService } from './planning-duration.service';
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
import { UserInputService } from './user-input-service';
|
import { UserInputService } from './user-input-service';
|
||||||
import { SelectableDayType } from './calendar-service';
|
import { SelectableDayType } from './calendar-service';
|
||||||
import { createPlanningCalculation } from './leave-planner.service';
|
import { createPlanningCalculation } from './leave-planner.service';
|
||||||
@@ -260,7 +260,7 @@ describe('saved rule migration', () => {
|
|||||||
expiryDate: new Date('2024-01-05'),
|
expiryDate: new Date('2024-01-05'),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const request = TestBed.inject(PlanningDurationService).request()!;
|
const request = TestBed.inject(PlanningCalculationService).request()!;
|
||||||
expect(request.rules).toHaveLength(6);
|
expect(request.rules).toHaveLength(6);
|
||||||
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
|
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
|
||||||
vi.stubGlobal('window', undefined);
|
vi.stubGlobal('window', undefined);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from './rule-service';
|
} from './rule-service';
|
||||||
import { SelectableDayType } from './calendar-service';
|
import { SelectableDayType } from './calendar-service';
|
||||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
|
|
||||||
describe('RuleService', () => {
|
describe('RuleService', () => {
|
||||||
let service: RuleService;
|
let service: RuleService;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
import { Injectable, inject, Injector, computed } from '@angular/core';
|
import { Injectable, inject, Injector, computed } from '@angular/core';
|
||||||
import { storedSignal } from '../shared/utils/stored-signal';
|
import { storedSignal } from '../shared/utils/stored-signal';
|
||||||
import type { PlanOutcome, RemainingBalance } from './leave-planner.service';
|
import type { PlanOutcome, RemainingBalance } from './leave-planner.service';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { UserInputService } from './user-input-service';
|
import { UserInputService } from './user-input-service';
|
||||||
import { SelectableDayType, VacDay } from './calendar-service';
|
import { SelectableDayType, VacDay } from './calendar-service';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
|
|
||||||
describe('UserInputService', () => {
|
describe('UserInputService', () => {
|
||||||
const savedBalance = {
|
const savedBalance = {
|
||||||
@@ -46,7 +46,6 @@ describe('UserInputService', () => {
|
|||||||
{ date: '2024-01-02', type: 'other' },
|
{ date: '2024-01-02', type: 'other' },
|
||||||
]);
|
]);
|
||||||
expect(service.vacationNumberSignal()).toBe(balances);
|
expect(service.vacationNumberSignal()).toBe(balances);
|
||||||
expect(service.computeTriggerSignal()).toBe(0);
|
|
||||||
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,7 +118,6 @@ describe('UserInputService', () => {
|
|||||||
]);
|
]);
|
||||||
expect(save).toHaveBeenCalledTimes(3);
|
expect(save).toHaveBeenCalledTimes(3);
|
||||||
expect(service.vacationNumberSignal()).toBe(balances);
|
expect(service.vacationNumberSignal()).toBe(balances);
|
||||||
expect(service.computeTriggerSignal()).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('restores an empty list when saved declarations are unreadable', () => {
|
it('restores an empty list when saved declarations are unreadable', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import { SelectableDayType, VacDay } from './calendar-service';
|
import { SelectableDayType, VacDay } from './calendar-service';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
isWorkingPlanningDate,
|
isWorkingPlanningDate,
|
||||||
parseDeclaredDays,
|
parseDeclaredDays,
|
||||||
} from './declared-non-working-days';
|
} from './declared-non-working-days';
|
||||||
import type { LeaveType, PlanningOutcome, PlanningSnapshot } from './leave-planner.service';
|
import type { LeaveType } from './leave-planner.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -161,20 +161,6 @@ export class UserInputService {
|
|||||||
readonly vacationNumberSignal = signal<VacDay[]>(
|
readonly vacationNumberSignal = signal<VacDay[]>(
|
||||||
this.getSavedValues() ?? this.getInitialValues(),
|
this.getSavedValues() ?? this.getInitialValues(),
|
||||||
);
|
);
|
||||||
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
|
|
||||||
readonly planningOutcomeSignal = signal<PlanningOutcome | null>(null);
|
|
||||||
readonly planningSnapshotSignal = signal<PlanningSnapshot | null>(null);
|
|
||||||
readonly displayedPlanSignal = computed(() => {
|
|
||||||
const outcome = this.planningOutcomeSignal();
|
|
||||||
return outcome?.kind === 'Plan'
|
|
||||||
? outcome
|
|
||||||
: outcome?.kind === 'PlanningIncomplete'
|
|
||||||
? outcome.incumbent
|
|
||||||
: undefined;
|
|
||||||
});
|
|
||||||
readonly computeTriggerSignal = signal(0);
|
|
||||||
readonly computationRequestedAtMs = signal<number | null>(null);
|
|
||||||
|
|
||||||
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
|
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
|
||||||
this.vacationNumberSignal.update(updater);
|
this.vacationNumberSignal.update(updater);
|
||||||
try {
|
try {
|
||||||
@@ -194,9 +180,4 @@ export class UserInputService {
|
|||||||
console.warn('Failed to save leave balances to localStorage.', error);
|
console.warn('Failed to save leave balances to localStorage.', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public requestComputation(): void {
|
|
||||||
this.computationRequestedAtMs.set(performance.now());
|
|
||||||
this.computeTriggerSignal.update((count) => count + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
|
|
||||||
import { App } from './app/app';
|
|
||||||
import { config } from './app/app.config.server';
|
|
||||||
|
|
||||||
const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);
|
|
||||||
|
|
||||||
export default bootstrap;
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test';
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { connect } from 'node:net';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createServer } from './server';
|
||||||
|
|
||||||
|
const INDEX = '<!doctype html><title>fixture app</title>';
|
||||||
|
const SECRET = 'top-secret-outside-the-root';
|
||||||
|
const JS = 'main-ABC123.js';
|
||||||
|
const IMMUTABLE = 'public, max-age=31536000';
|
||||||
|
|
||||||
|
let workdir: string;
|
||||||
|
let server: ReturnType<typeof createServer>;
|
||||||
|
let emptyServer: ReturnType<typeof createServer>;
|
||||||
|
|
||||||
|
// fetch() normalizes dot segments, so traversal needs an unnormalized request line.
|
||||||
|
function rawGet(port: number, target: string): Promise<{ status: number; text: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let text = '';
|
||||||
|
const socket = connect(port, '127.0.0.1', () =>
|
||||||
|
socket.write(`GET ${target} HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n`),
|
||||||
|
);
|
||||||
|
socket.on('data', (chunk) => (text += chunk));
|
||||||
|
socket.on('end', () => resolve({ status: Number(text.split(' ')[1]), text }));
|
||||||
|
socket.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
workdir = mkdtempSync(join(tmpdir(), 'vac-server-'));
|
||||||
|
const root = join(workdir, 'browser');
|
||||||
|
mkdirSync(join(root, 'assets'), { recursive: true });
|
||||||
|
writeFileSync(join(root, 'index.html'), INDEX);
|
||||||
|
writeFileSync(join(root, JS), 'console.log("app");');
|
||||||
|
writeFileSync(join(root, 'styles-DEF456.css'), 'body{margin:0}');
|
||||||
|
writeFileSync(join(root, 'assets', 'data.json'), '{"nested":true}');
|
||||||
|
writeFileSync(join(workdir, 'secret.txt'), SECRET);
|
||||||
|
mkdirSync(join(workdir, 'empty'));
|
||||||
|
server = createServer({ root, port: 0 });
|
||||||
|
emptyServer = createServer({ root: join(workdir, 'empty'), port: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.stop(true);
|
||||||
|
await emptyServer.stop(true);
|
||||||
|
rmSync(workdir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('direct navigation', () => {
|
||||||
|
it.each(['/', '/benchmark', '/benchmark/', '/unknown/client/route', '/assets'])(
|
||||||
|
'serves the application shell for %s',
|
||||||
|
async (path) => {
|
||||||
|
const response = await fetch(new URL(path, server.url));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('content-type')).toStartWith('text/html');
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-cache');
|
||||||
|
expect(await response.text()).toBe(INDEX);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('never gives the shell the long asset cache when requested by name', async () => {
|
||||||
|
const response = await fetch(new URL('/index.html', server.url));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-cache');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('static assets', () => {
|
||||||
|
it.each([
|
||||||
|
[JS, 'javascript', 'console.log("app");'],
|
||||||
|
['styles-DEF456.css', 'css', 'body{margin:0}'],
|
||||||
|
['assets/data.json', 'json', '{"nested":true}'],
|
||||||
|
])('serves /%s with its content type and the long cache', async (file, type, body) => {
|
||||||
|
const response = await fetch(new URL(`/${file}`, server.url));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('content-type')).toContain(type);
|
||||||
|
expect(response.headers.get('cache-control')).toBe(IMMUTABLE);
|
||||||
|
expect(await response.text()).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers HEAD with the GET headers and no body', async () => {
|
||||||
|
const get = await fetch(new URL(`/${JS}`, server.url));
|
||||||
|
const head = await fetch(new URL(`/${JS}`, server.url), { method: 'HEAD' });
|
||||||
|
|
||||||
|
expect(head.status).toBe(200);
|
||||||
|
expect(head.headers.get('content-type')).toBe(get.headers.get('content-type'));
|
||||||
|
expect(head.headers.get('content-length')).toBe(get.headers.get('content-length'));
|
||||||
|
expect(head.headers.get('cache-control')).toBe(IMMUTABLE);
|
||||||
|
expect(await head.text()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers HEAD for a client route with no body', async () => {
|
||||||
|
const head = await fetch(new URL('/benchmark', server.url), { method: 'HEAD' });
|
||||||
|
|
||||||
|
expect(head.status).toBe(200);
|
||||||
|
expect(await head.text()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['/missing.js', '/assets/missing.css', '/nope.js/', '/favicon.ico'])(
|
||||||
|
'answers 404, not the shell, for missing asset %s',
|
||||||
|
async (path) => {
|
||||||
|
const response = await fetch(new URL(path, server.url));
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.headers.get('content-type')).toStartWith('text/plain');
|
||||||
|
expect(await response.text()).not.toContain('fixture app');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('methods', () => {
|
||||||
|
it.each([`/${JS}`, '/benchmark'])('rejects POST to %s with 405', async (path) => {
|
||||||
|
const response = await fetch(new URL(path, server.url), { method: 'POST', body: 'x' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
expect(response.headers.get('allow')).toBe('GET, HEAD');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('path protection', () => {
|
||||||
|
it.each([
|
||||||
|
'/../secret.txt',
|
||||||
|
'/%2e%2e/secret.txt',
|
||||||
|
'/..%2fsecret.txt',
|
||||||
|
'/%2e%2e%2f%2e%2e%2fetc/passwd',
|
||||||
|
'/..%5csecret.txt',
|
||||||
|
'/assets/..%2f..%2fsecret.txt',
|
||||||
|
'/%2e%2e%2fsecret.txt',
|
||||||
|
'//secret.txt',
|
||||||
|
'/%00',
|
||||||
|
'/assets%00/data.json',
|
||||||
|
])('does not leak files outside the root through %s', async (target) => {
|
||||||
|
const response = await rawGet(server.port!, target);
|
||||||
|
|
||||||
|
expect([400, 404]).toContain(response.status);
|
||||||
|
expect(response.text).not.toContain(SECRET);
|
||||||
|
expect(response.text).not.toContain('root:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['/..%2fsecret.txt', '/%2e%2e%2f%2e%2e%2fetc/passwd', '/%00'])(
|
||||||
|
'rejects %s as a bad request',
|
||||||
|
async (target) => {
|
||||||
|
expect((await rawGet(server.port!, target)).status).toBe(400);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects malformed percent-encoding with 400 instead of failing', async () => {
|
||||||
|
for (const target of ['/%ZZ', '/%E0%A4%A', '/assets/%']) {
|
||||||
|
expect((await rawGet(server.port!, target)).status).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('errors', () => {
|
||||||
|
it('answers 500 as plain text and logs it when the build has no index.html', async () => {
|
||||||
|
const log = spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const response = await fetch(new URL('/benchmark', emptyServer.url));
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.headers.get('content-type')).toStartWith('text/plain');
|
||||||
|
expect(await response.text()).toBe('Internal Server Error');
|
||||||
|
expect(log).toHaveBeenCalledTimes(1);
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still answers a missing asset with 404 when index.html is absent', async () => {
|
||||||
|
expect((await fetch(new URL('/missing.js', emptyServer.url))).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
+61
-56
@@ -1,66 +1,71 @@
|
|||||||
import {
|
import { extname, join, resolve, sep } from 'node:path';
|
||||||
AngularNodeAppEngine,
|
|
||||||
createNodeRequestHandler,
|
|
||||||
isMainModule,
|
|
||||||
writeResponseToNodeResponse,
|
|
||||||
} from '@angular/ssr/node';
|
|
||||||
import express from 'express';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
const browserDistFolder = join(import.meta.dirname, '../browser');
|
const IMMUTABLE = 'public, max-age=31536000';
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const angularApp = new AngularNodeAppEngine();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Example Express Rest API endpoints can be defined here.
|
* Fetch handler for a client-only Angular build in `root`: existing files are served as-is,
|
||||||
* Uncomment and define endpoints as necessary.
|
* extension-less paths fall back to `index.html` (client routes) and missing assets get a 404.
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* ```ts
|
|
||||||
* app.get('/api/{*splat}', (req, res) => {
|
|
||||||
* // Handle API request
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
|
export function createHandler(root: string) {
|
||||||
/**
|
const base = resolve(root);
|
||||||
* Serve static files from /browser
|
const index = join(base, 'index.html');
|
||||||
*/
|
const isFile = (path: string) =>
|
||||||
app.use(
|
Bun.file(path)
|
||||||
express.static(browserDistFolder, {
|
.stat()
|
||||||
maxAge: '1y',
|
.then(
|
||||||
index: false,
|
(stats) => stats.isFile(),
|
||||||
redirect: false,
|
() => false,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
// index.html is not content-hashed, so it must be revalidated on every deployment.
|
||||||
/**
|
const send = (path: string) =>
|
||||||
* Handle all other requests by rendering the Angular application.
|
new Response(Bun.file(path), {
|
||||||
*/
|
headers: { 'Cache-Control': path === index ? 'no-cache' : IMMUTABLE },
|
||||||
app.use((req, res, next) => {
|
|
||||||
angularApp
|
|
||||||
.handle(req)
|
|
||||||
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
|
|
||||||
.catch(next);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
return async (request: Request): Promise<Response> => {
|
||||||
* Start the server if this module is the main entry point, or it is ran via PM2.
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||||
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
|
return new Response('Method Not Allowed', {
|
||||||
*/
|
status: 405,
|
||||||
if (isMainModule(import.meta.url) || process.env['pm_id']) {
|
headers: { Allow: 'GET, HEAD' },
|
||||||
const port = process.env['PORT'] || 4000;
|
|
||||||
app.listen(port, (error) => {
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Node Express server listening on http://localhost:${port}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
let path: string;
|
||||||
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
|
try {
|
||||||
*/
|
path = decodeURIComponent(new URL(request.url).pathname);
|
||||||
export const reqHandler = createNodeRequestHandler(app);
|
} catch {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
const file = resolve(base, `.${path}`);
|
||||||
|
if (path.includes('\0') || (file !== base && !file.startsWith(base + sep))) {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortcut: symlinks inside the build are followed; fine while the build output is trusted.
|
||||||
|
if (await isFile(file)) return send(file);
|
||||||
|
// A path with an extension names an asset: a missing one must not turn into HTML.
|
||||||
|
if (extname(file)) return new Response('Not Found', { status: 404 });
|
||||||
|
// A missing index.html is a broken deployment: fail loudly (500) instead of a silent 404.
|
||||||
|
if (!(await isFile(index))) throw new Error(`Missing ${index}`);
|
||||||
|
return send(index);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServer({ root, port }: { root: string; port: number }) {
|
||||||
|
return Bun.serve({
|
||||||
|
port,
|
||||||
|
fetch: createHandler(root),
|
||||||
|
error(error) {
|
||||||
|
console.error(error);
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const server = createServer({
|
||||||
|
root: join(import.meta.dir, '../browser'),
|
||||||
|
port: Number(process.env['PORT'] || 4000),
|
||||||
|
});
|
||||||
|
console.log(`Server listening on ${server.url}`);
|
||||||
|
}
|
||||||
+110
-7
@@ -1,13 +1,116 @@
|
|||||||
import '@angular/compiler';
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { vi } from 'vitest';
|
import { plugin } from 'bun';
|
||||||
import '@analogjs/vitest-angular/setup-snapshots';
|
import { readFileSync } from 'node:fs';
|
||||||
import '@analogjs/vitest-angular/setup-serializers';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
|
import * as bt from 'bun:test';
|
||||||
|
// shortcut: imports Analog's internal jitTransform by path; replace when Angular or Analog exports a JIT transform.
|
||||||
|
import { jitTransform } from '../node_modules/@analogjs/vite-plugin-angular/src/lib/compiler/jit-transform.js';
|
||||||
|
import { vi } from './testing/vitest-compat';
|
||||||
|
|
||||||
setupTestBed();
|
// Inline templateUrl/styleUrl(s) before Bun transpiles: JIT has no resource loader.
|
||||||
|
plugin({
|
||||||
|
name: 'angular-jit',
|
||||||
|
setup(build) {
|
||||||
|
build.onLoad({ filter: /src\/app\/(?!.*\.spec\.ts$).*\.ts$/ }, ({ path }) => {
|
||||||
|
let src = readFileSync(path, 'utf8');
|
||||||
|
if (!/@(Component|Directive|Injectable|Pipe|NgModule)\(/.test(src)) {
|
||||||
|
return { contents: src, loader: 'ts' };
|
||||||
|
}
|
||||||
|
const read = (p: string) =>
|
||||||
|
JSON.stringify(readFileSync(resolve(dirname(path), p), 'utf8'));
|
||||||
|
src = src.replace(/templateUrl:\s*'([^']+)'/, (_, p) => `template: ${read(p)}`);
|
||||||
|
// Styles are not compiled; they do not matter to the tests.
|
||||||
|
src = src.replace(/styleUrl:\s*'([^']+)'/, 'styles: []');
|
||||||
|
src = src.replace(/styleUrls:\s*\[[^\]]*\]/, 'styles: []');
|
||||||
|
// Signal input()/output()/viewChild() become decorators and @Component becomes ɵcompileComponent.
|
||||||
|
return { contents: jitTransform(src, path).code, loader: 'ts' };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { JSDOM } = await import('jsdom');
|
||||||
|
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||||
|
url: 'http://localhost:3000/',
|
||||||
|
pretendToBeVisual: true,
|
||||||
|
});
|
||||||
|
const win = dom.window as any;
|
||||||
|
const keep = new Set([
|
||||||
|
'console',
|
||||||
|
'globalThis',
|
||||||
|
'undefined',
|
||||||
|
'Infinity',
|
||||||
|
'NaN',
|
||||||
|
'Object',
|
||||||
|
'Function',
|
||||||
|
'Array',
|
||||||
|
'Error',
|
||||||
|
'Promise',
|
||||||
|
'Symbol',
|
||||||
|
'Map',
|
||||||
|
'Set',
|
||||||
|
'JSON',
|
||||||
|
'Math',
|
||||||
|
'Date',
|
||||||
|
'process',
|
||||||
|
'window',
|
||||||
|
'self',
|
||||||
|
]);
|
||||||
|
const force = new Set([
|
||||||
|
'document',
|
||||||
|
'navigator',
|
||||||
|
'location',
|
||||||
|
'localStorage',
|
||||||
|
'sessionStorage',
|
||||||
|
'history',
|
||||||
|
'getComputedStyle',
|
||||||
|
]);
|
||||||
|
const domNames =
|
||||||
|
/^(HTML|SVG|DOM|Mutation|Node|Element|Event|Text|Comment|Document|Range|Selection|CSS|Shadow|Custom|Keyboard|Mouse|Focus|Input|UI|Wheel|Pointer|Touch|Animation|File|Blob|FormData|Window|Storage|Headers|Abort)/;
|
||||||
|
for (const k of Object.getOwnPropertyNames(win)) {
|
||||||
|
if (keep.has(k) || (k in globalThis && !force.has(k) && !domNames.test(k))) continue;
|
||||||
|
// Live accessors: spying on window.x also affects bare x (jsdom window !== globalThis here).
|
||||||
|
Object.defineProperty(globalThis, k, {
|
||||||
|
configurable: true,
|
||||||
|
get: () => {
|
||||||
|
const v = win[k];
|
||||||
|
return typeof v === 'function' && /^[a-z]/.test(k) ? v.bind(win) : v;
|
||||||
|
},
|
||||||
|
set: (v) => {
|
||||||
|
win[k] = v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const k of ['window', 'self']) {
|
||||||
|
Object.defineProperty(globalThis, k, { configurable: true, writable: true, value: win });
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
vi,
|
||||||
|
describe: bt.describe,
|
||||||
|
it: bt.it,
|
||||||
|
test: bt.test,
|
||||||
|
expect: bt.expect,
|
||||||
|
beforeEach: bt.beforeEach,
|
||||||
|
afterEach: bt.afterEach,
|
||||||
|
beforeAll: bt.beforeAll,
|
||||||
|
afterAll: bt.afterAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
await import('@angular/compiler');
|
||||||
|
const { getTestBed, ɵgetCleanupHook } = await import('@angular/core/testing');
|
||||||
|
const { BrowserTestingModule, platformBrowserTesting } =
|
||||||
|
await import('@angular/platform-browser/testing');
|
||||||
|
const { provideZonelessChangeDetection } = await import('@angular/core');
|
||||||
|
getTestBed().initTestEnvironment([BrowserTestingModule], platformBrowserTesting(), {
|
||||||
|
teardown: { destroyAfterEach: true },
|
||||||
|
});
|
||||||
|
getTestBed().configureTestingModule({ providers: [provideZonelessChangeDetection()] });
|
||||||
|
bt.beforeEach(ɵgetCleanupHook(false));
|
||||||
|
bt.afterEach(ɵgetCleanupHook(true));
|
||||||
|
|
||||||
|
// Bun has a native Worker; the app must see none, as under jsdom.
|
||||||
|
delete (globalThis as any).Worker;
|
||||||
vi.stubGlobal('Worker', undefined);
|
vi.stubGlobal('Worker', undefined);
|
||||||
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: vi.fn().mockImplementation((query: string) => ({
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
|||||||
Vendored
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars */
|
||||||
|
import 'bun:test';
|
||||||
|
|
||||||
|
// Vitest APIs that the compat shim adds to bun:test.
|
||||||
|
declare module 'bun:test' {
|
||||||
|
interface Expect {
|
||||||
|
// Vitest-style loose matcher arguments: Bun's strict ones reject readonly and widened values.
|
||||||
|
(actual?: unknown): Matchers<any>;
|
||||||
|
poll<T>(
|
||||||
|
fn: () => T | Promise<T>,
|
||||||
|
options?: { timeout?: number; interval?: number },
|
||||||
|
): Matchers<Awaited<T>> & { not: Matchers<Awaited<T>> } & Record<string, unknown>;
|
||||||
|
}
|
||||||
|
interface Matchers<T> {
|
||||||
|
toHaveBeenCalledOnce(): void;
|
||||||
|
toHaveBeenCalledExactlyOnceWith(...args: unknown[]): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import * as bt from 'bun:test';
|
||||||
|
|
||||||
|
const stubbed = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
const realUseFake = bt.vi.useFakeTimers.bind(bt.vi);
|
||||||
|
const flush = () => new Promise<void>((r) => setImmediate(r));
|
||||||
|
type AnyFn = (...args: any[]) => any;
|
||||||
|
type VitestCompat = Omit<typeof bt.vi, 'spyOn' | 'useFakeTimers'> & {
|
||||||
|
setSystemTime(now?: Date | number): VitestCompat;
|
||||||
|
spyOn<T extends object, K extends keyof T>(
|
||||||
|
obj: T,
|
||||||
|
method: K,
|
||||||
|
): bt.Mock<T[K] extends AnyFn ? T[K] : AnyFn>;
|
||||||
|
useFakeTimers(options?: { toFake?: string[] }): VitestCompat;
|
||||||
|
stubGlobal(name: string, value: unknown): VitestCompat;
|
||||||
|
unstubAllGlobals(): VitestCompat;
|
||||||
|
advanceTimersByTimeAsync(ms: number): Promise<void>;
|
||||||
|
runAllTicks(): void;
|
||||||
|
waitFor<T>(
|
||||||
|
fn: () => T | Promise<T>,
|
||||||
|
options?: { timeout?: number; interval?: number },
|
||||||
|
): Promise<T>;
|
||||||
|
};
|
||||||
|
export const vi = Object.assign(bt.vi, {
|
||||||
|
setSystemTime: (d?: Date | number) => bt.setSystemTime(d as any),
|
||||||
|
// Bun ignores toFake and fakes every timer; Date-only is emulated by setSystemTime alone.
|
||||||
|
useFakeTimers(o?: { toFake?: string[] }) {
|
||||||
|
if (o?.toFake?.length === 1 && o.toFake[0] === 'Date') return vi;
|
||||||
|
const now = Date.now();
|
||||||
|
realUseFake();
|
||||||
|
bt.setSystemTime(now);
|
||||||
|
return vi;
|
||||||
|
},
|
||||||
|
stubGlobal(name: string, value: unknown) {
|
||||||
|
if (!stubbed.has(name))
|
||||||
|
stubbed.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||||
|
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||||
|
return vi;
|
||||||
|
},
|
||||||
|
unstubAllGlobals() {
|
||||||
|
for (const [n, d] of stubbed) {
|
||||||
|
if (d) Object.defineProperty(globalThis, n, d);
|
||||||
|
else delete (globalThis as any)[n];
|
||||||
|
}
|
||||||
|
stubbed.clear();
|
||||||
|
return vi;
|
||||||
|
},
|
||||||
|
async advanceTimersByTimeAsync(ms: number) {
|
||||||
|
await flush();
|
||||||
|
bt.vi.advanceTimersByTime(ms);
|
||||||
|
await flush();
|
||||||
|
},
|
||||||
|
runAllTicks() {},
|
||||||
|
async waitFor<T>(fn: () => T | Promise<T>, { timeout = 1000, interval = 50 } = {}): Promise<T> {
|
||||||
|
const end = performance.now() + timeout;
|
||||||
|
let last: unknown;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
last = e;
|
||||||
|
if (performance.now() > end) throw last;
|
||||||
|
await new Promise((r) => setTimeout(r, interval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}) as unknown as VitestCompat;
|
||||||
|
export const { describe, it, test, expect, beforeEach, afterEach, beforeAll, afterAll } = bt;
|
||||||
|
export type MockInstance<T extends AnyFn = AnyFn> = bt.Mock<T>;
|
||||||
|
|
||||||
|
// expect.poll: retry `expect(await fn())<.not>.matcher(...)` until it passes or times out.
|
||||||
|
(bt.expect as any).poll = (fn: () => unknown, { timeout = 1000, interval = 50 } = {}) => {
|
||||||
|
const chain = (negate: boolean): any =>
|
||||||
|
new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_, m: string) =>
|
||||||
|
m === 'not'
|
||||||
|
? chain(!negate)
|
||||||
|
: async (...args: unknown[]) => {
|
||||||
|
const end = performance.now() + timeout;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const e: any = bt.expect(await fn());
|
||||||
|
return await (negate ? e.not : e)[m](...args);
|
||||||
|
} catch (err) {
|
||||||
|
if (performance.now() > end) throw err;
|
||||||
|
await new Promise((r) => setTimeout(r, interval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return chain(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
bt.expect.extend({
|
||||||
|
toHaveBeenCalledExactlyOnceWith(fn: any, ...args: unknown[]) {
|
||||||
|
const calls = fn.mock.calls as unknown[][];
|
||||||
|
const pass = calls.length === 1 && this.equals(calls[0], args);
|
||||||
|
return {
|
||||||
|
pass,
|
||||||
|
message: () =>
|
||||||
|
`expected exactly one call with ${JSON.stringify(args)}, got ${JSON.stringify(calls)}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
+1
-1
@@ -7,5 +7,5 @@
|
|||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["src/**/*.spec.ts"]
|
"exclude": ["src/**/*.spec.ts", "src/server.ts"]
|
||||||
}
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": ["vitest/globals"],
|
"types": ["bun", "bun-types/test-globals", "node"],
|
||||||
"target": "es2022"
|
"target": "es2022"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
/// <reference types="vitest" />
|
|
||||||
|
|
||||||
import angular from '@analogjs/vite-plugin-angular';
|
|
||||||
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig(({ mode }) => {
|
|
||||||
return {
|
|
||||||
plugins: [angular()],
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'jsdom',
|
|
||||||
setupFiles: ['src/test-setup.ts'],
|
|
||||||
include: ['**/*.spec.ts'],
|
|
||||||
reporters: ['default'],
|
|
||||||
pool: 'forks',
|
|
||||||
coverage: {
|
|
||||||
provider: 'istanbul',
|
|
||||||
reporter: ['text', 'html', 'cobertura'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
'import.meta.vitest': mode !== 'production',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
Reference in new issue
Block a user