Compare commits

...
Author SHA1 Message Date
nessarandClaude Opus 5.5 282d9c6787 fix: report line-only coverage honestly and tighten Bun test helpers
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 41s
CI / build (push) Successful in 36s
CI / policy (pull_request) Successful in 10s
CI / validate (pull_request) Successful in 32s
CI / security (push) Successful in 1m3s
CI / test (push) Successful in 3m3s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 57s
CI / test (pull_request) Successful in 3m25s
CI / benchmark (push) Successful in 5m19s
CI / benchmark (pull_request) Successful in 4m48s
CI / images (push) Successful in 1m20s
CI / required (push) Successful in 3s
CI / images (pull_request) Successful in 54s
CI / required (pull_request) Successful in 1s
The generated HTML and Cobertura reports no longer show unmeasured
branch and function coverage as 100%. Poll and waitFor deadlines use
performance.now so frozen dates cannot make them loop forever, the
malformed-configuration test covers undefined again next to the empty
array, compose health checks honor PORT, and the migration spec lists
only what is still open.

Refs #291, #292, #293

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:10:29 +02:00
nessarandClaude Opus 5.5 dfe6d28d2b test(planning): make the checkpoint cancellation test deterministic
The cancelled stage takes about a millisecond, so under load the worker
could finish it before the cancel message arrived and report it as
completed. The real-worker harness now holds the worker after reporting
that stage until the cancel is delivered, keeping every assertion.

Refs #291

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:57:33 +02:00
nessarandClaude Opus 5.5 ac69a41fc8 ci: run development, quality checks and CI on Bun 1.4.3
Pin Bun 1.4.3 with packageManager, and force every bun run script onto
the Bun runtime through bunfig so ng, ESLint and Prettier never fall
back to an installed Node. Type checking uses bun check for the
application and test projects, next to the Angular build.

CI jobs install the pinned release from a checksum-verified download
instead of npm and run project steps in a shell without Node on PATH,
which stays available only for the checkout and upload actions. The
image test's fake docker runs on Bun.

Refs #290, #293

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:25:46 +02:00
nessarandClaude Opus 5.5 f74d744f9b feat(server): serve the client-only build with Bun
Angular's server build needs Node loader hooks that Bun does not apply,
and every route is client-rendered, so Angular now builds a client-only
bundle and a small Bun.serve server delivers it: hashed assets with the
one-year cache, index.html revalidated, extension-less routes falling
back to index.html, missing assets 404, traversal and malformed paths
rejected, and other methods 405. Express, @angular/ssr and the unused
hydration provider are removed. Both Docker stages use the pinned
oven/bun:1.4.3-alpine image without Node, and the health checks use
Bun's fetch.

Refs #292

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:50:05 +02:00
nessarandClaude Opus 5.5 94d57add2c test: run the suite and benchmark with Bun's native test runner
Replace Vitest with bun test across the whole suite. A preload compiles
components through Angular's JIT transform with inlined templates, sets
up jsdom, TestBed and zoneless change detection, and a small shim covers
the Vitest APIs bun:test lacks. Files run isolated, real-worker specs
spawn the running Bun, and line coverage from lcov is converted to the
existing HTML and Cobertura reports. The CI benchmark replaces its heap
cap with a container memory limit. Vitest, Jest, Nx and their Vite
configuration are removed.

Refs #291

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:40:42 +02:00
nessarandClaude Opus 5.5 558c70aa24 docs(adr): record Bun 1.4.3 verification results and decisions
Stable Bun 1.4.3 runs installs, the development server, standalone type
checks, the native test runner and a Bun.serve static server. Native
lint/fmt are canary-only, and Angular's server build needs Node loader
hooks Bun does not apply, so the spec now keeps ESLint/Prettier until a
stable release, builds Angular client-only behind a Bun static server,
converts line coverage from lcov, and replaces the heap cap with a
container memory limit.

Refs #289

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:49:17 +02:00
nessarandClaude Opus 5.5 c949480e49 docs(adr): propose Bun runtime and native tooling
Record the proposed decision to run project-owned JavaScript under Bun
with its native test runner, linter, formatter, type checker and HTTP
server, keeping Angular's standard build pipeline, plus the development
spec for the migration tickets.

Refs #289

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:01:42 +02:00
nessar 752c8b7a99 refactor(planning): consolidate calculation, projection and worker ownership
CI / policy (push) Successful in 11s
CI / validate (push) Successful in 57s
CI / build (push) Successful in 57s
CI / security (push) Successful in 1m1s
CI / test (push) Successful in 3m12s
CI / benchmark (push) Successful in 7m13s
CI / images (push) Successful in 1m10s
CI / required (push) Successful in 1s
close #274, close #275, close #276, close #277

See pull request #298
2026-10-17 11:53:19 +02:00
nessarandClaude Opus 5.5 cee67ce3df refactor(calendar): drop the unused projection horizon
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m2s
CI / build (push) Successful in 1m27s
CI / policy (pull_request) Successful in 11s
CI / security (push) Successful in 1m12s
CI / validate (pull_request) Successful in 1m21s
CI / benchmark (push) Successful in 4m10s
CI / test (push) Successful in 4m19s
CI / security (pull_request) Successful in 1m3s
CI / test (pull_request) Successful in 3m23s
CI / images (push) Successful in 1m34s
CI / build (pull_request) Successful in 34s
CI / required (push) Successful in 3s
CI / benchmark (pull_request) Successful in 7m13s
CI / images (pull_request) Successful in 1m56s
CI / required (pull_request) Successful in 2s
No caller read CalendarProjection.horizon, and with fixed months it no
longer described the listed months.

Refs #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:36:19 +02:00
nessarandClaude Opus 5.5 a472347a5e fix(calendar): keep declaration-entry months fixed while editing
Declaring a date at the edge of the planning period widened the
projection and inserted a disabled month above the grid. Declaration
entry now lists only the planning period's months, while selections
keep the connected-period widening.

Refs #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:29:31 +02:00
nessarandClaude Opus 5.5 feb651148e fix(planning): react only to calculations started by the mounted calendar
Re-mounting the calendar no longer records a duration sample or
announces the previous result, explanation or rejected explanation.
Starting a calculation during a cancellation announces loading and
keeps focus where it is. Only rejected explanation deliveries are
announced, as before. Replacement tests now replace a real plan.

Refs #275, #274

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:22:38 +02:00
nessarandClaude Opus 5.5 43677ff434 refactor(planning): drop the unreachable late worker message guard
release() already clears the worker handlers, so the released checks
inside them could never run. The lifetime test now states what it
checks: no handler is left for late messages.

Refs #277

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:22:38 +02:00
nessarandClaude Opus 5.5 502d561f6f refactor(calendar): encapsulate calendar projection and selection ranges
CalendarService.project returns the covered months together with the
projected selections, so month enumeration and selected-day rendering
share one projection horizon. SelectedDates is built once from the
complete range list and groups connected periods internally, exposing
read-only selection, boundary and day-type queries.

In declaration entry, months reached by adjacent declared or
non-working days are now shown, matching the projected selections.

Refs #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:18:33 +02:00
nessarandClaude Opus 5.5 5cb0b93339 refactor(planning): give planning calculations one state owner
PlanningCalculationService now builds the submitted request, owns the
pending and accepted calculation state, and delivers explanations for
the accepted calculation only. The duration estimator reads the same
request. The calendar and leave-balance summary consume the accepted
outcome instead of coordinating writable state on the input service.

Refs #275

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:11:37 +02:00
nessarandClaude Opus 5.5 b69b3c3690 refactor(planning): give planning workers one lifetime owner
Planning, counterfactual analysis and checkpoint explanation now share
one internal worker-job owner for creation, handler cleanup,
termination and supersession, and one request/response message
contract between the coordinator and the worker. Job-specific progress,
checkpoint recovery and completion rules stay explicit.

Refs #277

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:11:37 +02:00
nessarandClaude Opus 5.5 7fc2b33587 refactor(planning): remove the unused remaining-balance mirror
The summary and details already read the displayed plan's remaining
balances, so the writable mirror, its legacy conversion and the VacDay
classification/reason fields it alone populated are removed.

Refs #274

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:05:51 +02:00
nessar 40a11c245a perf(planning): incremental exact scoring for the loaded year
CI / policy (push) Successful in 6s
CI / validate (push) Successful in 57s
CI / build (push) Successful in 1m1s
CI / security (push) Successful in 54s
CI / test (push) Successful in 3m9s
CI / benchmark (push) Successful in 7m25s
CI / images (push) Successful in 1m10s
CI / required (push) Successful in 1s
close #280

See pull request #287
2026-10-17 02:18:27 +02:00
73 changed files with 2764 additions and 7752 deletions

No files matched your search

+7 -6
View File
@@ -3,16 +3,17 @@
All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to the following rules before declaring a task complete:
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:
```bash
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.
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:
```bash
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:
- **Linting check**: `bun run lint` (or use `bun run lint:fix` to resolve auto-fixable issues)
- **Formatting check**: `bun run format:check`
- **TypeScript Type check**: `bunx tsc --noEmit -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.
- **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`
- 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 `NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/benchmark/ --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`.
+48 -19
View File
@@ -9,6 +9,11 @@ on:
permissions:
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:
group: ci-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
@@ -18,6 +23,9 @@ jobs:
name: policy
runs-on: ubuntu-latest
container: node:24.21.0
defaults:
run:
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
outputs:
slug: ${{ steps.policy.outputs.slug }}
tag: ${{ steps.policy.outputs.tag }}
@@ -26,44 +34,53 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
- name: Install Bun
run: sh scripts/install-bun.sh
- 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
id: policy
run: node scripts/ci-policy.mjs
run: bun scripts/ci-policy.mjs
validate:
name: validate
runs-on: ubuntu-latest
container: node:24.21.0
defaults:
run:
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
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 run lint
- run: bun run format:check
- run: bunx prettier --check 'scripts/ci-*.mjs' '.gitea/workflows/*.yml'
- run: bunx tsc --noEmit -p tsconfig.app.json
- run: bunx tsc --noEmit -p tsconfig.spec.json
- run: bun run typecheck
test:
name: test
runs-on: ubuntu-latest
container: node:24.21.0
env:
NODE_OPTIONS: --max-old-space-size=2048
defaults:
run:
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
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 scripts/generate-langs.js
- 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
if: ${{ always() }}
run: bun scripts/coverage-reports.mjs
- name: Upload coverage
if: ${{ always() }}
uses: https://gitea.com/actions/upload-artifact@v3
with:
@@ -75,28 +92,36 @@ jobs:
benchmark:
name: benchmark
runs-on: ubuntu-latest
container: node:24.21.0
env:
NODE_OPTIONS: --max-old-space-size=2048
container:
image: node:24.21.0
options: --memory=3g
defaults:
run:
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
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 scripts/generate-langs.js
- name: Benchmark protocol with relative gate
run: timeout 600s bunx vitest run src/app/benchmark/ --maxWorkers=1
run: timeout 600s bun test src/app/benchmark --timeout=600000
build:
name: build
runs-on: ubuntu-latest
container: node:24.21.0
defaults:
run:
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
steps:
- uses: https://gitea.com/actions/checkout@v4
with:
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 run build -- --configuration production
- uses: https://gitea.com/actions/upload-artifact@v3
@@ -175,17 +200,21 @@ jobs:
run:
shell: sh
steps:
- name: Install checkout and policy runtimes
run: apk add --no-cache nodejs git
# Alpine's nodejs package is for the checkout action only (it shares /usr/bin
# with git); Bun needs libgcc and libstdc++ and the installer needs curl.
- name: Install checkout runtime and tools
run: apk add --no-cache nodejs git curl libgcc libstdc++
- uses: https://gitea.com/actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Install Bun
run: sh scripts/install-bun.sh
- name: Build branch images or promote a release
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: node scripts/ci-image.mjs
run: /opt/bun/bin/bun scripts/ci-image.mjs
required:
name: required
+3
View File
@@ -0,0 +1,3 @@
{
"npm.packageManager": "bun"
}
+10 -28
View File
@@ -1,48 +1,30 @@
# Stage 1: Build the Angular application
FROM oven/bun:alpine AS builder
# Stage 1: Build the Angular application and the Bun static server
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
# 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 . .
# 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
# 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
# Copy package files
COPY package.json bun.lock* ./
# Install only production dependencies
RUN bun install --production
# Copy built application from builder stage
# The server is a self-contained Bun bundle that serves the browser build, so
# the runtime has no dependencies to install.
COPY --from=builder /app/dist ./dist
# Set environment to production
ENV NODE_ENV=production
# Expose port (default Express port)
EXPOSE 4000
# Health check
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", "run", "dist/vac-optimizer/server/server.mjs"]
CMD ["bun", "dist/vac-optimizer/server/server.js"]
+33 -21
View File
@@ -17,7 +17,7 @@ The planner answers these questions:
```mermaid
flowchart LR
Host["Express production host<br/>static and client assets"] -. serves .-> UI
Host["Bun static server<br/>client assets"] -. serves .-> UI
subgraph Browser
UI["Angular standalone components"]
@@ -42,7 +42,7 @@ flowchart LR
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
@@ -162,9 +162,9 @@ Incomplete outcomes explain the retained plan and unfinished phases; conflicts i
- Web Workers for planner isolation
- `date-holidays` for the French public calendar
- Bun for dependency management and project scripts
- Vitest through the Angular test builder
- ESLint and Prettier
- Angular server build hosted by Express
- `bun test` with jsdom and Angular TestBed (JIT)
- ESLint and Prettier, run by Bun, and `bun check` for standalone type checking
- Client-only Angular build served by a small `Bun.serve` static server (`src/server.ts`)
- 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.
@@ -198,9 +198,10 @@ CONTEXT.md # Canonical domain language and rules
## 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
curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.3"
bun install --frozen-lockfile
bun start
```
@@ -209,22 +210,29 @@ Open <http://localhost:4200>. The development server reloads when source files c
### Useful commands
| Command | Purpose |
| ----------------------------------------- | ------------------------------------------------------------ |
| `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 lint` | Run ESLint. |
| `bun run format:check` | Check Prettier formatting. |
| `bunx tsc --noEmit -p tsconfig.app.json` | Type-check application code. |
| `bunx tsc --noEmit -p tsconfig.spec.json` | Type-check tests. |
| `bun run build` | Create the optimized browser and server bundles in `dist/`. |
| `bun run serve:ssr:vac-optimizer` | Serve a completed production build on port 4000. |
| Command | Purpose |
| ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `bun start` | Generate the i18n registry and start the development server. |
| `bun run watch` | Rebuild the development bundle when source files change. |
| `bun run test` | Run the unit, integration and benchmark suites once. |
| `bun test --isolate --path-ignore-patterns='**/benchmark/**'` | Run the functional suite only. |
| `bun test src/app/benchmark --timeout=600000` | Run the planning benchmark (no coverage). |
| `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 lint` | Run ESLint (`bun run lint:fix` applies its fixes). |
| `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
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 individual multi-year duration ceiling is established. CI requires the complete seven-scenario Node protocol to finish within 300 seconds; individual scenario timings remain diagnostic evidence.
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
@@ -233,15 +241,19 @@ docker build -t 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
[`.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 required benchmark job runs without coverage and keeps its five-minute protocol timeout, 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.
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.
@@ -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.
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
-9
View File
@@ -29,12 +29,6 @@
"styles": [
"src/styles.scss"
],
"server": "src/main.server.ts",
"outputMode": "server",
"ssr": {
"entry": "src/server.ts"
},
"prerender": false,
"allowedCommonJsDependencies": [
"deepmerge",
"moment-timezone",
@@ -85,9 +79,6 @@
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@analogjs/vitest-angular:test"
}
}
}
+49 -6519
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -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"]
+7
View File
@@ -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
View File
@@ -17,7 +17,7 @@ services:
'CMD',
'bun',
'-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
timeout: 10s
+1 -1
View File
@@ -17,7 +17,7 @@ services:
'CMD',
'bun',
'-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
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.
+16 -4
View File
@@ -68,6 +68,18 @@ the loaded year, while every completed scenario was within budget. The CI
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
handoff for #78 are recorded in [the #77 verification report](../trace-free-planning-verification.md).
@@ -136,12 +148,12 @@ bun build src/app/services/leave-planner.core.ts --target=node --external date-h
A failure prints the seed and the full request as JSON. To run only that seed:
```sh
FROZEN_ENGINE_SEED=<seed> bunx vitest run src/app/services/frozen-engine.spec.ts
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 it takes
about 32 seconds without coverage and 38 seconds with coverage
(`NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/services/frozen-engine.spec.ts --maxWorkers=1`).
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.
+304
View File
@@ -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.
+48 -12
View File
@@ -16,16 +16,16 @@ synchronization, and title/body edits. Manual dispatch runs validation without
publishing images. There is no schedule or deployment job in the preserved
configuration; the Compose files remain manual deployment options.
| Job | Behavior |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policy` | Tests the policy and publisher, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
| `validate` | Runs lint, formatting, and application/test type checks. |
| `test` | Runs functional coverage with one worker and a 2 GiB Node heap; uploads coverage for seven days even on failure. |
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage with one worker and a 2 GiB Node heap. 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. |
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. |
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag. Runs only for pushes and same-repository PRs. |
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. |
| Job | Behavior |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policy` | Tests the policy and publisher with Bun, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
| `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 `bun test --isolate` (line coverage only), converts it to HTML and Cobertura, and uploads coverage for seven days even on failure. |
| `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 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. The scanners are not project JavaScript, so this job installs no Bun. |
| `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, 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.
@@ -41,13 +41,13 @@ branch slug; stable promotions share one group and are not cancelled in progress
## Runners and permissions
Validation uses Node 24.21.0 and Bun 1.4.2 with the frozen lockfile. The image
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
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
the default registries returned a missing database blob and a download timeout.
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
`ubuntu-latest` and `ubuntu-24.04`. Repository runners, secrets, and variables
@@ -72,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
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 use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
+6 -7
View File
@@ -32,19 +32,18 @@ If Cancel is accepted just before a queued natural result reaches the facade, th
## 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
bunx vitest run src/app/services/leave-planner-cancellation.spec.ts
bunx vitest run src/app/services/leave-planner.worker.spec.ts
bunx vitest run src/app/components/calendar-year/calendar-year-planning.spec.ts
bunx tsc --noEmit -p tsconfig.app.json
bunx tsc --noEmit -p tsconfig.spec.json
bun test --isolate src/app/services/leave-planner-cancellation.spec.ts
bun test --isolate src/app/services/leave-planner.worker.spec.ts
bun test --isolate src/app/components/calendar-year/calendar-year-planning.spec.ts
bun run typecheck
```
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.
+2 -3
View File
@@ -73,9 +73,8 @@ range is explicitly indicative, not a confidence interval or a promise.
## What is timed
Manual requests capture `performance.now()` inside
`UserInputService.requestComputation`, before updating the calculation trigger.
The existing initial calculation falls back to the start of `startCalculation`.
Every request, manual or initial, captures `performance.now()` inside
`PlanningCalculationService.start`.
The endpoint is Angular's `afterNextRender` callback after the complete plan has
been projected into the calendar, its months and placements have been rendered,
and the loading overlay has been removed. This measures request scheduling,
+1 -2
View File
@@ -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. |
Production-source searches found no decision-trace, historical analysis,
acknowledgement or replay implementation. Angular hydration's `withEventReplay`
is unrelated to planning history. `PlanningArtifacts` carries actual explanation,
acknowledgement or replay implementation. `PlanningArtifacts` carries actual explanation,
final-state and planning-work timing data, rather than a compatibility shell.
## Existing deterministic fixtures
+13 -22
View File
@@ -1,17 +1,20 @@
{
"name": "vac-optimizer",
"version": "1.5.0",
"packageManager": "bun@1.4.3",
"scripts": {
"ng": "ng",
"start": "bun scripts/generate-langs.js && ng serve",
"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",
"test": "bun scripts/generate-langs.js && ng test",
"serve:ssr:vac-optimizer": "bun dist/vac-optimizer/server/server.mjs",
"test": "bun scripts/generate-langs.js && bun test --isolate",
"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: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:check": "bun scripts/generate-langs.js && prettier --check \"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}\" \"scripts/ci-*.mjs\" \".gitea/workflows/*.yml\""
},
"prettier": {
"printWidth": 100,
@@ -36,43 +39,31 @@
"@angular/material": "^22.0.0",
"@angular/platform-browser": "22.2.1",
"@angular/platform-browser-dynamic": "22.2.1",
"@angular/platform-server": "22.2.1",
"@angular/router": "22.2.1",
"@angular/ssr": "22.2.2",
"date-holidays": "^3.30.2",
"dayjs": "^1.11.21",
"express": "^5.2.1",
"rxjs": "~7.8.2",
"tslib": "^2.8.1"
},
"devDependencies": {
"@analogjs/platform": "^2.7.5",
"@analogjs/vite-plugin-angular": "^2.7.5",
"@analogjs/vitest-angular": "^2.7.5",
"@angular/build": "22.2.2",
"@angular/cli": "22.2.2",
"@angular/compiler-cli": "22.2.1",
"@eslint/js": "^10.0.1",
"@nx/devkit": "^23.0.0",
"@nx/vite": "^23.0.0",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/bun": "1.4.3",
"@types/jsdom": "^21.1.7",
"@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-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"fast-check": "4.10.2",
"jest": "^30.4.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",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.60.0",
"vite": "^8.0.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^5.0.0"
"typescript-eslint": "^8.60.0"
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ try {
const docker = join(bin, 'docker');
writeFileSync(
docker,
`#!/usr/bin/env node
`#!/usr/bin/env bun
import { appendFileSync, readFileSync } from 'node:fs';
const args = process.argv.slice(2);
const input = args[0] === 'login' ? readFileSync(0, 'utf8') : '';
+55
View File
@@ -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 ',
),
);
+41
View File
@@ -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
+1 -1
View File
@@ -7,7 +7,7 @@ import { MatOptionHarness } from '@angular/material/core/testing';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatSelectHarness } from '@angular/material/select/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 { CalendarService } from './services/calendar-service';
import { calculatePlanningInternal } from './services/leave-planner.core';
-10
View File
@@ -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);
-2
View File
@@ -6,13 +6,11 @@ import {
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(routes),
provideClientHydration(withEventReplay()),
],
};
-8
View File
@@ -1,8 +0,0 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '**',
renderMode: RenderMode.Client,
},
];
@@ -1,4 +1,4 @@
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 {
@@ -78,7 +78,7 @@ describe('leave planner benchmark protocol', () => {
await calculatePlanningAsyncInternal({ ...request, calculationDate: '2024-01-01' });
calibrationMs = Math.min(calibrationMs, performance.now() - start);
}
// Direct writes skip Vitest's per-call "stdout |" headers and appear immediately.
// 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` +
@@ -184,14 +184,10 @@ export class CalendarMonth implements OnInit {
getDayTooltip(day: Date): string | null {
const previewed = this.declarationPreview?.dates.has(this.getDateKey(day));
const tooltips: string[] = this.selectedDates.datesSelected
.filter(
(dateSelected) =>
dateSelected.isSelected(day) &&
dateSelected.type &&
(!previewed || !['cp', 'rtt', 'other'].includes(dateSelected.type)),
)
.map((dateSelected) => this.translationService.getDayTypeLabel(dateSelected.type!));
const tooltips: string[] = this.selectedDates
.dayTypes(day)
.filter((type) => !previewed || !['cp', 'rtt', 'other'].includes(type))
.map((type) => this.translationService.getDayTypeLabel(type));
const declaredType = this.getDeclaredType(this.getDateKey(day));
if (declaredType)
tooltips.push(
@@ -1,7 +1,7 @@
import { createOptimizationPresetRules } from '../../services/rule-service';
import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { vi } from '../../../testing/vitest-compat';
import { CalendarYear } from './calendar-year';
import { UserInput } from '../user-input/user-input';
import {
@@ -9,6 +9,7 @@ import {
DayType,
SelectedDate,
SelectedDates,
calendarProjectionHorizon,
SelectableDayType,
} from '../../services/calendar-service';
import {
@@ -30,6 +31,7 @@ import { CalendarSettingsService } from '../../services/calendar-settings-servic
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { DateRange } from '@angular/material/datepicker';
const invalid: InvalidInputOutcome = {
@@ -124,17 +126,39 @@ const explanation: PlanningExplanation = {
})),
};
function selectedDatesForPlan(value: PlanOutcome, includeHoliday = false): SelectedDates {
const selected = new SelectedDates();
function projectionFor(
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) {
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) {
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 {
@@ -208,11 +232,9 @@ function calendarFor(outcome: PlanningOutcome, stableExplanation?: PlanningExpla
return {
calculation,
calendar: {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
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();
const before = {
balances: structuredClone(input.vacationNumberSignal()),
remaining: structuredClone(input.remainingVacationDaysSignal()),
displayed: structuredClone(
TestBed.inject(PlanningCalculationService).displayedPlan(),
),
declarations: structuredClone(input.declaredDaysSignal()),
rules: structuredClone(rules.rules),
};
@@ -506,9 +530,11 @@ describe('CalendarYear planning outcome boundary', () => {
];
expect(statistics()).toEqual(expectedStatistics);
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.remainingVacationDaysSignal()).toEqual(before.remaining);
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toEqual(
before.displayed,
);
expect(input.declaredDaysSignal()).toEqual(before.declarations);
expect(rules.rules).toEqual(before.rules);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
@@ -525,7 +551,7 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(statistics()).toEqual(expectedStatistics);
expect(input.planningOutcomeSignal()).toBe(outcome);
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
},
);
@@ -1047,7 +1073,6 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const input = TestBed.inject(UserInputService);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
@@ -1056,10 +1081,10 @@ describe('CalendarYear planning outcome boundary', () => {
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
input.requestComputation();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
input.requestComputation();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
const otherControl = document.createElement('button');
otherControl.textContent = 'Another control';
@@ -1071,7 +1096,7 @@ describe('CalendarYear planning outcome boundary', () => {
oldOutcome.resolve(plan);
await fixture.whenStable();
fixture.detectChanges();
expect(input.planningOutcomeSignal()).toBe(replacement);
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(replacement);
const opener = root.querySelector<HTMLButtonElement>('.statistics-button');
if (replacement.kind === 'Plan') {
expect(opener).not.toBeNull();
@@ -1747,7 +1772,7 @@ describe('CalendarYear planning outcome boundary', () => {
},
]);
const duration = TestBed.inject(PlanningDurationService);
const submitted = duration.request()!;
const submitted = TestBed.inject(PlanningCalculationService).request()!;
calendar.createPlanningCalculation.mockReturnValue({
...calculation,
outcome: pending.promise,
@@ -1849,11 +1874,13 @@ describe('CalendarYear planning outcome boundary', () => {
}).compileComponents();
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-01', '2024-12-31');
const input = TestBed.inject(UserInputService);
const duration = TestBed.inject(PlanningDurationService);
calendar.createPlanningCalculation.mockImplementation(() => ({
...calculation,
snapshot: { ...calculation.snapshot, ...duration.request()! },
snapshot: {
...calculation.snapshot,
...TestBed.inject(PlanningCalculationService).request()!,
},
outcome:
calendar.createPlanningCalculation.mock.calls.length === 1
? old.promise
@@ -1863,7 +1890,7 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
now = 11_000;
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.
fixture.detectChanges();
const initial = duration.estimate();
@@ -1885,7 +1912,10 @@ describe('CalendarYear planning outcome boundary', () => {
const revisitedOutcome = deferred<PlanningOutcome>();
calendar.createPlanningCalculation.mockReturnValueOnce({
...calculation,
snapshot: { ...calculation.snapshot, ...duration.request()! },
snapshot: {
...calculation.snapshot,
...TestBed.inject(PlanningCalculationService).request()!,
},
outcome: revisitedOutcome.promise,
});
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.querySelector('.loading-overlay')).toBeNull();
expect(document.activeElement).toBe(root.querySelector('#planning-results-title'));
TestBed.inject(UserInputService).requestComputation();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
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 () => {
await TestBed.configureTestingModule({
imports: [CalendarYear],
@@ -2051,7 +2221,7 @@ describe('CalendarYear planning outcome boundary', () => {
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
settings.setPlanningDates('2021-05-14', '2021-05-14');
TestBed.inject(UserInputService).requestComputation();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
oldOutcome.resolve(plan);
@@ -2117,7 +2287,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(calculate).toHaveBeenCalledTimes(1);
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
input.requestComputation();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
@@ -2156,6 +2326,9 @@ describe('CalendarYear planning outcome boundary', () => {
['PlanningIncomplete without incumbent', incomplete],
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
const { calendar } = calendarFor(outcome);
calendar.createPlanningCalculation
.mockReturnValueOnce(calculationFor(plan, explanation))
.mockReturnValueOnce(calculationFor(outcome));
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -2163,20 +2336,27 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).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);
fixture.detectChanges();
await fixture.whenStable();
const service = TestBed.inject(PlanningCalculationService);
const settle = async () => {
fixture.detectChanges();
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 () => {
@@ -2188,14 +2368,14 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const userInput = TestBed.inject(UserInputService);
userInput.remainingVacationDaysSignal.set([]);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
expect(
TestBed.inject(PlanningCalculationService).displayedPlan()?.remainingBalances,
).toEqual(plan.remainingBalances);
const summaryValues = Array.from(
fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf<HTMLElement>,
).map((element) => element.textContent?.trim());
@@ -2340,9 +2520,7 @@ describe('CalendarYear planning outcome boundary', () => {
placeType: 'cp',
},
];
calendar.selectedDatesForPlan.mockImplementation((value) =>
selectedDatesForPlan(value, true),
);
calendar.project.mockImplementation((value) => projectionFor(value, true));
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -2547,7 +2725,7 @@ describe('CalendarYear planning outcome boundary', () => {
);
expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1);
TestBed.inject(UserInputService).requestComputation();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
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 () => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
createPlanningCalculation: vi.fn(() => ({
...calculationFor(plan),
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({
imports: [CalendarYear],
@@ -2968,15 +3144,6 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).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);
fixture.detectChanges();
await fixture.whenStable();
@@ -2988,6 +3155,6 @@ describe('CalendarYear planning outcome boundary', () => {
if (outcome?.kind === 'PlanningIncomplete')
expect(outcome.reasons[0].code).toBe('planner-error');
expect(fixture.componentInstance['isLoading']()).toBe(false);
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined();
});
});
+106 -267
View File
@@ -33,26 +33,17 @@ import {
import { CalendarMonth, DeclarationPreview } from '../calendar-month/calendar-month';
import { addPlanningDays } from '../../services/planning-date';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import {
CalendarService,
calendarProjectionHorizon,
SelectedDates,
SelectableDayType,
VacDay,
} from '../../services/calendar-service';
import { CalendarService, SelectedDates } from '../../services/calendar-service';
import { TranslationService } from '../../services/translation-service';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import {
CounterfactualAnalysis,
CounterfactualSelection,
LeaveType,
DeclaredNonWorkingDay,
PlanExplanation,
PlanOutcome,
PlanningCalculationContext,
PlanningExplanation,
PlanningObjective,
PlanningObjectiveVector,
PlanningOutcome,
@@ -89,6 +80,7 @@ export class CalendarYear {
private readonly calendarService = inject(CalendarService);
private readonly settings = inject(CalendarSettingsService);
private readonly duration = inject(PlanningDurationService);
private readonly calculations = inject(PlanningCalculationService);
private readonly injector = inject(Injector);
private readonly dialog = inject(MatDialog);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
@@ -101,13 +93,13 @@ export class CalendarYear {
private statisticsDialog: MatDialogRef<unknown> | null = null;
protected readonly translationService = inject(TranslationService);
protected readonly isLoading = signal(false);
protected readonly isCancelling = signal(false);
protected readonly isLoading = this.calculations.isCalculating;
protected readonly isCancelling = this.calculations.isCancelling;
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
protected readonly loadingProgress = signal(0);
protected readonly planningOutcome = this.userInput.planningOutcomeSignal;
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
protected readonly explanationError = signal<PlanningReason | null>(null);
protected readonly loadingProgress = this.calculations.progress;
protected readonly planningOutcome = this.calculations.outcome;
protected readonly planningExplanation = this.calculations.explanation;
protected readonly explanationError = this.calculations.explanationError;
protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(false);
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
@@ -118,8 +110,7 @@ export class CalendarYear {
protected readonly invalidComparisonDate = signal(false);
protected readonly comparisonDateInput = signal<Date | null>(null);
protected readonly liveMessage = signal('');
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates());
protected readonly planningHorizon = this.calculations.displayedHorizon;
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
private readonly monthGrid = viewChild<ElementRef<HTMLElement>>('monthGrid');
private readonly declarationDrag = signal<{
@@ -144,21 +135,21 @@ export class CalendarYear {
}
return { dates, type: this.userInput.declaredTypeSignal() };
});
private readonly resultDeclaredDays = signal<readonly DeclaredNonWorkingDay[]>([]);
protected readonly calendarHorizon = computed(() =>
this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(),
);
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
const days = this.declarationEntry()
? this.userInput.declaredDaysSignal()
: (this.displayedPlan()?.declaredDays ?? this.resultDeclaredDays());
: (this.calculations.accepted()?.declaredDays ?? []);
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
});
private calculationVersion = 0;
private counterfactualVersion = 0;
private activeCalculation: PlanningCalculationContext | null = null;
private runningCalculation: PlanningCalculationContext | null = null;
private wasCancelling = false;
private readonly initialAccepted = this.calculations.accepted();
private readonly initialExplanation = this.calculations.explanation();
private readonly initialExplanationError = this.calculations.explanationError();
private displayDateLocale = '';
private displayDateFormatter?: Intl.DateTimeFormat;
@@ -166,7 +157,7 @@ export class CalendarYear {
return this.translationService.tr;
}
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
protected readonly displayedPlan = this.calculations.displayedPlan;
protected readonly weekendConnectedHolidays = computed(() => {
const isWeekend = (date: string) => {
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
@@ -277,20 +268,22 @@ export class CalendarYear {
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
);
protected readonly calendarSelectedDates = computed(() => {
if (this.declarationEntry()) {
const horizon = this.calendarHorizon();
return horizon
? this.calendarService.selectedDatesForPlan({
placements: [],
horizon,
declaredDays: this.userInput.declaredDaysSignal(),
})
: new SelectedDates();
}
return this.selectedDates();
protected readonly calendarProjection = computed(() => {
const horizon = this.calendarHorizon();
if (!horizon) return undefined;
if (this.declarationEntry())
return this.calendarService.project(
{ placements: [], horizon, declaredDays: this.userInput.declaredDaysSignal() },
{ fixedMonths: true },
);
const plan = this.displayedPlan();
return this.calendarService.project(
plan ? { ...plan, horizon: plan.horizon ?? horizon } : { placements: [], horizon },
);
});
protected readonly calendarSelectedDates = computed(
() => this.calendarProjection()?.selections ?? new SelectedDates(),
);
protected readonly currentPlacementDate = computed(() => {
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
@@ -300,26 +293,7 @@ export class CalendarYear {
this.planDetailsOpen() ? this.selectedExplanation()?.timeOffPeriod : undefined,
);
protected readonly months = computed(() => {
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;
});
protected readonly months = computed(() => this.calendarProjection()?.months ?? []);
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
@@ -342,188 +316,75 @@ export class CalendarYear {
});
});
effect(() => {
this.userInput.computeTriggerSignal();
untracked(() => this.startCalculation());
if (this.calculations.pendingId() !== null)
untracked(() => this.liveMessage.set(this.tr().loading));
});
}
private startCalculation(): void {
const requestedAt = this.userInput.computationRequestedAtMs() ?? performance.now();
this.userInput.computationRequestedAtMs.set(null);
const request = this.duration.request();
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));
},
effect(() => {
const pending = this.calculations.pendingId() !== null;
const cancelling = this.isCancelling();
untracked(() => {
if (this.wasCancelling && !pending) this.resultsTitle()?.nativeElement.focus();
this.wasCancelling = cancelling;
});
} 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);
})
.finally(() => {
if (version !== this.calculationVersion) return;
this.runningCalculation = null;
this.loadingProgress.set(100);
this.isLoading.set(false);
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
this.isCancelling.set(false);
if (completed && !this.destroyRef.destroyed)
});
effect(() => {
const accepted = this.calculations.accepted();
if (!accepted || accepted === this.initialAccepted) return;
untracked(() => {
this.closeStatistics();
this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(false);
this.clearCounterfactual();
this.liveMessage.set(this.outcomeLabel(accepted.outcome));
if (accepted.successful && !this.destroyRef.destroyed)
afterNextRender(
() => {
if (version === this.calculationVersion && !this.destroyRef.destroyed)
if (
this.calculations.accepted() === accepted &&
!this.destroyRef.destroyed &&
accepted.snapshot
)
this.duration.recordCompletion(
calculation.snapshot,
performance.now() - requestedAt,
accepted.snapshot,
performance.now() - accepted.requestedAt,
);
},
{ 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 {
if (!this.isLoading() || this.isCancelling() || !this.runningCalculation) return;
this.isCancelling.set(true);
if (!this.isLoading() || this.isCancelling()) return;
this.calculations.cancel();
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 {
@@ -829,7 +690,7 @@ export class CalendarYear {
selection: CounterfactualSelection,
openDialog = false,
): Promise<void> {
const calculation = this.activeCalculation;
const calculation = this.calculations.accepted()?.calculation;
if (!calculation) return;
this.counterfactualSelection = selection;
const version = ++this.counterfactualVersion;
@@ -844,7 +705,10 @@ export class CalendarYear {
);
try {
const result = await calculation.analyzeCounterfactual(selection);
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
if (
version !== this.counterfactualVersion ||
calculation !== this.calculations.accepted()?.calculation
)
return;
if (result.kind === 'Counterfactual') {
this.counterfactual.set(result);
@@ -855,7 +719,10 @@ export class CalendarYear {
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
} catch (error) {
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
if (
version !== this.counterfactualVersion ||
calculation !== this.calculations.accepted()?.calculation
)
return;
const message = error instanceof Error ? error.message : String(error);
this.counterfactualError.set(message);
@@ -920,10 +787,10 @@ export class CalendarYear {
}
protected ruleLabel(ruleId: string): string {
const rule = this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === ruleId,
);
return rule?.name ?? this.activeCalculation?.snapshot.labels[ruleId] ?? ruleId;
const rule = this.calculations
.snapshot()
?.rules.find((candidate) => candidate.id === ruleId);
return rule?.name ?? this.calculations.snapshot()?.labels[ruleId] ?? ruleId;
}
protected balanceLabel(explanation: PlacementExplanation): string {
@@ -955,9 +822,9 @@ export class CalendarYear {
protected objectiveCopy(id: string) {
if (id.startsWith('optimization:')) {
const ruleId = id.slice('optimization:'.length);
const rule = this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === ruleId,
);
const rule = this.calculations
.snapshot()
?.rules.find((candidate) => candidate.id === ruleId);
const copy = this.tr().planning.explanation.objectives['optimization'];
return {
...copy,
@@ -975,9 +842,9 @@ export class CalendarYear {
const copy = this.tr().planning.explanation.objectives[key];
const rule =
key === 'preference'
? this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === id.slice('preference:'.length),
)
? this.calculations
.snapshot()
?.rules.find((candidate) => candidate.id === id.slice('preference:'.length))
: undefined;
return (
copy && {
@@ -1062,32 +929,4 @@ export class CalendarYear {
: this.tr().planning.cancelled;
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 { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { vi } from 'vitest';
import { vi } from '../../../testing/vitest-compat';
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
import { MatExpansionPanel } from '@angular/material/expansion';
@@ -2,7 +2,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatExpansionPanel } from '@angular/material/expansion';
import { vi } from 'vitest';
import { vi } from '../../../testing/vitest-compat';
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
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 { MatSelectHarness } from '@angular/material/select/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 { 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 { 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';
const presets = createOptimizationPresetRules();
@@ -21,6 +28,8 @@ describe('UserInput', () => {
let component: UserInput;
let fixture: ComponentFixture<UserInput>;
let userInputService: UserInputService;
let createCalculation: MockInstance<CalendarService['createPlanningCalculation']>;
let calculations: PlanningCalculationService;
beforeEach(async () => {
localStorage.clear();
@@ -45,6 +54,11 @@ describe('UserInput', () => {
fixture = TestBed.createComponent(UserInput);
component = fixture.componentInstance;
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();
await fixture.whenStable();
}
@@ -91,16 +105,25 @@ describe('UserInput', () => {
async function calculate(request: PlanningRequest): Promise<void> {
const browserWindow = globalThis.window;
let calculation: ReturnType<typeof createPlanningCalculation>;
let calculation: PlanningCalculationContext;
try {
vi.stubGlobal('window', undefined);
calculation = createPlanningCalculation(request);
} finally {
vi.stubGlobal('window', browserWindow);
}
const outcome = await calculation.outcome;
userInputService.planningSnapshotSignal.set(calculation.snapshot);
userInputService.planningOutcomeSignal.set(outcome);
await show(calculation);
}
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();
}
@@ -132,6 +155,33 @@ describe('UserInput', () => {
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 () => {
TestBed.inject(TranslationService).setLanguage('fr');
await calculate({
@@ -155,19 +205,18 @@ describe('UserInput', () => {
expect(card.textContent).toContain('Aucune action nécessaire');
expect(card.textContent).not.toContain('Disponible seulement');
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.textContent).not.toContain('trace');
expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
expect(JSON.stringify(calculations.outcome())).toBe(outcome);
const plan = userInputService.displayedPlanSignal()!;
userInputService.planningOutcomeSignal.set({
const plan = calculations.displayedPlan()!;
await show(calculations.accepted()!.calculation!, {
kind: 'PlanningIncomplete',
incumbent: plan,
completedStages: [],
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).not.toContain('Conservé pour plus tard');
expect(root.querySelector('.remaining-history')).toBeNull();
@@ -185,7 +234,7 @@ describe('UserInput', () => {
TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03');
fixture.detectChanges();
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 () => {
@@ -218,7 +267,7 @@ describe('UserInput', () => {
start: '2024-02-27',
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 () => {
@@ -233,11 +282,11 @@ describe('UserInput', () => {
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
calculate.click();
component.triggerComputation();
expect(userInputService.computeTriggerSignal()).toBe(0);
expect(createCalculation).not.toHaveBeenCalled();
editInput('input[name="planningStart"]', '29/02/2024');
expect(calculate.disabled).toBe(false);
calculate.click();
expect(userInputService.computeTriggerSignal()).toBe(1);
expect(createCalculation).toHaveBeenCalledTimes(1);
});
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-describedby')).not.toContain('planning-dates-error');
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', () => {
@@ -518,7 +567,7 @@ describe('UserInput', () => {
fixture.nativeElement.querySelector('.calculate-button').click();
await fixture.whenStable();
expect(userInputService.computeTriggerSignal()).toBe(1);
expect(createCalculation).toHaveBeenCalledTimes(1);
expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
+9 -11
View File
@@ -15,6 +15,7 @@ import { VacDay } from '../../services/calendar-service';
import type { LeaveType } from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
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 calendarSettings = inject(CalendarSettingsService);
protected readonly duration = inject(PlanningDurationService);
private readonly calculations = inject(PlanningCalculationService);
protected readonly translationService = inject(TranslationService);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
protected readonly planningDates = computed(() => {
@@ -72,22 +74,21 @@ export class UserInput {
}
protected readonly remainingDays = computed(() =>
(this.userInputService.displayedPlanSignal()?.remainingBalances ?? []).reduce(
(this.calculations.displayedPlan()?.remainingBalances ?? []).reduce(
(total, balance) => total + balance.quantity,
0,
),
);
protected readonly hasAtRiskDays = computed(() =>
this.userInputService
.displayedPlanSignal()
this.calculations
.displayedPlan()
?.remainingBalances.some((balance) => balance.classification !== 'deferred'),
);
protected remainingDaysDetails = computed(() => {
const plan = this.userInputService.displayedPlanSignal();
const plan = this.calculations.displayedPlan();
if (!plan) return [];
const incomplete =
this.userInputService.planningOutcomeSignal()?.kind === 'PlanningIncomplete';
const incomplete = this.calculations.outcome()?.kind === 'PlanningIncomplete';
const copy = this.tr().userInput;
return plan.remainingBalances.map((balance) => {
const code =
@@ -147,10 +148,7 @@ export class UserInput {
}
private ruleName(id: string): string {
return (
this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id)
?.name ?? id
);
return this.calculations.snapshot()?.rules.find((rule) => rule.id === id)?.name ?? id;
}
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
@@ -178,6 +176,6 @@ export class UserInput {
public triggerComputation() {
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 { promisify } from 'node:util';
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 type { OptimizationPlanningRule } from './leave-planner.core';
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 }));
`,
);
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
await promisify(execFile)(process.execPath, [
'build',
entry,
'--target=node',
'--outfile',
bundle,
]);
});
beforeEach(() => {
+134 -111
View File
@@ -46,21 +46,18 @@ describe('CalendarService', () => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'Europe/Paris';
try {
const selected = service.selectedDatesForPlan({
const { selections: selected } = service.project({
horizon: { start: date, end },
placements: [
{ date, balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
],
});
const period = selected.datesSelected.find(
(item) =>
item.type === null &&
item.range.start?.getTime() ===
new Date(`${periodStart}T00:00:00`).getTime() &&
item.range.end?.getTime() === new Date(`${date}T00:00:00`).getTime(),
);
expect(period).toBeDefined();
expect(period?.getLengthInDays()).toBe(3);
const day = (key: string) => new Date(`${key}T00:00:00`);
expect(selected.isStart(day(periodStart))).toBe(true);
expect(selected.isEnd(day(periodStart))).toBe(false);
expect(selected.isStart(day(date))).toBe(false);
expect(selected.isEnd(day(date))).toBe(true);
expect(selected.isSelected(day(end))).toBe(false);
} finally {
if (previousTimeZone === undefined) delete process.env['TZ'];
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', () => {
const horizon = { start: '2024-04-02', end: '2024-04-03' };
const selected = service.selectedDatesForPlan({
const { selections: selected } = service.project({
horizon,
placements: [
{ 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}`);
const selected = service.selectedDatesForPlan(outcome);
const selected = service.project(outcome).selections;
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-06-01'))).toContain(DayType.WEEKEND);
expect(selected.getClasses(date('2024-06-03'))).toContain(DayType.CP);
expect(selected.isSelected(date('2024-06-05'))).toBe(false);
expect(
selected.datesSelected
.find(
(selection) =>
selection.type === null &&
selection.range.start?.getTime() === date('2024-05-31').getTime(),
)
?.getLengthInDays(),
).toBe(4);
expect(selected.isStart(date('2024-05-31'))).toBe(true);
expect(selected.isEnd(date('2024-06-03'))).toBe(true);
expect(selected.isEnd(date('2024-06-02'))).toBe(false);
expect(outcome.placements).toEqual([]);
expect(outcome.horizon).toEqual(horizon);
});
@@ -134,7 +125,7 @@ describe('CalendarService', () => {
];
const before = { horizon, declaredDays, placements: [] };
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 = {
...before,
placements: [
@@ -147,7 +138,7 @@ describe('CalendarService', () => {
},
],
};
const selected = service.selectedDatesForPlan(after);
const selected = service.project(after).selections;
expect(calendarProjectionHorizon(after)).toEqual({
start: horizon.start,
end: '2024-08-02',
@@ -201,52 +192,60 @@ describe('CalendarService', () => {
});
});
describe('getSelectedDatesForYear', () => {
it('should return selected dates for the year', () => {
const selectedDates = service.getSelectedDatesForYear();
expect(selectedDates).toBeDefined();
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
describe('project', () => {
const project = (horizon: { start: string; end: string }) =>
service.project({ placements: [], horizon });
const day = (key: string) => new Date(`${key}T00:00:00`);
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', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasWeekend = selectedDates._datesSelected.some(
(date) => date.type === DayType.WEEKEND,
);
expect(hasWeekend).toBe(true);
it('lists the covered months, including partial boundary months and year changes', () => {
expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([
{ monthIndex: 10, year: 2024 },
{ monthIndex: 11, year: 2024 },
{ monthIndex: 0, year: 2025 },
{ monthIndex: 1, year: 2025 },
]);
});
it('should include holidays', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasHoliday = selectedDates._datesSelected.some(
(date) => date.type === DayType.CLOSED_DAY,
);
expect(hasHoliday).toBe(true);
});
it('should include today', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasToday = selectedDates._datesSelected.some(
(date) => date.type === DayType.TODAY,
);
expect(hasToday).toBe(true);
it('keeps fixed months when an edge declaration widens the projection', () => {
const plan = {
placements: [],
horizon: { start: '2024-07-01', end: '2025-01-04' },
declaredDays: [{ date: '2024-07-01', type: 'cp' as const }],
};
const months = (projection: ReturnType<typeof service.project>) =>
projection.months.map(({ monthIndex, year }) => `${year}-${monthIndex}`);
expect(months(service.project(plan))[0]).toBe('2024-5');
const fixed = service.project(plan, { fixedMonths: true });
expect(months(fixed)).toEqual([
'2024-6',
'2024-7',
'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', () => {
const selectedDates = service.getSelectedDatesForHorizon({
start: '2024-01-15',
end: '2025-01-15',
});
expect(selectedDates.getClasses(new Date('2025-01-04T00:00:00'))).toContain(
DayType.WEEKEND,
);
const { selections } = project({ start: '2024-01-15', end: '2025-01-15' });
expect(selections.dayTypes(day('2025-01-04'))).toContain(DayType.WEEKEND);
});
it('projects holidays in every covered year and in both partial boundary months', () => {
const selected = service.getSelectedDatesForHorizon({
start: '2023-12-26',
end: '2027-01-03',
});
const { selections } = project({ start: '2023-12-26', end: '2027-01-03' });
for (const date of [
'2023-12-25',
'2024-04-01',
@@ -254,20 +253,34 @@ describe('CalendarService', () => {
'2026-04-06',
'2027-01-01',
]) {
expect(selected.getClasses(new Date(`${date}T00:00:00`))).toContain(
DayType.CLOSED_DAY,
);
expect(selections.dayTypes(day(date))).toContain(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('should add weekend for Saturday', () => {
const selectedDates = new SelectedDates();
const saturday = new Date(2024, 0, 6); // A Saturday
service.setWeekendSelection(saturday, selectedDates);
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
it('does not mutate the submitted plan or its horizon', () => {
const plan = {
horizon: { start: '2024-04-02', end: '2024-04-03' },
placements: [
{
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);
});
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', () => {
const startDate = new Date(2024, 0, 1);
const endDate = new Date(2024, 0, 3);
@@ -320,47 +326,64 @@ describe('SelectedDate', () => {
});
describe('SelectedDates', () => {
it('should push selected dates', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
expect(selectedDates._datesSelected.length).toBe(1);
});
const range = (type: DayType, start: Date, end = start) =>
new SelectedDate(type, new DateRange<Date>(start, end));
it('should check if date is selected', () => {
const selectedDates = new SelectedDates();
it('selects covered dates and reports their day types', () => {
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.dayTypes(date)).toEqual([DayType.CP]);
expect(selectedDates.getClasses(date)).toContain(DayType.CP);
});
it('should group adjacent dates', () => {
const selectedDates = new SelectedDates();
const date1 = new Date(2024, 0, 1);
const date2 = new Date(2024, 0, 2);
selectedDates._datesSelected.push(
new SelectedDate(DayType.CP, new DateRange<Date>(date1, date1)),
);
selectedDates._datesSelected.push(
new SelectedDate(DayType.RTT, new DateRange<Date>(date2, date2)),
);
selectedDates.update();
expect(selectedDates.datesSelected.length).toBeGreaterThan(2);
it('treats adjacent ranges as one period with shared boundaries', () => {
const first = new Date(2024, 0, 1);
const second = new Date(2024, 0, 2);
const third = new Date(2024, 0, 3);
const selectedDates = new SelectedDates([
range(DayType.CP, first),
range(DayType.RTT, second),
range(DayType.OTHER, third),
]);
expect(selectedDates.isStart(first)).toBe(true);
expect(selectedDates.isEnd(first)).toBe(false);
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', () => {
const selectedDates = new SelectedDates();
it('keeps separated ranges as separate periods', () => {
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);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const classes = selectedDates.getClasses(date);
expect(classes).toContain(DayType.CP);
});
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();
const selectedDates = new SelectedDates([range(DayType.TODAY, date)]);
expect(selectedDates.isSelected(date)).toBe(false);
expect(selectedDates.dayTypes(date)).toEqual([]);
expect(selectedDates.getClasses(date)).toContain(DayType.TODAY);
});
});
+105 -231
View File
@@ -10,9 +10,7 @@ import {
planLeave as planLeaveOperation,
LeaveType,
PlanOutcome,
PlanningReason,
PlanningStateSnapshot,
RemainingClassification,
} from './leave-planner.service';
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
@@ -54,6 +52,11 @@ export function calendarProjectionHorizon(plan: CalendarPlan) {
return horizon;
}
export interface CalendarProjection {
months: { monthIndex: number; year: number }[];
selections: SelectedDates;
}
@Injectable({
providedIn: 'root',
})
@@ -68,112 +71,82 @@ export class CalendarService {
return { start, end: addPlanningYears(start, 1) };
}
selectedDatesForPlan(plan: CalendarPlan): SelectedDates {
const projectionHorizon = calendarProjectionHorizon(plan);
const selected = projectionHorizon
? this.getSelectedDatesForHorizon(projectionHorizon)
: this.getSelectedDatesForYear();
for (const placement of [
...plan.placements,
...(plan.declaredDays ?? []).filter(
(day) =>
!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`);
/** `fixedMonths` lists only the plan horizon's months, so editing declarations never adds a month. */
project(
plan: CalendarPlan & { horizon: PlanningStateSnapshot['horizon'] },
{ fixedMonths = false } = {},
): CalendarProjection {
const horizon = calendarProjectionHorizon(plan)!;
const firstMonth = plan.horizon.start.slice(0, 7);
const lastMonth = addPlanningDays(plan.horizon.end, -1).slice(0, 7);
const first = new Date(`${horizon.start}T00:00:00`);
const last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
const holidays = this.getHolidays(start.getFullYear(), last.getFullYear());
const monthInHorizon: Year = [];
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 holidays = new Set(
this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()),
);
const {
date: todayDate,
month: todayMonth,
year: todayYear,
} = this.dateCache.getTodayComponents();
// Create a Set of holiday timestamps for O(1) lookup instead of O(n)
const holidayTimestamps = new Set(holidays.map((h) => h.getTime()));
for (const weeksInAMonth of monthInHorizon) {
for (const daysInAWeek of weeksInAMonth) {
for (const day of daysInAWeek) {
if (day) {
this.setWeekendSelection(day, selectedDates);
if (holidayTimestamps.has(day.getTime())) {
selectedDates._datesSelected.push(
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
);
}
if (
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getFullYear() === todayYear
) {
selectedDates._datesSelected.push(
new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)),
);
}
const months: CalendarProjection['months'] = [];
const ranges: SelectedDate[] = [];
const cursor = new Date(first);
cursor.setDate(1);
while (cursor <= last) {
const month = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`;
if (!fixedMonths || (month >= firstMonth && month <= lastMonth))
months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
for (const week of CalendarService.weeksInMonth(
cursor.getMonth(),
cursor.getFullYear(),
)) {
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 (
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getFullYear() === todayYear
)
ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)));
}
}
cursor.setMonth(cursor.getMonth() + 1);
}
selectedDates.update();
return selectedDates;
}
setWeekendSelection(date: Date, selectedDates: SelectedDates) {
if (date.getDay() === 6) {
const end = new Date(date);
end.setDate(end.getDate() + 1);
selectedDates._datesSelected.push(
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)),
for (const placement of [
...plan.placements,
...(plan.declaredDays ?? []).filter(
(day) => day.date >= horizon.start && day.date < horizon.end,
),
]) {
const day = new Date(`${placement.date}T00:00:00`);
ranges.push(
new SelectedDate(
dayTypeForLeaveType[placement.type],
new DateRange<Date>(day, day),
),
);
}
return { months, selections: new SelectedDates(ranges) };
}
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. */
availableDate?: Date;
expiryDate: Date;
classification?: RemainingClassification;
reason?: PlanningReason;
};
export type Week = (Date | null)[];
@@ -285,16 +256,6 @@ const dayTypeForLeaveType: Record<LeaveType, DayType> = {
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[] = [
'Janvier',
'Février',
@@ -354,15 +315,6 @@ export class SelectedDate implements SelectedDateInterface {
return returnType;
}
getTooltip(date: Date): string | null | undefined {
if (this.isSelected(date)) {
if (this.type) {
return tooltipTypeMapping[this.type];
}
}
return null;
}
getLengthInDays(): number {
if (this.range.start && this.range.end) {
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 {
_datesSelected: SelectedDate[] = [];
datesSelected: SelectedDate[] = [];
private readonly ranges: readonly 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;
private maxDate: Date | null = null;
update(): void {
this.datesSelected = this._datesSelected.slice();
this.grouping();
constructor(ranges: readonly SelectedDate[] = []) {
this.ranges = ranges.slice();
this.grouped = SelectedDates.group(this.ranges);
}
push(selectedDate: SelectedDate) {
this._datesSelected.push(selectedDate);
this.datesSelected = this._datesSelected.slice();
this.updateMinMaxDates();
this.grouping();
}
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
private static group(ranges: readonly SelectedDate[]): SelectedDate[] {
const result = ranges.slice();
if (ranges.length <= 1) return result;
const sorted = [...ranges].sort(
(a, b) => (a.range.start?.getTime() ?? 0) - (b.range.start?.getTime() ?? 0),
);
const groups: SelectedDate[][] = [];
let currentGroup: SelectedDate[] = [sorted[0]];
let currentGroupMaxEnd = sorted[0].range.end?.getTime() ?? 0;
for (let i = 1; i < sorted.length; i++) {
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);
let current: SelectedDate[] = [sorted[0]];
let maxEnd = sorted[0].range.end?.getTime() ?? 0;
for (const next of sorted.slice(1)) {
const nextDay = new Date(maxEnd);
nextDay.setDate(nextDay.getDate() + 1);
if (currStart <= nextDay.getTime()) {
currentGroup.push(current);
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
} else {
if (currentGroup.length > 1) {
groups.push(currentGroup);
}
currentGroup = [current];
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
if ((next.range.start?.getTime() ?? 0) > nextDay.getTime()) {
if (current.length > 1) groups.push(current);
current = [];
}
current.push(next);
maxEnd = Math.max(maxEnd, next.range.end?.getTime() ?? 0);
}
// 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];
if (current.length > 1) groups.push(current);
for (const group of groups) {
const groupStart = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity));
const groupEnd = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0));
const start = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity));
const end = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0));
result.push(
new SelectedDate(
null,
new DateRange<Date>(new Date(groupStart), new Date(groupEnd)),
),
new SelectedDate(null, new DateRange<Date>(new Date(start), new Date(end))),
);
}
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 {
return this.datesSelected.some((selectedDate) => selectedDate.isSelected(date));
return this.grouped.some((selectedDate) => selectedDate.isSelected(date));
}
private getRangeBoundaryCounts(date: Date): { starts: number; ends: number } {
let starts = 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()) {
starts++;
}
@@ -501,27 +400,9 @@ export class SelectedDates implements SelectedDateInterface {
return ends >= starts;
}
/**
* 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.
*/
// True when the date lies strictly inside at least one selected range.
private existInARangeButNotABoundary(dateTest: Date): boolean {
return this.datesSelected.some(
return this.grouped.some(
(dateSelected) =>
dateSelected.isSelected(dateTest) &&
!dateSelected.isStart(dateTest) &&
@@ -530,13 +411,6 @@ export class SelectedDates implements SelectedDateInterface {
}
getClasses(date: Date): string {
return this.datesSelected.map((dateSelected) => dateSelected.getClasses(date)).join(' ');
}
getTooltip(date: Date): string | null | undefined {
return this.datesSelected
.map((dateSelected) => dateSelected.getTooltip(date))
.filter((tooltip) => tooltip)
.join(' + ');
return this.grouped.map((dateSelected) => dateSelected.getClasses(date)).join(' ');
}
}
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { CalendarSettingsService } from './calendar-settings-service';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
describe('CalendarSettingsService', () => {
let service: CalendarSettingsService;
+1 -1
View File
@@ -1,4 +1,4 @@
import { afterAll, describe, expect, it } from 'vitest';
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';
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'bun:test';
import {
analyzeCounterfactualInternal,
calculatePlanningInternal,
@@ -1,5 +1,5 @@
import { createOptimizationPresetRules } from './rule-service';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import { createPlanningCalculation, type PlanningIncompleteOutcome } from './leave-planner.service';
const request = {
@@ -1,5 +1,6 @@
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';
const balance = (
@@ -1,5 +1,5 @@
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 {
evaluateOptimization,
@@ -1,4 +1,4 @@
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import {
calculatePlanningAsyncInternal,
calculatePlanningInternal,
@@ -1,4 +1,4 @@
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import {
calculatePlanningInternal,
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!);
});
});
});
@@ -1,5 +1,5 @@
import { createOptimizationPresetRules } from './rule-service';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import {
PlanningOutcome,
PlanningRequest,
@@ -1562,7 +1562,11 @@ describe('planLeave', () => {
const { isWorkingPlanningDate } = await import('./declared-non-working-days');
const { addPlanningDays } = await import('./planning-date');
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' });
const result = isPlan(
await runInNonBrowser({ horizon, balances: [], rules: [], declaredDays }),
+110 -125
View File
@@ -17,6 +17,7 @@ import type {
StaleCounterfactualAnalysis,
} from './leave-planner.core';
import { planningDateInParis } from './planning-date';
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
export type {
IfThenPlanningRule,
@@ -278,20 +279,6 @@ function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | unde
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 {
outcome: Promise<PlanningOutcome>;
explanation: Promise<PlanningExplanation | undefined>;
@@ -320,6 +307,47 @@ function interruptedOutcome(
const latestWorkerCancels = new Set<() => void>();
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 {
for (const cancel of [...latestWorkerCancels]) cancel();
latestWorkerCancels.clear();
@@ -349,9 +377,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
});
let cancel: (() => void) | undefined;
try {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
const job = startWorkerJob(
() => supersede(),
(message) => handleMessage(message),
() => fail('worker-error', 'Planning worker failed before returning explanation data.'),
);
let outcomeSettled = false;
let cancellationRequested = false;
let cancelledRace: PlanningIncompleteOutcome | undefined;
@@ -364,9 +394,7 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
let receivedFinalState: PlanningStateSnapshot | undefined;
let livenessWatchdog: ReturnType<typeof setTimeout>;
const terminate = () => {
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
job.release();
clearTimeout(livenessWatchdog);
};
const finishOutcome = (result: PlanningOutcome) => {
@@ -434,7 +462,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
resolveArtifacts(complete);
resolveTimings(complete.timings ?? {});
terminate();
latestWorkerCancels.delete(supersede);
};
const supersede = () => {
if (artifactsSettled) return;
@@ -456,7 +483,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
finalState: receivedFinalState ?? checkpointState,
});
};
latestWorkerCancels.add(supersede);
const fail = (code: string, message: string) => {
const reason = { code, message };
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
@@ -508,15 +534,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
cancel = () => {
if (outcomeSettled || cancellationRequested) return;
cancellationRequested = true;
try {
worker.postMessage({ type: 'cancel' });
} catch {
fail('worker-error', 'The cancellation request could not reach the worker.');
}
job.post({ type: 'cancel' }, () =>
fail('worker-error', 'The cancellation request could not reach the worker.'),
);
};
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const message = event.data;
if (!message) return;
const handleMessage = (message: WorkerResponse) => {
if (message.type === 'heartbeat') {
scheduleLivenessWatchdog();
return;
@@ -552,13 +574,9 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
else fail('worker-error', 'Planning worker returned an unexpected response.');
};
worker.onerror = () =>
fail('worker-error', 'Planning worker failed before returning explanation data.');
try {
worker.postMessage({ type: 'plan', request: { ...snapshot, progress: undefined } });
} catch {
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
}
job.post({ type: 'plan', request: { ...snapshot, progress: undefined } }, () =>
fail('worker-error', 'Planning input could not be sent to the isolated worker.'),
);
} catch {
const result = interruptedOutcome(
undefined,
@@ -677,62 +695,43 @@ function counterfactualInWorker(
): Promise<CounterfactualAnalysis> {
return new Promise((resolve, reject) => {
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 {
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
job = startWorkerJob(
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) {
reject(error);
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;
latestWorkerCancels.add(cancel);
worker.onmessage = (
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));
}
job.post({ type: 'counterfactual', request: snapshot, plan, placement, state }, (error) =>
finish(() => reject(error)),
);
});
}
@@ -743,53 +742,39 @@ function explainCheckpointInWorker(
interruption: PlanningReason,
): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> {
return new Promise((resolve, reject) => {
let worker: Worker;
try {
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
} catch (error) {
reject(error);
return;
}
let job: WorkerJob;
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);
job.release();
callback();
};
const cancel = () =>
finish(() => reject(new Error('Checkpoint explanation was superseded.')));
latestWorkerCancels.add(cancel);
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const response = event.data;
if (response?.type === 'explanation')
finish(() =>
resolve({
explanation: response.explanation,
explanationError: response.explanationError,
finalState: response.finalState,
}),
);
else if (response?.type === 'error') finish(() => reject(new Error(response.message)));
};
worker.onerror = () =>
finish(() => reject(new Error('Checkpoint explanation worker failed.')));
try {
worker.postMessage({
type: 'explain-checkpoint',
request: snapshot,
plan,
state,
interruption,
});
job = startWorkerJob(
() => finish(() => reject(new Error('Checkpoint explanation was superseded.'))),
(response) => {
if (response.type === 'explanation')
finish(() =>
resolve({
explanation: response.explanation,
explanationError: response.explanationError,
finalState: response.finalState,
}),
);
else if (response.type === 'error')
finish(() => reject(new Error(response.message)));
},
() => finish(() => reject(new Error('Checkpoint explanation worker failed.'))),
);
} 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 };
+29 -6
View File
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Worker as NodeWorker } from 'node:worker_threads';
import { promisify } from 'node:util';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import {
createPlanningCalculation,
type PlanningCalculationContext,
@@ -31,6 +31,7 @@ describe('real planning worker cancellation', () => {
let holdArtifacts = false;
const heldArtifactMessages: (() => void)[] = [];
let simulateLongCalculation = false;
let holdForCancelAtStage: string | undefined;
let simulatedElapsed = 0;
const threads = new Set<NodeWorker>();
const sentTypes: string[] = [];
@@ -43,8 +44,9 @@ describe('real planning worker cancellation', () => {
const entry = join(directory, 'entry.ts');
writeFileSync(
entry,
`import { parentPort, workerData } from 'node:worker_threads';
`import { parentPort, receiveMessageOnPort, workerData } from 'node:worker_threads';
let elapsed = 0;
const pause = new Int32Array(new SharedArrayBuffer(4));
const originalNow = performance.now.bind(performance);
if (workerData.simulateLongCalculation)
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')
setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0);
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'))});
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(() => {
@@ -67,6 +85,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
localStorage.clear();
heartbeats = 0;
simulateLongCalculation = false;
holdForCancelAtStage = undefined;
simulatedElapsed = 0;
sentTypes.length = 0;
receivedTypes.length = 0;
@@ -74,7 +93,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
class RealWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
private thread = new NodeWorker(bundle, { workerData: { simulateLongCalculation } });
private thread = new NodeWorker(bundle, {
workerData: { simulateLongCalculation, holdForCancelAtStage },
});
constructor() {
threads.add(this.thread);
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 () => {
const cancelStage = 'optimization:default-non-working-day-gain';
holdForCancelAtStage = cancelStage;
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
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),
],
progress: (stage) => {
if (stage === 'optimization:default-non-working-day-gain') calculation.cancel();
if (stage === cancelStage) calculation.cancel();
},
});
const outcome = await calculation.outcome;
@@ -698,7 +721,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.reasons[0].code).toBe('user-cancelled');
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!;
expect(incumbent.placements).toHaveLength(3);
expect(incumbent.placements).toContainEqual({
+2 -58
View File
@@ -1,15 +1,7 @@
/// <reference lib="webworker" />
import type {
CounterfactualAnalysis,
LeavePlacement,
PlanOutcome,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
PlanningStateSnapshot,
} from './leave-planner.service';
import type { PlanningOutcome } from './leave-planner.service';
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
import {
analyzeCounterfactualInternal,
calculatePlanningAsyncInternal,
@@ -17,54 +9,6 @@ import {
type PlanningArtifacts,
} 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;
let activeCalculation: { cancelled: boolean } | undefined;
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'bun:test';
import {
calculatePlanningInternal,
type PlanningArtifacts,
+1 -1
View File
@@ -1,4 +1,4 @@
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
import { calculatePlanningInternal } from './leave-planner.core';
import { createPlanningCalculation } from './leave-planner.service';
import type { PlanningRequest } from './leave-planner.service';
+1 -1
View File
@@ -1,5 +1,5 @@
import fc from 'fast-check';
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'bun:test';
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
import {
addDays,
@@ -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 { createPlanningCalculation } from './leave-planner.service';
import { PlanningDurationService } from './planning-duration.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
describe('Planning duration estimate', () => {
@@ -31,7 +32,8 @@ describe('Planning duration estimate', () => {
it('offers a coarse seconds range on the first launch without calculating', () => {
const duration = TestBed.inject(PlanningDurationService);
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([]);
});
@@ -47,7 +49,7 @@ describe('Planning duration estimate', () => {
});
const duration = TestBed.inject(PlanningDurationService);
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' });
});
@@ -81,19 +83,20 @@ describe('Planning duration estimate', () => {
expect(duration.estimate()).toEqual(moreLeave);
settings.setPlanningDates('2026-12-31', '2024-01-01');
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', () => {
const duration = TestBed.inject(PlanningDurationService);
const submitted = duration.request()!;
const submitted = TestBed.inject(PlanningCalculationService).request()!;
duration.recordCompletion(submitted, 120_000);
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 6, unit: 'minutes' });
});
it('calibrates against the submitted snapshot even when the form has since changed', () => {
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');
duration.recordCompletion(submitted, 20_000);
expect(duration.estimate()!.maximum).toBeGreaterThan(60);
@@ -114,14 +117,16 @@ describe('Planning duration estimate', () => {
placeType: SelectableDayType.CP,
});
const duration = TestBed.inject(PlanningDurationService);
const calculation = createPlanningCalculation(duration.request()!);
const calculation = createPlanningCalculation(
TestBed.inject(PlanningCalculationService).request()!,
);
duration.recordCompletion(calculation.snapshot, 20_000);
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', () => {
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])
duration.recordCompletion(submitted, elapsed);
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) => {
const duration = TestBed.inject(PlanningDurationService);
const initial = duration.estimate();
duration.recordCompletion(duration.request()!, elapsed);
duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, elapsed);
expect(duration.estimate()).toEqual(initial);
});
});
+3 -26
View File
@@ -1,9 +1,7 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import dayjs from 'dayjs';
import { CalendarSettingsService } from './calendar-settings-service';
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
import { RuleService } from './rule-service';
import { UserInputService } from './user-input-service';
import { PlanningCalculationService } from './planning-calculation.service';
export interface PlanningDurationRange {
minimum: number;
@@ -54,32 +52,11 @@ function initialDurationMs(request: PlanningRequest): number {
@Injectable({ providedIn: 'root' })
export class PlanningDurationService {
private readonly settings = inject(CalendarSettingsService);
private readonly userInput = inject(UserInputService);
private readonly rules = inject(RuleService);
private readonly calculations = inject(PlanningCalculationService);
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>(() => {
const request = this.request();
const request = this.calculations.request();
if (!request) return null;
const factors = [...this.calibration()].sort((a, b) => a - b);
const middle = Math.floor(factors.length / 2);
+3 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'bun:test';
import {
compareObjectiveLists,
compareObjectiveValues,
@@ -174,7 +174,8 @@ describe('composable planning objectives', () => {
it.each([
null,
[],
[undefined],
[[]],
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, conditions: ['internal', 'internal'] },
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, direction: 'up' },
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: new Array(1) },
+3 -3
View File
@@ -1,8 +1,8 @@
import { TestBed } from '@angular/core/testing';
import { RuleService, RuleType } from './rule-service';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
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 { SelectableDayType } from './calendar-service';
import { createPlanningCalculation } from './leave-planner.service';
@@ -260,7 +260,7 @@ describe('saved rule migration', () => {
expiryDate: new Date('2024-01-05'),
},
]);
const request = TestBed.inject(PlanningDurationService).request()!;
const request = TestBed.inject(PlanningCalculationService).request()!;
expect(request.rules).toHaveLength(6);
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
vi.stubGlobal('window', undefined);
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from './rule-service';
import { SelectableDayType } from './calendar-service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
describe('RuleService', () => {
let service: RuleService;
-1
View File
@@ -1,4 +1,3 @@
/// <reference types="vite/client" />
import { Injectable, inject, Injector, computed } from '@angular/core';
import { storedSignal } from '../shared/utils/stored-signal';
import type { PlanOutcome, RemainingBalance } from './leave-planner.service';
+1 -3
View File
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { UserInputService } from './user-input-service';
import { SelectableDayType, VacDay } from './calendar-service';
import { vi } from 'vitest';
import { vi } from '../../testing/vitest-compat';
describe('UserInputService', () => {
const savedBalance = {
@@ -46,7 +46,6 @@ describe('UserInputService', () => {
{ date: '2024-01-02', type: 'other' },
]);
expect(service.vacationNumberSignal()).toBe(balances);
expect(service.computeTriggerSignal()).toBe(0);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
@@ -119,7 +118,6 @@ describe('UserInputService', () => {
]);
expect(save).toHaveBeenCalledTimes(3);
expect(service.vacationNumberSignal()).toBe(balances);
expect(service.computeTriggerSignal()).toBe(0);
});
it('restores an empty list when saved declarations are unreadable', () => {
+2 -21
View File
@@ -1,4 +1,4 @@
import { computed, Injectable, signal } from '@angular/core';
import { Injectable, signal } from '@angular/core';
import { SelectableDayType, VacDay } from './calendar-service';
import dayjs from 'dayjs';
import {
@@ -6,7 +6,7 @@ import {
isWorkingPlanningDate,
parseDeclaredDays,
} from './declared-non-working-days';
import type { LeaveType, PlanningOutcome, PlanningSnapshot } from './leave-planner.service';
import type { LeaveType } from './leave-planner.service';
@Injectable({
providedIn: 'root',
@@ -161,20 +161,6 @@ export class UserInputService {
readonly vacationNumberSignal = signal<VacDay[]>(
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 {
this.vacationNumberSignal.update(updater);
try {
@@ -194,9 +180,4 @@ export class UserInputService {
console.warn('Failed to save leave balances to localStorage.', error);
}
}
public requestComputation(): void {
this.computationRequestedAtMs.set(performance.now());
this.computeTriggerSignal.update((count) => count + 1);
}
}
-7
View File
@@ -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;
+174
View File
@@ -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
View File
@@ -1,66 +1,71 @@
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { join } from 'node:path';
import { extname, join, resolve, sep } from 'node:path';
const browserDistFolder = join(import.meta.dirname, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
const IMMUTABLE = 'public, max-age=31536000';
/**
* Example Express Rest API endpoints can be defined here.
* Uncomment and define endpoints as necessary.
*
* Example:
* ```ts
* app.get('/api/{*splat}', (req, res) => {
* // Handle API request
* });
* ```
* Fetch handler for a client-only Angular build in `root`: existing files are served as-is,
* extension-less paths fall back to `index.html` (client routes) and missing assets get a 404.
*/
export function createHandler(root: string) {
const base = resolve(root);
const index = join(base, 'index.html');
const isFile = (path: string) =>
Bun.file(path)
.stat()
.then(
(stats) => stats.isFile(),
() => false,
);
// index.html is not content-hashed, so it must be revalidated on every deployment.
const send = (path: string) =>
new Response(Bun.file(path), {
headers: { 'Cache-Control': path === index ? 'no-cache' : IMMUTABLE },
});
/**
* Serve static files from /browser
*/
app.use(
express.static(browserDistFolder, {
maxAge: '1y',
index: false,
redirect: false,
}),
);
/**
* Handle all other requests by rendering the Angular application.
*/
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});
/**
* Start the server if this module is the main entry point, or it is ran via PM2.
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
*/
if (isMainModule(import.meta.url) || process.env['pm_id']) {
const port = process.env['PORT'] || 4000;
app.listen(port, (error) => {
if (error) {
throw error;
return async (request: Request): Promise<Response> => {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response('Method Not Allowed', {
status: 405,
headers: { Allow: 'GET, HEAD' },
});
}
console.log(`Node Express server listening on http://localhost:${port}`);
let path: string;
try {
path = decodeURIComponent(new URL(request.url).pathname);
} 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 });
},
});
}
/**
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
*/
export const reqHandler = createNodeRequestHandler(app);
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
View File
@@ -1,13 +1,116 @@
import '@angular/compiler';
import { vi } from 'vitest';
import '@analogjs/vitest-angular/setup-snapshots';
import '@analogjs/vitest-angular/setup-serializers';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { plugin } from 'bun';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
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);
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
+18
View File
@@ -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;
}
}
+107
View File
@@ -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
View File
@@ -7,5 +7,5 @@
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
"exclude": ["src/**/*.spec.ts", "src/server.ts"]
}
+1 -1
View File
@@ -2,7 +2,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["vitest/globals"],
"types": ["bun", "bun-types/test-globals", "node"],
"target": "es2022"
},
"include": ["src/**/*.ts"],
-29
View File
@@ -1,29 +0,0 @@
/// <reference types="vitest" />
import angular from '@analogjs/vite-plugin-angular';
import { defineConfig } from 'vite';
import { coverageConfigDefaults } from 'vitest/config';
// 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'],
exclude: [...coverageConfigDefaults.exclude, '**/frozen-engine/**'],
},
},
define: {
'import.meta.vitest': mode !== 'production',
},
};
});