Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56b66e10c | ||
|
|
5ee95fe9ca | ||
|
|
389f6eb7e2 | ||
|
|
880efe4e90 | ||
|
|
c59d75e526 | ||
|
|
b086f1ffc8 | ||
|
|
813d9b4d7f | ||
|
|
9158966982 | ||
|
|
d9c7f72461 | ||
|
|
1636355920 | ||
|
|
85fc4561fd | ||
|
|
282d9c6787 | ||
|
|
dfe6d28d2b | ||
|
|
ac69a41fc8 | ||
|
|
f74d744f9b | ||
|
|
94d57add2c | ||
|
|
558c70aa24 | ||
|
|
c949480e49 | ||
|
|
752c8b7a99 | ||
|
|
cee67ce3df | ||
|
|
a472347a5e | ||
|
|
feb651148e | ||
|
|
43677ff434 | ||
|
|
502d561f6f | ||
|
|
5cb0b93339 | ||
|
|
b69b3c3690 | ||
|
|
7fc2b33587 | ||
|
|
40a11c245a |
No files matched your search
+7
-6
@@ -3,16 +3,17 @@
|
||||
All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to the following rules before declaring a task complete:
|
||||
|
||||
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`.
|
||||
+60
-31
@@ -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,54 +23,66 @@ 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 }}
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- uses: https://gitea.com/actions/checkout@v7
|
||||
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
|
||||
- uses: https://gitea.com/actions/checkout@v7
|
||||
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
|
||||
- uses: https://gitea.com/actions/checkout@v7
|
||||
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() }}
|
||||
uses: https://gitea.com/actions/upload-artifact@v3
|
||||
run: bun scripts/coverage-reports.mjs
|
||||
- name: Upload coverage
|
||||
if: ${{ always() }}
|
||||
uses: https://gitea.com/actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage/
|
||||
@@ -75,31 +92,39 @@ 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
|
||||
- uses: https://gitea.com/actions/checkout@v7
|
||||
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
|
||||
- uses: https://gitea.com/actions/checkout@v7
|
||||
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
|
||||
- uses: https://gitea.com/actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
@@ -111,7 +136,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:24.21.0
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- uses: https://gitea.com/actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install scanners
|
||||
@@ -137,7 +162,7 @@ jobs:
|
||||
run: trivy fs --db-repository public.ecr.aws/aquasecurity/trivy-db:2 --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
|
||||
- name: Security reports
|
||||
if: ${{ always() }}
|
||||
uses: https://gitea.com/actions/upload-artifact@v3
|
||||
uses: https://gitea.com/actions/upload-artifact@v7
|
||||
with:
|
||||
name: security
|
||||
path: '*-results.sarif'
|
||||
@@ -153,13 +178,13 @@ jobs:
|
||||
(github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository))
|
||||
runs-on: ubuntu-latest
|
||||
container: docker:29.8.2
|
||||
container: docker:29.9.1
|
||||
concurrency:
|
||||
group: image-${{ needs.policy.outputs.tag != '' && 'stable' || needs.policy.outputs.slug }}
|
||||
cancel-in-progress: false
|
||||
services:
|
||||
docker:
|
||||
image: docker:29.8.2-dind
|
||||
image: docker:29.9.1-dind
|
||||
env:
|
||||
DOCKER_TLS_CERTDIR: ''
|
||||
options: >-
|
||||
@@ -175,17 +200,21 @@ jobs:
|
||||
run:
|
||||
shell: sh
|
||||
steps:
|
||||
- name: Install checkout and policy runtimes
|
||||
run: apk add --no-cache nodejs git
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
# 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@v7
|
||||
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
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"npm.packageManager": "bun"
|
||||
}
|
||||
+10
-28
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[test]
|
||||
preload = ["./src/test-setup.ts"]
|
||||
root = "src"
|
||||
coverage = true
|
||||
coverageReporter = ["text", "lcov"]
|
||||
coverageDir = "coverage"
|
||||
coverageSkipTestFiles = true
|
||||
coveragePathIgnorePatterns = ["**/frozen-engine/**", "**/testing/**", "**/test-setup.ts"]
|
||||
@@ -0,0 +1,7 @@
|
||||
[run]
|
||||
# Node-shebang tools (ng, eslint, prettier) run on Bun even when Node is on PATH.
|
||||
bun = true
|
||||
|
||||
[test]
|
||||
preload = ["./src/test-setup.ts"]
|
||||
root = "src"
|
||||
+1
-1
@@ -17,7 +17,7 @@ services:
|
||||
'CMD',
|
||||
'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
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
+49
-13
@@ -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
|
||||
job uses Docker 29.8.2 and its matching Docker-in-Docker service. Scanner
|
||||
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.9.1 and its matching Docker-in-Docker service. Scanner
|
||||
versions are Semgrep 1.180.0 and Trivy 0.75.0; Trivy downloads are checksum-checked.
|
||||
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/)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
-33
@@ -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,
|
||||
@@ -27,52 +30,40 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "22.2.1",
|
||||
"@angular/animations": "22.2.3",
|
||||
"@angular/cdk": "^22.0.0",
|
||||
"@angular/common": "22.2.1",
|
||||
"@angular/compiler": "22.2.1",
|
||||
"@angular/core": "22.2.1",
|
||||
"@angular/forms": "22.2.1",
|
||||
"@angular/common": "22.2.3",
|
||||
"@angular/compiler": "22.2.3",
|
||||
"@angular/core": "22.2.3",
|
||||
"@angular/forms": "22.2.3",
|
||||
"@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",
|
||||
"@angular/platform-browser": "22.2.3",
|
||||
"@angular/platform-browser-dynamic": "22.2.3",
|
||||
"@angular/router": "22.2.3",
|
||||
"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",
|
||||
"@angular/build": "22.2.3",
|
||||
"@angular/cli": "22.2.3",
|
||||
"@angular/compiler-cli": "22.2.3",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -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') : '';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Converts coverage/lcov.info (Bun) to HTML and Cobertura. Run: bun scripts/coverage-reports.mjs
|
||||
// shortcut: line coverage only, Bun's lcov has no usable branch/function data; revisit if Bun adds it.
|
||||
// Totals here sum lines (8351/8578); Bun's text table averages per-file percentages, so it reads lower.
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import libCoverage from 'istanbul-lib-coverage';
|
||||
import libReport from 'istanbul-lib-report';
|
||||
import reports from 'istanbul-reports';
|
||||
|
||||
const map = libCoverage.createCoverageMap({});
|
||||
const loc = (line) => ({ start: { line, column: 0 }, end: { line, column: 1 } });
|
||||
for (const record of readFileSync('coverage/lcov.info', 'utf8').split('end_of_record')) {
|
||||
const file = /^SF:(.+)$/m.exec(record)?.[1];
|
||||
if (!file) continue;
|
||||
const statementMap = {};
|
||||
const s = {};
|
||||
for (const [, line, hits] of record.matchAll(/^DA:(\d+),(\d+)/gm)) {
|
||||
statementMap[line] = loc(+line);
|
||||
s[line] = +hits;
|
||||
}
|
||||
map.addFileCoverage({
|
||||
path: resolve(file),
|
||||
statementMap,
|
||||
s,
|
||||
fnMap: {},
|
||||
f: {},
|
||||
branchMap: {},
|
||||
b: {},
|
||||
});
|
||||
}
|
||||
const context = libReport.createContext({ dir: 'coverage', coverageMap: map });
|
||||
for (const name of ['html', 'cobertura']) reports.create(name).execute(context);
|
||||
|
||||
// Istanbul reports 0 branches/functions as 100%; relabel them instead of claiming coverage.
|
||||
const rewrite = (file, edit) => writeFileSync(file, edit(readFileSync(file, 'utf8')));
|
||||
for (const page of readdirSync('coverage', { recursive: true }).filter((f) => f.endsWith('.html')))
|
||||
rewrite(join('coverage', page), (html) =>
|
||||
html
|
||||
.replace(
|
||||
/<span class="strong">100% <\/span>(\s*<span class="quiet">(?:Branches|Functions)<\/span>\s*<span class='fraction'>)0\/0</g,
|
||||
'<span class="strong">n/a </span>$1not collected<',
|
||||
)
|
||||
.replace(
|
||||
/<td data-value="100" class="pct \w+">100%<\/td>(\s*)<td data-value="0" class="abs \w+">0\/0<\/td>/g,
|
||||
'<td data-value="0" class="pct">n/a</td>$1<td data-value="0" class="abs">not collected</td>',
|
||||
),
|
||||
);
|
||||
rewrite('coverage/cobertura-coverage.xml', (xml) =>
|
||||
xml
|
||||
.replaceAll('branch-rate="1"', 'branch-rate="0"')
|
||||
.replace(
|
||||
'<coverage ',
|
||||
'<!-- Line coverage only: bun test does not collect branch or function coverage. -->\n<coverage ',
|
||||
),
|
||||
);
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
# Installs the pinned Bun release to /opt/bun/bin without npm, after verifying its SHA-256.
|
||||
# Needs curl, unzip and sha256sum. Bun upgrades change the version, package.json and every checksum together.
|
||||
set -eu
|
||||
|
||||
version=1.4.3
|
||||
dest=${BUN_INSTALL_DIR:-/opt/bun}
|
||||
|
||||
grep -q "\"packageManager\": \"bun@${version}\"" package.json || {
|
||||
echo "package.json packageManager must be bun@${version}." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64) arch=x64 ;;
|
||||
aarch64 | arm64) arch=aarch64 ;;
|
||||
*)
|
||||
echo "Unsupported Bun architecture: $(uname -m)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
libc=
|
||||
if [ -e /etc/alpine-release ]; then libc=-musl; fi
|
||||
asset="bun-linux-${arch}${libc}"
|
||||
case "$asset" in
|
||||
bun-linux-x64) sha256=3e730528d89775f03f87f05c06787c4df4099d0a37f3f252e7b829cb23816e18 ;;
|
||||
bun-linux-x64-musl) sha256=6e9ea98554ea0d05efda5840ae292618aaa3adefd66b0853520c5c86a37eac9c ;;
|
||||
bun-linux-aarch64) sha256=efa9813da5ed72423bf847f916e8d2c47c0d776add972354026a75e10da9aa21 ;;
|
||||
bun-linux-aarch64-musl) sha256=8de8a0003cc2707bb555887e3d450e24e1edf3f605f491393e89ed61a8853420 ;;
|
||||
esac
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
curl --fail --location --silent --show-error \
|
||||
"https://github.com/oven-sh/bun/releases/download/bun-v${version}/${asset}.zip" \
|
||||
--output "$work/bun.zip"
|
||||
echo "${sha256} ${work}/bun.zip" | sha256sum -c -
|
||||
unzip -q "$work/bun.zip" -d "$work"
|
||||
mkdir -p "${dest}/bin"
|
||||
install -m 0755 "${work}/${asset}/bun" "${dest}/bin/bun"
|
||||
"${dest}/bin/bun" --version
|
||||
@@ -7,7 +7,7 @@ import { MatOptionHarness } from '@angular/material/core/testing';
|
||||
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
||||
import { 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';
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
||||
import { provideServerRendering, withRoutes } from '@angular/ssr';
|
||||
import { appConfig } from './app.config';
|
||||
import { serverRoutes } from './app.routes.server';
|
||||
|
||||
const serverConfig: ApplicationConfig = {
|
||||
providers: [provideServerRendering(withRoutes(serverRoutes))],
|
||||
};
|
||||
|
||||
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||
@@ -6,13 +6,11 @@ import {
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter(routes),
|
||||
provideClientHydration(withEventReplay()),
|
||||
],
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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 }),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
|
||||
import { App } from './app/app';
|
||||
import { config } from './app/app.config.server';
|
||||
|
||||
const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);
|
||||
|
||||
export default bootstrap;
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { connect } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createServer } from './server';
|
||||
|
||||
const INDEX = '<!doctype html><title>fixture app</title>';
|
||||
const SECRET = 'top-secret-outside-the-root';
|
||||
const JS = 'main-ABC123.js';
|
||||
const IMMUTABLE = 'public, max-age=31536000';
|
||||
|
||||
let workdir: string;
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let emptyServer: ReturnType<typeof createServer>;
|
||||
|
||||
// fetch() normalizes dot segments, so traversal needs an unnormalized request line.
|
||||
function rawGet(port: number, target: string): Promise<{ status: number; text: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let text = '';
|
||||
const socket = connect(port, '127.0.0.1', () =>
|
||||
socket.write(`GET ${target} HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n`),
|
||||
);
|
||||
socket.on('data', (chunk) => (text += chunk));
|
||||
socket.on('end', () => resolve({ status: Number(text.split(' ')[1]), text }));
|
||||
socket.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
workdir = mkdtempSync(join(tmpdir(), 'vac-server-'));
|
||||
const root = join(workdir, 'browser');
|
||||
mkdirSync(join(root, 'assets'), { recursive: true });
|
||||
writeFileSync(join(root, 'index.html'), INDEX);
|
||||
writeFileSync(join(root, JS), 'console.log("app");');
|
||||
writeFileSync(join(root, 'styles-DEF456.css'), 'body{margin:0}');
|
||||
writeFileSync(join(root, 'assets', 'data.json'), '{"nested":true}');
|
||||
writeFileSync(join(workdir, 'secret.txt'), SECRET);
|
||||
mkdirSync(join(workdir, 'empty'));
|
||||
server = createServer({ root, port: 0 });
|
||||
emptyServer = createServer({ root: join(workdir, 'empty'), port: 0 });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop(true);
|
||||
await emptyServer.stop(true);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('direct navigation', () => {
|
||||
it.each(['/', '/benchmark', '/benchmark/', '/unknown/client/route', '/assets'])(
|
||||
'serves the application shell for %s',
|
||||
async (path) => {
|
||||
const response = await fetch(new URL(path, server.url));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toStartWith('text/html');
|
||||
expect(response.headers.get('cache-control')).toBe('no-cache');
|
||||
expect(await response.text()).toBe(INDEX);
|
||||
},
|
||||
);
|
||||
|
||||
it('never gives the shell the long asset cache when requested by name', async () => {
|
||||
const response = await fetch(new URL('/index.html', server.url));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('cache-control')).toBe('no-cache');
|
||||
});
|
||||
});
|
||||
|
||||
describe('static assets', () => {
|
||||
it.each([
|
||||
[JS, 'javascript', 'console.log("app");'],
|
||||
['styles-DEF456.css', 'css', 'body{margin:0}'],
|
||||
['assets/data.json', 'json', '{"nested":true}'],
|
||||
])('serves /%s with its content type and the long cache', async (file, type, body) => {
|
||||
const response = await fetch(new URL(`/${file}`, server.url));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain(type);
|
||||
expect(response.headers.get('cache-control')).toBe(IMMUTABLE);
|
||||
expect(await response.text()).toBe(body);
|
||||
});
|
||||
|
||||
it('answers HEAD with the GET headers and no body', async () => {
|
||||
const get = await fetch(new URL(`/${JS}`, server.url));
|
||||
const head = await fetch(new URL(`/${JS}`, server.url), { method: 'HEAD' });
|
||||
|
||||
expect(head.status).toBe(200);
|
||||
expect(head.headers.get('content-type')).toBe(get.headers.get('content-type'));
|
||||
expect(head.headers.get('content-length')).toBe(get.headers.get('content-length'));
|
||||
expect(head.headers.get('cache-control')).toBe(IMMUTABLE);
|
||||
expect(await head.text()).toBe('');
|
||||
});
|
||||
|
||||
it('answers HEAD for a client route with no body', async () => {
|
||||
const head = await fetch(new URL('/benchmark', server.url), { method: 'HEAD' });
|
||||
|
||||
expect(head.status).toBe(200);
|
||||
expect(await head.text()).toBe('');
|
||||
});
|
||||
|
||||
it.each(['/missing.js', '/assets/missing.css', '/nope.js/', '/favicon.ico'])(
|
||||
'answers 404, not the shell, for missing asset %s',
|
||||
async (path) => {
|
||||
const response = await fetch(new URL(path, server.url));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get('content-type')).toStartWith('text/plain');
|
||||
expect(await response.text()).not.toContain('fixture app');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
it.each([`/${JS}`, '/benchmark'])('rejects POST to %s with 405', async (path) => {
|
||||
const response = await fetch(new URL(path, server.url), { method: 'POST', body: 'x' });
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get('allow')).toBe('GET, HEAD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('path protection', () => {
|
||||
it.each([
|
||||
'/../secret.txt',
|
||||
'/%2e%2e/secret.txt',
|
||||
'/..%2fsecret.txt',
|
||||
'/%2e%2e%2f%2e%2e%2fetc/passwd',
|
||||
'/..%5csecret.txt',
|
||||
'/assets/..%2f..%2fsecret.txt',
|
||||
'/%2e%2e%2fsecret.txt',
|
||||
'//secret.txt',
|
||||
'/%00',
|
||||
'/assets%00/data.json',
|
||||
])('does not leak files outside the root through %s', async (target) => {
|
||||
const response = await rawGet(server.port!, target);
|
||||
|
||||
expect([400, 404]).toContain(response.status);
|
||||
expect(response.text).not.toContain(SECRET);
|
||||
expect(response.text).not.toContain('root:');
|
||||
});
|
||||
|
||||
it.each(['/..%2fsecret.txt', '/%2e%2e%2f%2e%2e%2fetc/passwd', '/%00'])(
|
||||
'rejects %s as a bad request',
|
||||
async (target) => {
|
||||
expect((await rawGet(server.port!, target)).status).toBe(400);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects malformed percent-encoding with 400 instead of failing', async () => {
|
||||
for (const target of ['/%ZZ', '/%E0%A4%A', '/assets/%']) {
|
||||
expect((await rawGet(server.port!, target)).status).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors', () => {
|
||||
it('answers 500 as plain text and logs it when the build has no index.html', async () => {
|
||||
const log = spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const response = await fetch(new URL('/benchmark', emptyServer.url));
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.headers.get('content-type')).toStartWith('text/plain');
|
||||
expect(await response.text()).toBe('Internal Server Error');
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
log.mockRestore();
|
||||
});
|
||||
|
||||
it('still answers a missing asset with 404 when index.html is absent', async () => {
|
||||
expect((await fetch(new URL('/missing.js', emptyServer.url))).status).toBe(404);
|
||||
});
|
||||
});
|
||||
+61
-56
@@ -1,66 +1,71 @@
|
||||
import {
|
||||
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
@@ -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) => ({
|
||||
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars */
|
||||
import 'bun:test';
|
||||
|
||||
// Vitest APIs that the compat shim adds to bun:test.
|
||||
declare module 'bun:test' {
|
||||
interface Expect {
|
||||
// Vitest-style loose matcher arguments: Bun's strict ones reject readonly and widened values.
|
||||
(actual?: unknown): Matchers<any>;
|
||||
poll<T>(
|
||||
fn: () => T | Promise<T>,
|
||||
options?: { timeout?: number; interval?: number },
|
||||
): Matchers<Awaited<T>> & { not: Matchers<Awaited<T>> } & Record<string, unknown>;
|
||||
}
|
||||
interface Matchers<T> {
|
||||
toHaveBeenCalledOnce(): void;
|
||||
toHaveBeenCalledExactlyOnceWith(...args: unknown[]): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as bt from 'bun:test';
|
||||
|
||||
const stubbed = new Map<string, PropertyDescriptor | undefined>();
|
||||
const realUseFake = bt.vi.useFakeTimers.bind(bt.vi);
|
||||
const flush = () => new Promise<void>((r) => setImmediate(r));
|
||||
type AnyFn = (...args: any[]) => any;
|
||||
type VitestCompat = Omit<typeof bt.vi, 'spyOn' | 'useFakeTimers'> & {
|
||||
setSystemTime(now?: Date | number): VitestCompat;
|
||||
spyOn<T extends object, K extends keyof T>(
|
||||
obj: T,
|
||||
method: K,
|
||||
): bt.Mock<T[K] extends AnyFn ? T[K] : AnyFn>;
|
||||
useFakeTimers(options?: { toFake?: string[] }): VitestCompat;
|
||||
stubGlobal(name: string, value: unknown): VitestCompat;
|
||||
unstubAllGlobals(): VitestCompat;
|
||||
advanceTimersByTimeAsync(ms: number): Promise<void>;
|
||||
runAllTicks(): void;
|
||||
waitFor<T>(
|
||||
fn: () => T | Promise<T>,
|
||||
options?: { timeout?: number; interval?: number },
|
||||
): Promise<T>;
|
||||
};
|
||||
export const vi = Object.assign(bt.vi, {
|
||||
setSystemTime: (d?: Date | number) => bt.setSystemTime(d as any),
|
||||
// Bun ignores toFake and fakes every timer; Date-only is emulated by setSystemTime alone.
|
||||
useFakeTimers(o?: { toFake?: string[] }) {
|
||||
if (o?.toFake?.length === 1 && o.toFake[0] === 'Date') return vi;
|
||||
const now = Date.now();
|
||||
realUseFake();
|
||||
bt.setSystemTime(now);
|
||||
return vi;
|
||||
},
|
||||
stubGlobal(name: string, value: unknown) {
|
||||
if (!stubbed.has(name))
|
||||
stubbed.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||
return vi;
|
||||
},
|
||||
unstubAllGlobals() {
|
||||
for (const [n, d] of stubbed) {
|
||||
if (d) Object.defineProperty(globalThis, n, d);
|
||||
else delete (globalThis as any)[n];
|
||||
}
|
||||
stubbed.clear();
|
||||
return vi;
|
||||
},
|
||||
async advanceTimersByTimeAsync(ms: number) {
|
||||
await flush();
|
||||
bt.vi.advanceTimersByTime(ms);
|
||||
await flush();
|
||||
},
|
||||
runAllTicks() {},
|
||||
async waitFor<T>(fn: () => T | Promise<T>, { timeout = 1000, interval = 50 } = {}): Promise<T> {
|
||||
const end = performance.now() + timeout;
|
||||
let last: unknown;
|
||||
for (;;) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
last = e;
|
||||
if (performance.now() > end) throw last;
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
}
|
||||
}
|
||||
},
|
||||
}) as unknown as VitestCompat;
|
||||
export const { describe, it, test, expect, beforeEach, afterEach, beforeAll, afterAll } = bt;
|
||||
export type MockInstance<T extends AnyFn = AnyFn> = bt.Mock<T>;
|
||||
|
||||
// expect.poll: retry `expect(await fn())<.not>.matcher(...)` until it passes or times out.
|
||||
(bt.expect as any).poll = (fn: () => unknown, { timeout = 1000, interval = 50 } = {}) => {
|
||||
const chain = (negate: boolean): any =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, m: string) =>
|
||||
m === 'not'
|
||||
? chain(!negate)
|
||||
: async (...args: unknown[]) => {
|
||||
const end = performance.now() + timeout;
|
||||
for (;;) {
|
||||
try {
|
||||
const e: any = bt.expect(await fn());
|
||||
return await (negate ? e.not : e)[m](...args);
|
||||
} catch (err) {
|
||||
if (performance.now() > end) throw err;
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
return chain(false);
|
||||
};
|
||||
|
||||
bt.expect.extend({
|
||||
toHaveBeenCalledExactlyOnceWith(fn: any, ...args: unknown[]) {
|
||||
const calls = fn.mock.calls as unknown[][];
|
||||
const pass = calls.length === 1 && this.equals(calls[0], args);
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
`expected exactly one call with ${JSON.stringify(args)}, got ${JSON.stringify(calls)}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
+1
-1
@@ -7,5 +7,5 @@
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
"exclude": ["src/**/*.spec.ts", "src/server.ts"]
|
||||
}
|
||||
+1
-1
@@ -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"],
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user