feat(planning): make optimization rules composable
Evaluate explicit, ordered optimization configurations through one shared planner model. Add six editable presets, durable one-time legacy migration, and consistent completed and cancelled plan provenance. Include functional regressions and the agreed trace-removal and benchmark follow-up specs. Performance acceptance remains in #78 under unchanged gates. Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
This commit is contained in:
1 parent
209bf05f36
commit
6811142299
45 files changed
+8795
-3075
No files matched your search
+25
-17
@@ -1,26 +1,34 @@
|
||||
# 06 — Migrer les règles sauvegardées et valider l’ensemble
|
||||
# 06 — Migrer les règles sauvegardées et valider le comportement fonctionnel
|
||||
|
||||
**GitLab:** [#75](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/75)
|
||||
|
||||
**What to build:** Au prochain chargement, l’utilisateur retrouve ses règles et les six anciens comportements du moteur sous forme de préréglages actifs. Il peut ensuite les modifier ou les supprimer durablement, et l’ensemble est validé avant sa mise en service.
|
||||
Blocked by: #74
|
||||
|
||||
**Blocked by:** 05 — Piloter l’économie des jours d’appoint.
|
||||
Les exigences de performance sont transférées à #78. La clôture de ce ticket dépend de la migration et de la validation fonctionnelle, pas du passage du protocole de performance. Les limites du benchmark et les contrôles CI restent inchangés.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
## What to build
|
||||
|
||||
Livraison intégrée de l’ADR 0008 sur la branche commune aux tickets 01 à 05. La préservation des données enregistrées et la migration unique ont été explicitement approuvées : ne pas réinitialiser les données pour simplifier le changement de contrat.
|
||||
Au prochain chargement, l’utilisateur retrouve ses règles et les six anciens comportements du moteur sous forme de préréglages actifs. Il peut ensuite les modifier ou les supprimer durablement, et l’ensemble est validé avant sa mise en service.
|
||||
|
||||
- [ ] À l’initialisation, fournir les six préréglages actifs avec cet ordre : prévention des pertes, règles personnelles ou règles initiales existantes dans leur ordre, semaines fériées, gain de jours non ouvrés, séquence maximale, déficit des reprises et économie des placements d’appoint.
|
||||
- [ ] Migrer une seule fois les listes enregistrées avant cette évolution, y compris une ancienne liste vide. Préserver intégralement l’identité, le nom, les paramètres, les dates, la force, l’activation et l’ordre relatif des règles existantes.
|
||||
- [ ] Enregistrer de façon cohérente les règles migrées et le fait que la migration a eu lieu. Ne pas considérer l’opération terminée si les règles n’ont pas été sauvegardées ; gérer les échecs de stockage sans écraser les données utilisateur valides.
|
||||
- [ ] Les chargements suivants ne dupliquent pas les préréglages, ne rétablissent pas ceux supprimés et ne réactivent pas ceux désactivés. Une liste vidée volontairement après migration reste vide. Les nouveaux utilisateurs reçoivent directement la nouvelle configuration initiale.
|
||||
- [ ] La restauration et la migration précèdent le premier calcul utilisant ces données. L’utilisateur peut immédiatement éditer les six configurations avec les mêmes commandes que ses règles personnelles et retrouver ses choix après rechargement.
|
||||
- [ ] Vérifier de bout en bout les six préréglages dans l’ordre initial, leurs réordonnancements et leurs désactivations. Une configuration personnelle différente d’un préréglage traverse édition, sauvegarde, worker, calcul et explication ; les paramètres ont un effet vérifiable.
|
||||
- [ ] La liste entièrement désactivée, la liste supprimée après migration et une requête directe sans règle active n’ajoutent aucun congé malgré des soldes à risque. Les jours déclarés, les quantités restantes et les raisons pertinentes restent cohérents.
|
||||
- [ ] Les obligations conjointes, les conditions des congés gagnés et les contraintes de validité sont respectées pour tout ordre des préférences. Une annulation restitue le meilleur plan valide disponible et sa trace originale selon la configuration soumise.
|
||||
- [ ] Les explications et analyses contrefactuelles utilisent le même ordre actif que le calcul. Modifier les règles durant le calcul ou pendant la livraison tardive de la trace ne change pas ses résultats. Les anciens objectifs implicites n’interviennent plus dans aucune comparaison ou protection.
|
||||
- [ ] Les fixtures et scénarios de benchmark qui dépendaient d’une optimisation implicite demandent explicitement les préréglages nécessaires. Les tests d’absence de règles n’utilisent aucun défaut implicite pour retrouver l’ancien résultat.
|
||||
- [ ] Exécuter les suites concernées de règles, interface, moteur, worker réel, explication, annulation et estimation de durée, ainsi que les contrôles habituels de compilation et de qualité. Vérifier les scénarios de benchmark existants, dont les horizons longs, pour le déterminisme, la réactivité et les durées, sans introduire de délai d’arrêt automatique.
|
||||
- [ ] La vérification commune des tickets 01 à 06 est la condition de mise en service. Documenter les choix finaux du contrat composable et les limites mesurées ; ne laisser ni double moteur, ni chemin historique implicite, ni adaptateur provisoire ajouté pour rendre les étapes indépendamment livrables.
|
||||
Livraison intégrée de l’ADR 0008 sur la branche commune aux tickets #70, #71, #72, #73, #74. La préservation des données enregistrées et la migration unique ont été explicitement approuvées : ne pas réinitialiser les données pour simplifier le changement de contrat.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] À l’initialisation, fournir les six préréglages actifs avec cet ordre : prévention des pertes, règles personnelles ou règles initiales existantes dans leur ordre, semaines fériées, gain de jours non ouvrés, séquence maximale, déficit des reprises et économie des placements d’appoint.
|
||||
- [x] Migrer une seule fois les listes enregistrées avant cette évolution, y compris une ancienne liste vide. Préserver intégralement l’identité, le nom, les paramètres, les dates, la force, l’activation et l’ordre relatif des règles existantes.
|
||||
- [x] Enregistrer de façon cohérente les règles migrées et le fait que la migration a eu lieu. Ne pas considérer l’opération terminée si les règles n’ont pas été sauvegardées ; gérer les échecs de stockage sans écraser les données utilisateur valides.
|
||||
- [x] Les chargements suivants ne dupliquent pas les préréglages, ne rétablissent pas ceux supprimés et ne réactivent pas ceux désactivés. Une liste vidée volontairement après migration reste vide. Les nouveaux utilisateurs reçoivent directement la nouvelle configuration initiale.
|
||||
- [x] La restauration et la migration précèdent le premier calcul utilisant ces données. L’utilisateur peut immédiatement éditer les six configurations avec les mêmes commandes que ses règles personnelles et retrouver ses choix après rechargement.
|
||||
- [x] Vérifier de bout en bout les six préréglages dans l’ordre initial, leurs réordonnancements et leurs désactivations. Une configuration personnelle différente d’un préréglage traverse édition, sauvegarde, worker, calcul et explication ; les paramètres ont un effet vérifiable.
|
||||
- [x] La liste entièrement désactivée, la liste supprimée après migration et une requête directe sans règle active n’ajoutent aucun congé malgré des soldes à risque. Les jours déclarés, les quantités restantes et les raisons pertinentes restent cohérents.
|
||||
- [x] Les obligations conjointes, les conditions des congés gagnés et les contraintes de validité sont respectées pour tout ordre des préférences. Une annulation restitue le meilleur plan valide disponible selon la configuration soumise. La suppression de la trace originale est suivie séparément dans #76.
|
||||
- [x] Les explications et analyses contrefactuelles utilisent le même ordre actif que le calcul. Modifier les règles durant le calcul ou pendant la livraison des explications ne change pas ses résultats. Les anciens objectifs implicites n’interviennent plus dans aucune comparaison ou protection.
|
||||
- [x] Les fixtures et scénarios de benchmark qui dépendaient d’une optimisation implicite demandent explicitement les préréglages nécessaires. Les tests d’absence de règles n’utilisent aucun défaut implicite pour retrouver l’ancien résultat.
|
||||
- [x] Exécuter les suites fonctionnelles concernées de règles, interface, moteur, worker réel, explication, annulation et estimation de durée, ainsi que les contrôles habituels de compilation et de qualité. Préserver les assertions de résultat et de déterminisme des fixtures, y compris les horizons longs. L’exécution complète du protocole de benchmark, la réactivité mesurée, les durées, la consommation mémoire et les optimisations nécessaires relèvent de #78 et ne conditionnent plus la clôture de #75. Ne pas introduire de délai d’arrêt automatique.
|
||||
- [x] La vérification fonctionnelle commune des tickets #70, #71, #72, #73, #74, #75 est la condition de clôture fonctionnelle de ce ticket. Documenter les choix finaux du contrat composable ; ne laisser ni double moteur, ni chemin historique implicite, ni adaptateur provisoire ajouté pour rendre les étapes indépendamment livrables. Les limites de performance mesurées et leur amélioration sont suivies dans #78 ; les exigences habituelles de CI et de mise en service ne sont pas abaissées.
|
||||
|
||||
**Vérification de bout en bout :** Partir d’une ancienne sauvegarde contenant des règles personnalisées et désactivées, charger la nouvelle version, vérifier le premier calcul, modifier ou supprimer des préréglages, recharger et constater la conservation exacte des choix.
|
||||
|
||||
## Blocked by
|
||||
|
||||
- #74 — Piloter l’économie des jours d’appoint
|
||||
@@ -0,0 +1,29 @@
|
||||
# 01 — Remove the original decision trace end to end
|
||||
|
||||
**GitLab:** [#76](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/76)
|
||||
|
||||
## What to build
|
||||
|
||||
Calculations no longer record, retain or deliver an original decision trace. Users receive the displayed plan, its stable explanation and remaining-balance reasons, and can still analyze an alternative date for a displayed placement. The advanced trace explorer, historical calendar replay and historical attempt lists disappear completely.
|
||||
|
||||
This is one cohesive contract replacement across the planner, calculation service, workers and UI. Use a scoped integration branch containing the existing #70–#75 implementation work; #75's final verification must not be treated as a prerequisite for removing tracing. The shared functional integration verification gate is #77 — Verify integrated planning after trace removal. Benchmark performance acceptance and any required optimization are owned by #78; performance is no longer a completion gate for #75.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Delete trace capture, materialization, candidate-history retention, replay, historical eligibility and caches or evidence pools used exclusively for tracing. Remove observational work while preserving search breadth, allocation previews, rule priority and incumbent selection.
|
||||
- [ ] Replace trace contracts directly and update all callers in the same change. Remove trace promises, callbacks, public types, streaming messages, status indexes, acknowledgements and backpressure queues across real-worker, browser-fallback and server-side calculation paths. Do not retain debug toggles, compatibility adapters, empty trace results or replacement history.
|
||||
- [ ] Preserve immutable calculation, valid-incumbent and final-state snapshots needed by retained behavior. Rename retained state contracts independently of tracing; do not delete cancellation or explanation data because it currently has a trace-related name.
|
||||
- [ ] Retain stable plan explanations, including placement entitlement and rule provenance, objective contributions and the first decisive objective, time-off periods, remaining balances and concrete non-placement reasons. Preserve invalid-input and conflict explanations and incomplete-calculation behavior.
|
||||
- [ ] Keep alternative-date analysis for a displayed placement using the captured calculation configuration, the same entitlement, leave type and provenance, and fixed unrelated placements. Remove analysis based on historical decisions; hypothetical analysis never mutates the displayed plan.
|
||||
- [ ] Remove the advanced trace panel, replay controls, historical calendar overlays and candidate statuses. Remove remaining-balance historical attempt lists, pagination and loading state while retaining classification, validity, earned-entitlement source and truthful displayed-plan reasons. Leave no dead controls or trace-unavailable messages.
|
||||
- [ ] Preserve explicit cancellation before and after a valid incumbent, best-valid-partial-plan recovery, heartbeat monitoring, stale-response rejection and the existing technical-budget contract. Interrupted calculations do not imply an obligation conflict. Explanation delivery failures do not discard an available outcome, and planning workers do not wait for trace acknowledgements after required delivery.
|
||||
- [ ] Preserve completed placements, balances, objective values, joint obligation satisfaction, earned-entitlement chronology, deterministic tie-breaking, user-controlled priorities and empty-active-rule behavior. Do not reset saved entitlements, declarations or rule configurations or change the existing migration.
|
||||
- [ ] Update component styles and translations across supported languages, preserving keyboard access and accessibility for retained explanations, calendar interactions and date analysis.
|
||||
- [ ] Adapt existing planner, service, worker, explanation, cancellation, calendar and remaining-balance tests. Delete tests exclusively for tracing while retaining assertions for supported behavior in mixed tests. Update benchmark callers, output and assertions to remove obsolete trace requirements without reducing scenarios or changing performance gates.
|
||||
- [ ] Supersede trace-specific requirements in the domain vocabulary and explanation/cancellation ADRs, and update benchmark documentation. Preserve the remaining planning contracts and document that the original decision trace and historical analysis are removed.
|
||||
|
||||
No new dependency, replacement telemetry, optimization-quality change, allocation-preview reduction or benchmark deadline relaxation is part of this ticket. No separate prefactor is required: retained explanation and hypothetical-analysis behavior already exists.
|
||||
|
||||
## Blocked by
|
||||
|
||||
None — can start immediately.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 02 — Verify integrated planning after trace removal
|
||||
|
||||
**GitLab:** [#77](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/77)
|
||||
|
||||
Blocked by: #76
|
||||
|
||||
## What to build
|
||||
|
||||
Demonstrate that the integrated #70–#75 work retains its supported behavior after trace removal. Provide a functionally verified trace-free baseline for #78, which now owns benchmark performance acceptance and optimization. Benchmark performance is no longer a completion gate for #75 or this functional verification ticket.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Review the integrated contract replacement across all calculation paths and UI callers. Confirm there is no trace recorder, trace transport, acknowledgement wait, historical replay API or retained trace-dependent control or compatibility shell.
|
||||
- [ ] Compare completed outcomes and stable explanations against existing deterministic fixtures, including typed joint obligations, earned-entitlement chains, custom objective formulas, reordered priorities, two- and three-year horizons and no active optimization rules. Do not rewrite placement expectations to hide a behavioral regression.
|
||||
- [ ] Verify real-worker outcome and explanation delivery, heartbeat monitoring, stale-response rejection and explanation failure handling. Exercise cancellation before any valid incumbent and after a valid incumbent, and confirm that partial outcomes remain valid without false conflicts.
|
||||
- [ ] Verify displayed-placement alternative-date analysis and remaining-balance reasons without historical data. Check that captured settings remain immutable and hypothetical alternatives do not change the displayed plan.
|
||||
- [ ] Run the existing full correctness, coverage, static, formatting and build checks appropriate to the integrated change, and resolve regressions within the trace-removal scope. Preserve the repository's required thresholds.
|
||||
- [ ] Complete browser QA for the retained explanation, calendar, cancellation and alternative-date flows, including keyboard interaction and a narrow viewport. Confirm the advanced trace explorer, historical overlays and historical attempt controls are absent.
|
||||
- [ ] Verify that benchmark callers use the supported trace-free calculation contract and retain their correctness, determinism, horizon, placement-count and completed-stage assertions. Full protocol execution, the unchanged 2 GB/300-second performance gates and any required optimization belong to #78.
|
||||
- [ ] Hand off the functionally verified integrated baseline and unchanged benchmark inputs to #78. Verify retained outcome and explanation delivery without waiting for removed trace artifacts or inventing replacement trace counters.
|
||||
- [ ] Keep the original #70–#75 dependency order and report the functional integration result. Apply #75’s revised functional completion criteria: a benchmark performance failure alone does not block #75 or this ticket. Outstanding benchmark requirements remain open in #78, without deadline relaxation or an automatic engine cutoff.
|
||||
|
||||
The removal is agreed; its measured speedup is unresolved and is now evaluated in #78. This ticket owns functional verification after trace removal. It does not itself implement performance optimizations or close other issues.
|
||||
|
||||
## Blocked by
|
||||
|
||||
- #76 — Remove the original decision trace end to end (https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/76).
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# 03 — Verify and optimize planning to meet the benchmarks from #75
|
||||
|
||||
**GitLab:** [#78](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/78)
|
||||
|
||||
Blocked by: #75, #77
|
||||
|
||||
## Parent
|
||||
|
||||
#75 — Performance acceptance separated from the functional migration ticket at the user's request.
|
||||
|
||||
## What to build
|
||||
|
||||
Verify the integrated planner against the benchmark requirements originally attached to #75 and apply performance optimizations when needed until those requirements pass. Start from the functionally verified implementation after original-decision-trace removal. #75 can finish on its functional requirements; performance acceptance belongs to this ticket.
|
||||
|
||||
If the measured baseline already passes, keep the engine unchanged and record the evidence. Otherwise profile the real failing path, apply the smallest complete improvement and verify both performance and retained planning behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Keep the existing seven benchmark scenarios and inputs, including the loaded year, typed joint obligations, chained earned entitlements and two- and three-year horizons. Keep explicit optimization configurations, quotas, expected outcomes, placement counts, completed-stage and determinism assertions.
|
||||
- [ ] Run the real existing benchmark protocol: one warm-up and two measured repetitions per scenario, outside coverage instrumentation, with a 2 GB Node heap limit and the 300-second timeout for the full protocol. All scenarios and repetitions must finish and pass; a timeout, skipped scenario or partial report does not satisfy this ticket.
|
||||
- [ ] After #76 removes tracing and #77 verifies the retained behavior, benchmark outcome and explanation delivery without obsolete trace counters, trace transfer or trace-acknowledgement waits. Do not reintroduce tracing as profiling infrastructure.
|
||||
- [ ] When the baseline fails, profile CPU, allocation and repeated planning work; apply optimizations and rerun the unchanged protocol until it passes. Preserve planning semantics, rule ordering, joint obligations, earned-entitlement chronology, final-plan quality and deterministic tie-breaking. Use behavior-level or differential tests to demonstrate that caching, deduplication or pruning is valid across the relevant configurations.
|
||||
- [ ] Preserve responsive browser interaction, explicit cancellation, heartbeat monitoring and best-valid-partial-plan recovery during representative and long-horizon calculations. Keep the existing duration-estimation contract and recalibrate estimates only when measured results require it.
|
||||
- [ ] Do not increase the benchmark timeout or heap allowance, reduce scenarios or repetitions, weaken correctness assertions, add an automatic elapsed-time engine cutoff, or reduce search breadth or planning quality to make the protocol pass.
|
||||
- [ ] Record reproducible before/after preparation, planning, total-duration, responsiveness and available peak-heap measurements, including environment and protocol. Clearly distinguish actual completed benchmark runs from diagnostics or interrupted measurements and document any reference-hardware checks that could not be performed.
|
||||
- [ ] Run required correctness, coverage, static and build checks after any optimization. Preserve the captured configuration, stable explanations, displayed-placement alternative-date analysis and saved-user-data behavior.
|
||||
- [ ] Update benchmark documentation with the final measured limits and evidence. Close this ticket only after the unchanged benchmark gates pass; a failed measurement remains an open performance requirement here rather than reopening #75 for performance alone.
|
||||
|
||||
## Existing evidence and scope
|
||||
|
||||
The previous real protocol timed out at 300 seconds without an out-of-memory failure. The loaded-year warm-up took about 184 seconds and its first measured repetition started about 252 seconds into the protocol. Original trace recording and repeated allocation previews were observed costs; the benefit of trace removal has not yet been measured. These are starting points for profiling, not permission to change optimization quality.
|
||||
|
||||
This ticket authorizes verification and implementation of performance improvements when needed. Functional migration remains in #75, trace removal in #76 and trace-removal functional verification in #77. Existing CI benchmark limits remain unchanged.
|
||||
|
||||
## Blocked by
|
||||
|
||||
- #75 — Finish the functional saved-rule migration and integrated planning validation.
|
||||
- #77 — Verify integrated planning after trace removal (itself blocked by #76).
|
||||
@@ -20,4 +20,6 @@ After all active rules compare equally, earlier balance expiry and then earlier
|
||||
|
||||
Only active rules drive placement decisions and preference comparisons; disabled or deleted optimization rules have no residual influence through fixed optimization stages or protected metrics. With no active rules, the planner adds no leave placements, even when balances are at risk of expiring, and retains declared non-working days. Final tie-breakers cannot independently trigger new placements.
|
||||
|
||||
Implementation is pending.
|
||||
Implemented in the #70–#75 series. Saved-rule migration and functional verification
|
||||
are covered by #75. Benchmark performance acceptance and any required optimization
|
||||
are tracked separately in #78; the benchmark limits remain unchanged.
|
||||
+38
-13
@@ -1,5 +1,30 @@
|
||||
# Mobile leave-planner benchmark
|
||||
|
||||
## Composable optimization rules: current verification status
|
||||
|
||||
The #70–#75 implementation uses explicit active optimization configurations in
|
||||
the benchmark fixtures. The latest full functional coverage run passes 652 tests
|
||||
across 34 files under the 2 GB Node heap limit, excluding the separate benchmark.
|
||||
This does not establish a passing performance protocol.
|
||||
|
||||
The most recent seven-scenario protocol attempt timed out at its unchanged
|
||||
300-second limit without an out-of-memory failure. The loaded-year warm-up took
|
||||
184.36 seconds; its first measured repetition began about 251.78 seconds into
|
||||
the protocol. These measurements precede the final period-projection cache and
|
||||
the planned original-decision-trace removal, and are not a result for either.
|
||||
|
||||
At the user's request, #75 finishes on saved-rule migration and functional
|
||||
verification. #78 owns benchmark verification and implementation of further
|
||||
optimizations when needed, after #76 removes tracing and #77 verifies the retained
|
||||
behavior. All seven scenarios, one warm-up and two measured repetitions per
|
||||
scenario, the 2 GB heap limit and the 300-second timeout remain required. No
|
||||
automatic elapsed-time planning cutoff or weaker CI gate is introduced.
|
||||
|
||||
The results below document earlier implementations and protocols. They are
|
||||
historical measurements rather than the performance result for composable rules.
|
||||
|
||||
## Reference-phone protocol
|
||||
|
||||
Open the deployed application at `/benchmark` on the reference phone. Tap
|
||||
**Run benchmark**, wait for the seven scenarios to finish, then tap **Copy
|
||||
report** and save the tab-separated output with the date and build identifier.
|
||||
@@ -90,10 +115,10 @@ and each flow sent exactly one planning request. All 1,949 and 3,956 trace
|
||||
packets respectively were acknowledged, carrying 122,621 and 249,234 candidate
|
||||
records without truncation. Heartbeats continued during the producing runs.
|
||||
|
||||
| Horizon | Calendar usable | Later trace delivery | Placements | Maximum UI timer delay before calendar / during trace | Peak page heap before calendar / during trace | Peak Chrome process-tree RSS before calendar / during trace |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Two years | 5.08 s | 2.55 s | 60 | 1.47 s / 0.70 s | 22.7 / 314.6 MiB | 1,143.2 / 1,606.8 MiB |
|
||||
| Three years | 10.86 s | 4.14 s | 90 | 2.11 s / 1.48 s | 22.9 / 597.6 MiB | 1,386.8 / 2,217.8 MiB |
|
||||
| Horizon | Calendar usable | Later trace delivery | Placements | Maximum UI timer delay before calendar / during trace | Peak page heap before calendar / during trace | Peak Chrome process-tree RSS before calendar / during trace |
|
||||
| ----------- | --------------- | -------------------- | ---------- | ----------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Two years | 5.08 s | 2.55 s | 60 | 1.47 s / 0.70 s | 22.7 / 314.6 MiB | 1,143.2 / 1,606.8 MiB |
|
||||
| Three years | 10.86 s | 4.14 s | 90 | 2.11 s / 1.48 s | 22.9 / 597.6 MiB | 1,386.8 / 2,217.8 MiB |
|
||||
|
||||
A 50 ms page timer measured extra event-loop delay and sampled available
|
||||
main-thread JS heap. RSS was sampled every 500 ms across that isolated Chrome
|
||||
@@ -111,10 +136,10 @@ completed stage. An editable input accepted focus while the worker remained
|
||||
active. Both flows kept a 44 px Cancel control, fit the viewport without
|
||||
horizontal overflow, and delivered the original trace after their outcome.
|
||||
|
||||
| Cancellation point | Request to outcome | Request to complete artifacts | Retained placements |
|
||||
| --- | --- | --- | --- |
|
||||
| Calendar preparation | 21.9 ms | 874.8 ms | 0 |
|
||||
| Active holiday-week optimization, keyboard | 21.5 ms | 1,431.7 ms | 90 |
|
||||
| Cancellation point | Request to outcome | Request to complete artifacts | Retained placements |
|
||||
| ------------------------------------------ | ------------------ | ----------------------------- | ------------------- |
|
||||
| Calendar preparation | 21.9 ms | 874.8 ms | 0 |
|
||||
| Active holiday-week optimization, keyboard | 21.5 ms | 1,431.7 ms | 90 |
|
||||
|
||||
No browser errors occurred. Cancellation issued one cancel request and no
|
||||
additional calculation. Separate tests cover late artifact errors and stale
|
||||
@@ -151,11 +176,11 @@ at the worker's terminal artifact delivery. The application's own calibration
|
||||
uses its request timestamp and `afterNextRender` endpoint as described in
|
||||
[planning-duration.md](../planning-duration.md).
|
||||
|
||||
| Horizon | Range before Calculate (with one local sample) | Observed calendar wait | Additional trace delivery | Range after completion | Placements |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| One year | 1–4 s | 1.74 s | 1.23 s | 1–5 s | 30 |
|
||||
| Two years | 2–16 s | 5.08 s | 2.88 s | 2–16 s | 60 |
|
||||
| Three years | 7–44 s | 11.03 s | 5.60 s | 6–39 s | 90 |
|
||||
| Horizon | Range before Calculate (with one local sample) | Observed calendar wait | Additional trace delivery | Range after completion | Placements |
|
||||
| ----------- | ---------------------------------------------- | ---------------------- | ------------------------- | ---------------------- | ---------- |
|
||||
| One year | 1–4 s | 1.74 s | 1.23 s | 1–5 s | 30 |
|
||||
| Two years | 2–16 s | 5.08 s | 2.88 s | 2–16 s | 60 |
|
||||
| Three years | 7–44 s | 11.03 s | 5.60 s | 6–39 s | 90 |
|
||||
|
||||
All three outcomes completed through `dates`, with no intermediate placements,
|
||||
implicit deadline, extra request or date movement. The three-year result
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
# Composable planning objective contract
|
||||
|
||||
The common implementation for tickets #70–#75 is on `feature/optimization-rules-70-75`.
|
||||
`src/app/services/planning-objectives.ts` evaluates supported configurations; rule names and
|
||||
identifiers never enter the evaluator. Supplied and personal optimization rules share this
|
||||
model, editor, persistence and ordered calculation pipeline.
|
||||
|
||||
## Configuration
|
||||
|
||||
`OptimizationConfiguration` contains `elements`, `conditions`, `measure`, `aggregation`,
|
||||
`direction`, and, only for `work-sequence-deficit`, `thresholdDays`. Conditions are a list
|
||||
of distinct selectors, combined with AND; an empty list selects every observed element.
|
||||
Directions are `minimize` and `maximize`. The finite `OPTIMIZATION_CATALOG` contains the
|
||||
following compatible combinations. Each row accepts any subset of its listed conditions.
|
||||
|
||||
| Elements | Applicable conditions | Measure | Aggregations | Measure unit | Parameters |
|
||||
| ---------------------- | ----------------------- | --------------------------- | ------------------------- | --------------------- | ------------------------------------------------------ |
|
||||
| `leave-balances` | `at-risk` | `unplaced-quantity` | `sum`, `count`, `maximum` | working days | none |
|
||||
| `public-holiday-weeks` | `complete` | `public-holiday-week-value` | `sum`, `count`, `maximum` | calendar-day distance | none |
|
||||
| `time-off-periods` | none | `non-working-day-gain` | `sum`, `count`, `maximum` | calendar days | none |
|
||||
| `work-sequences` | `internal`, `non-empty` | `work-sequence-length` | `sum`, `count`, `maximum` | working days | none |
|
||||
| `work-sequences` | `internal`, `non-empty` | `work-sequence-deficit` | `sum`, `count`, `maximum` | working days | `thresholdDays`: positive safe integer in working days |
|
||||
| `leave-placements` | `supporting` | `placement-count` | `sum`, `count`, `maximum` | count | none |
|
||||
|
||||
`count` counts selected elements, including selected elements whose measured value is zero;
|
||||
its unit is always count. `sum` and `maximum` aggregate the selected measured values.
|
||||
All three return **zero on an empty collection**. An empty work sequence has zero deficit,
|
||||
even if a configuration selects it. The maximum of an empty work-sequence collection is zero;
|
||||
the existing optional average statistics keep their separate null semantics.
|
||||
|
||||
`validateOptimizationConfiguration(unknown)` returns `{ valid: true, configuration }` or
|
||||
`{ valid: false, code, message }`. Malformed values use `invalid-optimization-configuration`;
|
||||
unsupported elements/measure/condition/aggregation/parameter combinations use
|
||||
`unsupported-optimization-configuration`. Unknown fields, duplicate conditions, missing,
|
||||
fractional, non-positive, non-finite and unsafe thresholds are rejected. Other measures
|
||||
reject `thresholdDays`. Direct and worker calculations attach each configuration error to
|
||||
its rule, including malformed sparse selector lists; they never replace it with a preset.
|
||||
|
||||
For example, a personal rule can select internal non-empty work sequences, sum their
|
||||
deficits at a threshold of three, and minimize the result. It uses the same measure as
|
||||
the five-day preset. Another can maximize the longest sequence or count complete holiday
|
||||
weeks; composition never dispatches by a preset identifier.
|
||||
|
||||
## Six supplied recipes
|
||||
|
||||
`OPTIMIZATION_PRESETS` provides configurations, without identities, display labels or enabled
|
||||
states. `RuleService` wraps copies in named planning rules and persists the full configuration.
|
||||
|
||||
| Export key | Elements and conditions | Measure and aggregation | Direction |
|
||||
| ---------------------- | --------------------------------------- | ---------------------------------- | --------- |
|
||||
| `unplacedAtRisk` | leave balances, at risk | unplaced quantity, sum | minimize |
|
||||
| `holidayWeekValue` | public-holiday weeks, complete | public-holiday week value, sum | maximize |
|
||||
| `nonWorkingDayGain` | time-off periods | non-working-day gain, sum | maximize |
|
||||
| `longestWorkSequence` | work sequences, including horizon edges | work-sequence length, maximum | minimize |
|
||||
| `shortSequenceDeficit` | internal non-empty work sequences | deficit at `thresholdDays: 5`, sum | minimize |
|
||||
| `supportingPlacements` | leave placements, supporting | placement count, count | minimize |
|
||||
|
||||
## Concrete observations and boundaries
|
||||
|
||||
`OptimizationObservations` carries collections from one concrete valid plan:
|
||||
|
||||
- Leave balances expose unplaced working-day quantity and the at-risk flag. Expiry on either
|
||||
the horizon start or its exclusive end is at risk, preserving the existing classification;
|
||||
placement dates themselves stay within the half-open horizon.
|
||||
- Public-holiday weeks expose their weekday holiday value and whether every other working
|
||||
date is a placement or declared non-working day. Monday through Friday contributions are
|
||||
**1, 2, 3, 2, 1**; several holidays in one week are summed once in that week's observation.
|
||||
Ordinary fully declared weeks never become holiday weeks. A partial horizon only admits
|
||||
a week when every required date outside it is already declared, preserving the existing rule.
|
||||
- Time-off periods carry distinct natural non-working dates; periods are disjoint and include
|
||||
adjacent weekends, holidays and declared days at either horizon edge. Placements and declared
|
||||
working days anchor periods but do not themselves contribute non-working-day gain.
|
||||
- Work sequences carry a working-day length and an `internal` flag. Weekends and holidays
|
||||
neither count nor split them. Both horizon-edge sequences contribute to the maximum and
|
||||
are excluded by the `internal` condition. Internal zero-length observations contribute zero
|
||||
deficit and are excluded by `non-empty`.
|
||||
- Leave placements expose their supporting classification. Declared days are fixed observations
|
||||
and do not consume balances or count as supporting placements.
|
||||
|
||||
`evaluateOptimization(configuration, observations)` validates the configuration and evaluates
|
||||
these collections. The core's `optimizationObservations(state, context, periods?)` builds the
|
||||
collections once; `evaluateStateOptimization(configuration, state, context)` is the shared core
|
||||
seam for a personal rule. The preset metrics call the same evaluator, with no second engine.
|
||||
|
||||
## Ordered rule execution
|
||||
|
||||
Named `type: 'optimization'` rules contain a full `configuration` and preference-only
|
||||
force. Storage, editor buffers, direct calculation snapshots and worker transport copy
|
||||
the nested configuration. The core executes only explicitly submitted active rules;
|
||||
it never adds presets to a request.
|
||||
|
||||
Obligations are solved jointly. Each active preference then runs in the displayed
|
||||
rule order. Disabled rules are excluded before validation and search. A request
|
||||
with no active rules preserves declared days and adds no placements, including
|
||||
when balances are at risk of expiry. Statistics may still report preset metrics;
|
||||
only the active configured objectives affect selection.
|
||||
|
||||
The shared core seams are:
|
||||
|
||||
- `activeObjectiveVector(state, context, throughIndex?)` evaluates active preferences
|
||||
in display order, with unique `optimization:<rule-id>` and `preference:<rule-id>`
|
||||
identities. Optimization measurements use their full configurations; calendar
|
||||
preferences measure actual matching dates/patterns.
|
||||
- `evaluateRuleCandidate(before, candidate, context, throughIndex, dates, sourceDate?,
|
||||
addition?, baseline?, beforeVector?, incumbentVector?)` validates mandatory type/date/pattern memberships and earned
|
||||
entitlement availability and compares the active prefix. Its comparison preserves
|
||||
the source state; technical selection facts compare with the actual current best
|
||||
candidate. It returns the valid state, its full vector and
|
||||
optional trace evaluation. Additions must improve an active prefix objective.
|
||||
- `optimizeRulePlacements(state, context, throughIndex, allowAdd, rule?)` generates
|
||||
single additions/removals/moves, atomic balance reassignments and balance swaps.
|
||||
`relocateForAddition` handles augmenting chains when a scarce balance needs an
|
||||
occupied date. Complete typed calendar patterns are reassigned atomically too.
|
||||
Coordinated family bundles can pass through `evaluateRuleCandidate` and rank through
|
||||
`compareObligationPreviews(candidate, incumbent, context)` without fixed metric guards.
|
||||
- `applyPreferenceStep` dispatches every optimization and calendar window through
|
||||
the same kernel. Earlier preferences replay when a later rule changes their state
|
||||
or creates an earned entitlement.
|
||||
|
||||
Earned entitlements follow actual completed active producer patterns, including patterns
|
||||
already fulfilled by another rule or declared days. Shared finalization reconciles those
|
||||
rights before candidate comparisons, cancellation retention and unchanged-state caching.
|
||||
Availability starts after the complete merged producing period; its own reward cannot
|
||||
finance a placement inside that period. Earning is a validity consequence, rather than
|
||||
an extra objective that an active configuration can suppress.
|
||||
|
||||
`compareObjectiveValues`, `compareObjectiveLists` and `firstDifferentObjective`
|
||||
serve both selection and explanation. Earlier balance expiry and placement dates
|
||||
are final tie-breaks after all active objectives compare equally; they only reshape
|
||||
existing placements. Equal expiry values are compared independently of arbitrary balance
|
||||
identifiers, so earlier placement dates remain the next tie-break. Captured rule identities, names and configurations label the
|
||||
result independently of subsequent editor changes.
|
||||
|
||||
Loaded plans retain the established sampling strategy for flexible move destinations
|
||||
above 24 placements, including non-working-adjacent, weekly and calendar-window dates.
|
||||
Excluded move destinations are recorded as `not-examined`, never as proven rejected.
|
||||
There is no automatic elapsed-time cutoff. Complete holiday weeks, joined time-off
|
||||
periods and work-sequence changes are compared as coordinated valid bundles.
|
||||
Moves that preserve every active objective can be pruned when their final tie-breaks
|
||||
cannot improve. Per-iteration vectors are reused; preset statistics are materialized
|
||||
only for selected incumbents and published plans.
|
||||
|
||||
For large non-earned window obligation searches above the existing exact-branch
|
||||
threshold, the joint feasibility search retains its existing limit. Feasible
|
||||
solutions are ranked by actual active objectives and final ties; the first eight
|
||||
receive full optimization previews. Other feasible solutions are recorded as
|
||||
`not-examined`. Small searches and earned-entitlement chains keep all previews.
|
||||
This deterministic sampling has no elapsed-time cutoff or user-facing setting.
|
||||
|
||||
A deferred optimization addition is supporting only when it provides one of the
|
||||
calendar benefits in the domain definition, rather than merely extending a period.
|
||||
Holiday occurrences and eligible patterns are immutable per request and cached
|
||||
independently of mutable placement states.
|
||||
|
||||
## Saved rules and initialization
|
||||
|
||||
`vacationRules` uses one versioned envelope: `{ version: 1, rules: [...] }`.
|
||||
`RuleService` restores it synchronously before the first duration estimate or calculation.
|
||||
A legacy array, including `[]`, receives loss prevention first, every original record in
|
||||
its original relative order, then the other five presets. Each original raw record is
|
||||
preserved, including names, IDs, dates, parameters, disabled state and a missing legacy
|
||||
force. Getters interpret missing force as preference without rewriting that record.
|
||||
Preset IDs use collision-free suffixes when a personal rule already uses a proposed ID.
|
||||
New users receive the six active presets around the existing Samedi Malin and Christmas
|
||||
calendar defaults.
|
||||
|
||||
The migration and its version are written by one synchronous `setItem`; no separate
|
||||
marker can succeed while the rules fail to save. A failed write leaves the original
|
||||
key intact and permits a later complete save or a fresh reload to retry. Failed reads,
|
||||
malformed saved data and unsupported versions disable writes for that service instance
|
||||
so later editing cannot overwrite unknown persisted user data. Storage unavailable
|
||||
in the runtime leaves an in-memory configuration usable.
|
||||
|
||||
Every later edit, toggle, reorder or deletion saves the full versioned list. Versioned
|
||||
empty lists stay empty; disabled and deleted presets stay that way. With no active
|
||||
rules, eligible unused leave remains visible with its original expiry classification
|
||||
and a translated explanation that the active planning rules justified no addition.
|
||||
Physical availability, occupation, expiry and incomplete-calculation reasons keep
|
||||
precedence where they apply. Declared days remain fixed and consume no leave.
|
||||
|
||||
## Search and trace limits
|
||||
|
||||
These are deterministic search limits, rather than claims of global optimality:
|
||||
|
||||
- Above 24 placements, flexible moves examine the existing non-working-adjacent,
|
||||
declared-adjacent, weekly and calendar-window destinations. Other destinations
|
||||
retain `not-examined` trace records.
|
||||
- Non-earned obligation searches whose estimated branch count exceeds 10,000 retain
|
||||
at most 4,096 feasible solutions. Large window searches rank them by the actual
|
||||
active vector and final ties, then fully preview the first eight; the other
|
||||
feasible solutions are marked `not-examined`. Earned chains and small searches
|
||||
retain all previews.
|
||||
- Calendar bundles rank original donors once by the actual active vector of the
|
||||
source plan with each donor released, then retain that order during recursive
|
||||
allocation. They keep the first prefix-valid allocation and collapse equivalent
|
||||
donor tokens. Work-sequence bundles use the nearest center per balance and the
|
||||
first accepted allocation. These choices can miss another coordinated allocation.
|
||||
- When a completed specialized bundle strictly improves the current active prefix,
|
||||
the kernel commits its full-vector best before scanning generic alternatives for
|
||||
the old state. The old generic neighborhood is marked `not-examined`, then bundles
|
||||
and generic candidates continue from the improved state. Earlier preferences replay
|
||||
and the final expiry/date pass still runs. This scheduling can make a better unseen
|
||||
old-state generic branch unreachable; it is an explicit heuristic rather than an
|
||||
exhaustive comparison of all bundles and moves.
|
||||
- Additions require a strict improvement through the current rule. A custom
|
||||
maximum/minimize unplaced-quantity rule with two tied at-risk CP1 balances can
|
||||
therefore keep both unused: either single addition leaves the maximum at one,
|
||||
while a coordinated addition of both would reduce it to zero. This balance
|
||||
plateau has no specialized bundle generator; the supplied sum/minimize loss
|
||||
preset does consume both.
|
||||
|
||||
A completely enumerated single obligation solution has no alternative to rank, so
|
||||
its discarded full preference preview is skipped; the real ordered execution still
|
||||
runs in full. Earlier rules reuse their completed evaluation only when the complete
|
||||
ordered incoming state matches their last output, including balance quantities and
|
||||
validity, earned activations, placement insertion order, supporting status and rule
|
||||
provenance. The trace records that unchanged state as `not-examined` with the earlier
|
||||
decisions retained. A candidate unable to beat a valid local incumbent already
|
||||
offered to cancellation retention keeps its primary comparison but avoids a duplicate
|
||||
global incumbent comparison. These are exact reuse decisions, not additional sampling.
|
||||
At the first explicit sum/minimize quantity preference, complete single additions can
|
||||
reuse an evaluated funding score when eligible at-risk balances have the same expiry
|
||||
and matching signature across every enabled time-window rule, including obligations.
|
||||
The first original-order funder is preserved; later equivalent alternatives are recorded
|
||||
as `not-examined` without fabricated comparisons. This applies only without active
|
||||
IF_THEN rules or earned activations, without a balance maximum objective, and with a
|
||||
safe combined remaining quantity. Recursive bundles and augmenting allocation retain
|
||||
every funding choice: equal current scores do not prove equivalent future capacity.
|
||||
A first-position sum/minimize unplaced-quantity rule finishes its preference stage when
|
||||
its reconciled value reaches zero. The same bound applies to a prefix consisting of
|
||||
that zero quantity followed only by fully satisfied time-window preferences or maximizing
|
||||
public-holiday-week preferences at their configured upper bound. Windows are capped at
|
||||
their requested quantity. A holiday maximum is evaluated from the same immutable request
|
||||
weeks, marking every week complete and using its configured sum, count or maximum;
|
||||
partial horizon weeks retain the existing declaration and eligibility semantics. The whole bounded stage is recorded as
|
||||
`not-examined`; later preferences, replay after actual state changes and the final
|
||||
full-vector expiry/date pass still run. Other configurations and preference orders
|
||||
retain their search. This schedules lower priorities later; it does not claim an
|
||||
exhaustive full-vector search in the bounded stage.
|
||||
|
||||
When every balance is exhausted and there is no active IF_THEN rule, a complete generic
|
||||
iteration that finds no full-vector improvement can be reused for the identical ordered
|
||||
state at a later preference. Explicit balance assignments use the same dates, validity,
|
||||
provenance and configured move sampling regardless of the current rule. Rule-specific
|
||||
bundles still run, and any balance, placement, supporting, provenance or activation change
|
||||
invalidates reuse. Bound returns and interrupted iterations never admit a state. Reuse
|
||||
is recorded as `not-examined` with the earlier sampled generic decisions retained; the
|
||||
final expiry/date pass still examines its neighborhood.
|
||||
|
||||
Captured trace vectors, scalar facts, placements, ordered date lists and deltas are immutable detached evidence. Bounded
|
||||
request-local indexes share only identical complete values and metadata; the vector
|
||||
index uses recorded date keys and holds at most 65,536 entries, including variants in
|
||||
one bucket. Eviction changes allocation only, with every examined candidate and its
|
||||
ordered factual fields retained. Mutable candidate shells and emitted replacement
|
||||
snapshots remain independent. Search never reads these sharing indexes. Calendar facts also reuse a bounded 4,096-entry request-local cache keyed by the complete occupied date set; balance quantities, earned metadata, provenance and supporting classifications are refreshed separately. Removing a placement copies the placement map without editing surviving entries; later assignment and metadata edits still detach them. Candidate locations are resolved from the latest recorded decision only when a later update or selection needs them, avoiding a permanent index for observation-only candidates.
|
||||
|
||||
Cancellation retains the best valid examined plan under the captured active vector
|
||||
and its original trace. Counterfactual and explanation comparisons use that same
|
||||
snapshot; later editor changes and delayed artifact delivery do not alter it.
|
||||
An explicit caller-owned planning-work budget remains supported; the application
|
||||
introduces no automatic duration-based stop.
|
||||
|
||||
## Confirmed decisions and technical assumptions
|
||||
|
||||
Confirmed by ADR 0008: one composable model for supplied and personal optimization rules;
|
||||
preference-only force; the six recipes' existing business meanings; declared-day and horizon
|
||||
semantics; user-controlled active preference order; expiry then date tie-breaks; preserving
|
||||
saved user data. The integrated implementation replaces the former implicit policy
|
||||
with explicit active rules and performs the one-time saved-data addition.
|
||||
|
||||
The supported configuration boundary is only sum/count/maximum and both directions; empty aggregate
|
||||
zero; deficit threshold is a positive working-day integer, initially five; every listed
|
||||
compatible condition subset and aggregation is supported; no other filters or measures.
|
||||
Public-holiday weights retain the existing business definition rather than becoming a parameter.
|
||||
|
||||
## Verification scope
|
||||
|
||||
Ticket #75 covers saved-rule migration and functional integration of #70–#75.
|
||||
Performance acceptance and any further optimization are tracked in #78, after
|
||||
original-decision-trace removal in #76 and functional verification in #77. The
|
||||
seven-scenario benchmark, its warm-up and two measured repetitions, the 2 GB heap
|
||||
limit and the 300-second timeout remain unchanged. Completing the functional
|
||||
migration does not establish that these performance gates pass.
|
||||
@@ -32,7 +32,7 @@ describe('Application Settings', () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('app.language', JSON.stringify('en'));
|
||||
localStorage.setItem('leaveBalances', savedBalances);
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
localStorage.setItem('unrelated.entry', 'keep this value');
|
||||
mediaQuery = Object.assign(new EventTarget(), {
|
||||
matches: true,
|
||||
@@ -461,7 +461,7 @@ describe('Application Settings', () => {
|
||||
await (await dialog.getHarness(MatSlideToggleHarness)).check();
|
||||
expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true');
|
||||
expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
|
||||
expect(localStorage.getItem('vacationRules')).toBe('[]');
|
||||
expect(localStorage.getItem('vacationRules')).toBe('{"version":1,"rules":[]}');
|
||||
expect(localStorage.getItem('unrelated.entry')).toBe('keep this value');
|
||||
expect(fixture.nativeElement.querySelectorAll('.week-number').length).toBeGreaterThan(
|
||||
0,
|
||||
@@ -490,7 +490,7 @@ describe('Application Settings', () => {
|
||||
TestBed.resetTestingModule();
|
||||
await createApp();
|
||||
expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
|
||||
expect(localStorage.getItem('vacationRules')).toBe('[]');
|
||||
expect(localStorage.getItem('vacationRules')).toBe('{"version":1,"rules":[]}');
|
||||
expect(localStorage.getItem('unrelated.entry')).toBe('keep this value');
|
||||
expect(document.body.classList.contains('dark-mode')).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -41,10 +41,11 @@ describe('leave planner benchmark protocol', () => {
|
||||
String(rttRule.startDate) <= String(cpRule.endDate),
|
||||
).toBe(true);
|
||||
const chain = scenarios.find((scenario) => scenario.name === 'chained earned entitlements');
|
||||
expect(chain?.request.rules?.map((rule) => rule.id)).toEqual([
|
||||
'wednesday-holidays',
|
||||
'thursday-consumer',
|
||||
]);
|
||||
expect(
|
||||
chain?.request.rules
|
||||
?.filter(({ type }) => type !== 'optimization')
|
||||
.map((rule) => rule.id),
|
||||
).toEqual(['wednesday-holidays', 'thursday-consumer']);
|
||||
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PlanningRequest,
|
||||
PlanningTrace,
|
||||
} from '../services/leave-planner.service';
|
||||
import { createOptimizationPresetRules } from '../services/rule-service';
|
||||
|
||||
export interface BenchmarkScenario {
|
||||
name: string;
|
||||
@@ -47,7 +48,8 @@ const balance = (
|
||||
|
||||
export function benchmarkScenarios(): BenchmarkScenario[] {
|
||||
const horizon = { start: '2024-01-01', end: '2025-01-01' };
|
||||
return [
|
||||
const presets = createOptimizationPresetRules();
|
||||
const scenarios: BenchmarkScenario[] = [
|
||||
{
|
||||
name: 'representative year',
|
||||
request: {
|
||||
@@ -169,6 +171,13 @@ export function benchmarkScenarios(): BenchmarkScenario[] {
|
||||
}),
|
||||
),
|
||||
];
|
||||
return scenarios.map((scenario) => ({
|
||||
...scenario,
|
||||
request: {
|
||||
...scenario.request,
|
||||
rules: [presets[0], ...scenario.request.rules, ...presets.slice(1)],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function resultSummary(outcome: PlanningOutcome): {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createOptimizationPresetRules } from '../../services/rule-service';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
@@ -407,6 +408,8 @@ function jointTrace(): PlanningTrace {
|
||||
};
|
||||
}
|
||||
|
||||
const presets = createOptimizationPresetRules();
|
||||
|
||||
describe('CalendarYear planning outcome boundary', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => {
|
||||
@@ -1342,7 +1345,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
);
|
||||
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
|
||||
localStorage.setItem('leaveBalances', '[]');
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
@@ -1421,7 +1424,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
);
|
||||
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
|
||||
localStorage.setItem('leaveBalances', '[]');
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
@@ -1496,7 +1499,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
expiryDate: placementDate,
|
||||
},
|
||||
],
|
||||
rules: [],
|
||||
rules: [presets[0]],
|
||||
declaredDays: [
|
||||
{ date: '2024-08-01', type: 'cp' as const },
|
||||
...(fixedAnchor ? [{ date: '2024-07-31', type: 'other' as const }] : []),
|
||||
@@ -1524,7 +1527,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
),
|
||||
);
|
||||
localStorage.setItem('leaveBalances', '[]');
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
@@ -1582,7 +1585,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
it('previews a growing and shrinking range across months, then saves it once on release', async () => {
|
||||
localStorage.setItem('app.language', '"fr"');
|
||||
localStorage.setItem('leaveBalances', '[]');
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear, UserInput],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
@@ -1749,7 +1752,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
|
||||
it('edits declarations from configuration and restores the preceding result when switched off', async () => {
|
||||
localStorage.setItem('app.language', '"fr"');
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
localStorage.setItem('leaveBalances', '[]');
|
||||
localStorage.setItem(
|
||||
'declaredNonWorkingDays',
|
||||
@@ -1844,7 +1847,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
});
|
||||
|
||||
it('keeps the preceding calendar until a submitted snapshot returns and ignores later edits', async () => {
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
localStorage.setItem('leaveBalances', '[]');
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear, UserInput],
|
||||
@@ -1987,7 +1990,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
let now = 1_000;
|
||||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
|
||||
try {
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
const pending = deferred<PlanningOutcome>();
|
||||
const trace = deferred<PlanningTrace | undefined>();
|
||||
const { calendar, calculation } = calendarFor(
|
||||
@@ -2100,7 +2103,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
let now = 1_000;
|
||||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
|
||||
try {
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
const old = deferred<PlanningOutcome>();
|
||||
const latest = deferred<PlanningOutcome>();
|
||||
const { calendar, calculation } = calendarFor(plan);
|
||||
|
||||
@@ -1536,6 +1536,24 @@ export class CalendarYear {
|
||||
}
|
||||
|
||||
protected objectiveCopy(id: string) {
|
||||
if (id.startsWith('optimization:')) {
|
||||
const ruleId = id.slice('optimization:'.length);
|
||||
const rule = this.activeCalculation?.snapshot.rules.find(
|
||||
(candidate) => candidate.id === ruleId,
|
||||
);
|
||||
const copy = this.tr().planning.explanation.objectives['optimization'];
|
||||
return {
|
||||
...copy,
|
||||
label: copy.label
|
||||
.replace('{rule}', this.ruleLabel(ruleId))
|
||||
.replace(
|
||||
'{summary}',
|
||||
rule && 'configuration' in rule
|
||||
? this.translationService.optimizationSummary(rule.configuration)
|
||||
: '',
|
||||
),
|
||||
};
|
||||
}
|
||||
const key = id.startsWith('preference:') ? 'preference' : id;
|
||||
const copy = this.tr().planning.explanation.objectives[key];
|
||||
const rule =
|
||||
@@ -1645,7 +1663,7 @@ export class CalendarYear {
|
||||
if (stage === 'validation') return 5;
|
||||
if (stage === 'obligations') return 20;
|
||||
if (stage === 'at-risk') return 35;
|
||||
if (stage.startsWith('preference:')) return 50;
|
||||
if (stage.startsWith('preference:') || stage.startsWith('optimization:')) return 50;
|
||||
if (stage === 'holiday-week-value') return 58;
|
||||
if (stage === 'non-working-day-gain') return 65;
|
||||
if (stage === 'longest-work-sequence') return 72;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||
import { RulesPanel } from './rules-panel';
|
||||
import { RuleService, RuleType, type OptimizationRule } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import {
|
||||
OPTIMIZATION_PRESETS,
|
||||
type OptimizationConfiguration,
|
||||
} from '../../services/planning-objectives';
|
||||
import { calculatePlanningInternal } from '../../services/leave-planner.core';
|
||||
|
||||
describe('calendar rule editing and persistence', () => {
|
||||
let fixture: ComponentFixture<RulesPanel>;
|
||||
let service: RuleService;
|
||||
let translations: TranslationService;
|
||||
|
||||
async function openEditor() {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RulesPanel],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
service = TestBed.inject(RuleService);
|
||||
translations = TestBed.inject(TranslationService);
|
||||
translations.setLanguage('fr');
|
||||
fixture = TestBed.createComponent(RulesPanel);
|
||||
fixture.detectChanges();
|
||||
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
await openEditor();
|
||||
for (const rule of service.rules) service.deleteRule(rule.id);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
async function select(label: string, value: string) {
|
||||
const fields: HTMLElement[] = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('mat-form-field'),
|
||||
);
|
||||
const field = fields.find(
|
||||
(candidate) => candidate.querySelector('mat-label')?.textContent?.trim() === label,
|
||||
);
|
||||
const control = field?.querySelector<HTMLElement>('mat-select');
|
||||
if (!control) throw new Error(`Missing labelled control: ${label}`);
|
||||
const labels = (control.getAttribute('aria-labelledby') ?? '')
|
||||
.split(/\s+/)
|
||||
.map((id) => document.getElementById(id)?.textContent?.trim());
|
||||
expect(labels).toContain(label);
|
||||
control.click();
|
||||
await fixture.whenStable();
|
||||
const option = Array.from(document.querySelectorAll<HTMLElement>('mat-option')).find(
|
||||
(candidate) => candidate.textContent?.trim() === value,
|
||||
);
|
||||
if (!option) throw new Error(`Missing translated option: ${value}`);
|
||||
option.click();
|
||||
document.querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
async function add(name: string, configuration: OptimizationConfiguration) {
|
||||
const tr = translations.tr().rules;
|
||||
const buttons: HTMLButtonElement[] = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.add-buttons button'),
|
||||
);
|
||||
buttons.find((button) => button.textContent?.includes(tr.optimization.newRule))!.click();
|
||||
await fixture.whenStable();
|
||||
const nameInput: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('.rule-editor input');
|
||||
nameInput.value = name;
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await select(
|
||||
tr.optimization.elementsLabel,
|
||||
tr.optimization.elements[configuration.elements],
|
||||
);
|
||||
for (const condition of configuration.conditions)
|
||||
await select(tr.optimization.conditionsLabel, tr.optimization.conditions[condition]);
|
||||
await select(
|
||||
tr.optimization.aggregationLabel,
|
||||
tr.optimization.aggregations[configuration.aggregation],
|
||||
);
|
||||
await select(
|
||||
tr.optimization.directionLabel,
|
||||
tr.optimization.directions[configuration.direction],
|
||||
);
|
||||
const actions: HTMLButtonElement[] = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.editor-actions button'),
|
||||
);
|
||||
actions.find((button) => button.textContent?.trim() === tr.add)!.click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function calculate() {
|
||||
let objectiveValues: readonly { id: string; value: number | string }[] = [];
|
||||
const plan = calculatePlanningInternal(
|
||||
{
|
||||
calculationDate: '2024-04-01',
|
||||
horizon: { start: '2024-04-29', end: '2024-05-18' },
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 3,
|
||||
availableFrom: '2024-04-29',
|
||||
expiryDate: '2025-01-01',
|
||||
},
|
||||
],
|
||||
rules: service.rules,
|
||||
},
|
||||
undefined,
|
||||
({ explanation }) => {
|
||||
if (explanation?.kind === 'PlanExplanation')
|
||||
objectiveValues = explanation.placements[0]?.objectiveVector.objectives ?? [];
|
||||
},
|
||||
);
|
||||
if (plan.kind !== 'Plan')
|
||||
throw new Error('Expected a plan from saved editor configuration');
|
||||
return { plan, objectiveValues };
|
||||
}
|
||||
|
||||
it('creates both recipes, reverses their order, edits a compatible aggregation and restores it on reload', async () => {
|
||||
await add('Mes semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue);
|
||||
await add('Mes week-ends', OPTIMIZATION_PRESETS.nonWorkingDayGain);
|
||||
expect(calculate().plan.metrics).toMatchObject({
|
||||
holidayWeekValue: 5,
|
||||
nonWorkingDayGain: 6,
|
||||
});
|
||||
const items: HTMLElement[] = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.rule-item'),
|
||||
);
|
||||
items[1].querySelector<HTMLButtonElement>('.rule-actions button')!.click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(calculate().plan.metrics).toMatchObject({
|
||||
holidayWeekValue: 0,
|
||||
nonWorkingDayGain: 9,
|
||||
});
|
||||
const holiday = service.rules.find(
|
||||
(rule): rule is OptimizationRule =>
|
||||
rule.type === RuleType.OPTIMIZATION &&
|
||||
rule.configuration.elements === 'public-holiday-weeks',
|
||||
)!;
|
||||
fixture.componentInstance.startEdit(holiday, new Event('click'));
|
||||
await fixture.whenStable();
|
||||
await select(
|
||||
translations.tr().rules.optimization.aggregationLabel,
|
||||
translations.tr().rules.optimization.aggregations.count,
|
||||
);
|
||||
fixture.componentInstance.saveEdit();
|
||||
await fixture.whenStable();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem('vacationRules')!).rules[1]?.configuration
|
||||
?.aggregation,
|
||||
)
|
||||
.toBe('count');
|
||||
const saved = service.rules;
|
||||
TestBed.resetTestingModule();
|
||||
await openEditor();
|
||||
expect(service.rules).toEqual(saved);
|
||||
expect(calculate().plan.metrics).toMatchObject({
|
||||
holidayWeekValue: 0,
|
||||
nonWorkingDayGain: 9,
|
||||
});
|
||||
fixture.componentInstance.moveRule(1, -1, new Event('click'));
|
||||
const edited = calculate();
|
||||
expect(edited.plan.metrics.holidayWeekValue).toBe(5);
|
||||
expect(
|
||||
edited.objectiveValues.find(
|
||||
(objective) => objective.id === `optimization:${holiday.id}`,
|
||||
)?.value,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('creates economy, edits its name, moves it with the focusable control and restores disable/delete choices', async () => {
|
||||
await add('Mes semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue);
|
||||
await add('Économiser les jours d’appoint', OPTIMIZATION_PRESETS.supportingPlacements);
|
||||
const economy = service.rules[1];
|
||||
expect(economy).toMatchObject({
|
||||
force: 'preference',
|
||||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
});
|
||||
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
|
||||
const move: HTMLButtonElement = fixture.nativeElement
|
||||
.querySelectorAll('.rule-item')[1]
|
||||
.querySelector('.rule-actions button');
|
||||
move.focus();
|
||||
expect(document.activeElement).toBe(move);
|
||||
move.click();
|
||||
await fixture.whenStable();
|
||||
expect(calculate().plan.placements).toEqual([]);
|
||||
|
||||
fixture.componentInstance.startEdit(service.rules[0], new Event('click'));
|
||||
await fixture.whenStable();
|
||||
const input: HTMLInputElement = fixture.nativeElement.querySelector('.rule-editor input');
|
||||
input.value = 'Mes jours conservés';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
fixture.componentInstance.saveEdit();
|
||||
await fixture.whenStable();
|
||||
const toggle: HTMLButtonElement = fixture.nativeElement
|
||||
.querySelector('.rule-item')
|
||||
.querySelector('button[role="switch"]');
|
||||
toggle.click();
|
||||
await fixture.whenStable();
|
||||
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
|
||||
await expect
|
||||
.poll(() => JSON.parse(localStorage.getItem('vacationRules')!).rules[0]?.enabled)
|
||||
.toBe(false);
|
||||
const saved = service.rules;
|
||||
TestBed.resetTestingModule();
|
||||
await openEditor();
|
||||
expect(service.rules).toEqual(saved);
|
||||
expect(service.rules[0]).toMatchObject({ id: economy.id, name: 'Mes jours conservés' });
|
||||
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
|
||||
fixture.componentInstance.toggleRule(economy.id);
|
||||
await fixture.whenStable();
|
||||
expect(calculate().plan.placements).toEqual([]);
|
||||
const remove: HTMLButtonElement = fixture.nativeElement
|
||||
.querySelector('.rule-item')
|
||||
.querySelector(`[aria-label="${translations.tr().rules.deleteRule}"]`);
|
||||
remove.click();
|
||||
await fixture.whenStable();
|
||||
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
|
||||
await expect
|
||||
.poll(() => JSON.parse(localStorage.getItem('vacationRules')!).rules.length)
|
||||
.toBe(1);
|
||||
TestBed.resetTestingModule();
|
||||
await openEditor();
|
||||
expect(service.rules.map(({ id }) => id)).not.toContain(economy.id);
|
||||
expect(calculate().plan.metrics.supportingPlacements).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -183,6 +183,8 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else if ($any(editBuffer).type === RuleType.OPTIMIZATION) {
|
||||
<ng-container [ngTemplateOutlet]="optimizationEditor" />
|
||||
} @else {
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
@@ -239,6 +241,9 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (validationError()) {
|
||||
<p role="alert">{{ validationError() }}</p>
|
||||
}
|
||||
<div class="editor-actions">
|
||||
<button mat-button (click)="cancelEdit()">
|
||||
{{ tr().rules.cancel }}
|
||||
@@ -338,6 +343,8 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
} @else if ($any(editBuffer).type === RuleType.OPTIMIZATION) {
|
||||
<ng-container [ngTemplateOutlet]="optimizationEditor" />
|
||||
} @else {
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
@@ -394,6 +401,9 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (validationError()) {
|
||||
<p role="alert">{{ validationError() }}</p>
|
||||
}
|
||||
<div class="editor-actions">
|
||||
<button mat-button (click)="cancelEdit()">{{ tr().rules.cancel }}</button>
|
||||
<button mat-flat-button color="primary" (click)="saveEdit()">
|
||||
@@ -411,6 +421,89 @@
|
||||
<button mat-stroked-button (click)="startAddNew(RuleType.TIME_WINDOW)">
|
||||
<mat-icon>add</mat-icon> {{ tr().rules.newTimeWindowRule }}
|
||||
</button>
|
||||
<button mat-stroked-button (click)="startAddNew(RuleType.OPTIMIZATION)">
|
||||
<mat-icon aria-hidden="true">add</mat-icon> {{ tr().rules.optimization.newRule }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
|
||||
<ng-template #optimizationEditor>
|
||||
@if (optimizationConfiguration; as configuration) {
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.optimization.elementsLabel }}</mat-label>
|
||||
<mat-select
|
||||
[ngModel]="configuration.elements"
|
||||
(ngModelChange)="changeOptimizationElements($event)"
|
||||
>
|
||||
@for (elements of optimizationElements; track elements) {
|
||||
<mat-option [value]="elements">{{
|
||||
tr().rules.optimization.elements[elements]
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.optimization.measureLabel }}</mat-label>
|
||||
<mat-select
|
||||
[ngModel]="configuration.measure"
|
||||
(ngModelChange)="changeOptimizationMeasure($event)"
|
||||
>
|
||||
@for (row of optimizationMeasures; track row.measure) {
|
||||
<mat-option [value]="row.measure">{{
|
||||
tr().rules.optimization.measures[row.measure]
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@if (optimizationConditions.length) {
|
||||
<mat-form-field class="full-width">
|
||||
<mat-label>{{ tr().rules.optimization.conditionsLabel }}</mat-label>
|
||||
<mat-select multiple [(ngModel)]="configuration.conditions">
|
||||
@for (condition of optimizationConditions; track condition) {
|
||||
<mat-option [value]="condition">{{
|
||||
tr().rules.optimization.conditions[condition]
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
<div class="editor-row">
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.optimization.aggregationLabel }}</mat-label>
|
||||
<mat-select [(ngModel)]="configuration.aggregation">
|
||||
@for (aggregation of optimizationAggregations; track aggregation) {
|
||||
<mat-option [value]="aggregation">{{
|
||||
tr().rules.optimization.aggregations[aggregation]
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.optimization.directionLabel }}</mat-label>
|
||||
<mat-select [(ngModel)]="configuration.direction">
|
||||
@for (direction of optimizationDirections; track direction) {
|
||||
<mat-option [value]="direction">{{
|
||||
tr().rules.optimization.directions[direction]
|
||||
}}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (configuration.measure === 'work-sequence-deficit') {
|
||||
<mat-form-field>
|
||||
<mat-label>{{ tr().rules.optimization.thresholdLabel }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
required
|
||||
[(ngModel)]="configuration.thresholdDays"
|
||||
/>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
@@ -7,6 +7,8 @@ import { By } from '@angular/platform-browser';
|
||||
import { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
|
||||
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
||||
import { vi } from 'vitest';
|
||||
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
|
||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||
|
||||
describe('RulesPanel', () => {
|
||||
let component: RulesPanel;
|
||||
@@ -36,7 +38,7 @@ describe('RulesPanel', () => {
|
||||
});
|
||||
|
||||
it('should display initial rules from service', () => {
|
||||
expect(component.rules.length).toBe(2);
|
||||
expect(component.rules.length).toBe(8);
|
||||
});
|
||||
|
||||
it('should start adding new If-Then rule', () => {
|
||||
@@ -116,6 +118,69 @@ describe('RulesPanel', () => {
|
||||
expect(component.rules[0].id).toBe(secondRuleId);
|
||||
});
|
||||
|
||||
it('creates, edits and moves an optimization using the keyboard controls with preference-only mode', async () => {
|
||||
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
|
||||
component.startAddNew(RuleType.OPTIMIZATION);
|
||||
fixture.detectChanges();
|
||||
expect(component['forceOptions'].map((option) => option.value)).toEqual(['preference']);
|
||||
expect(fixture.nativeElement.textContent).toContain('Éléments du planning');
|
||||
component['editBuffer'].name = 'Mes pertes';
|
||||
component.saveEdit();
|
||||
await fixture.whenStable();
|
||||
const rule = component.rules.at(-1)!;
|
||||
expect(rule).toMatchObject({
|
||||
name: 'Mes pertes',
|
||||
type: RuleType.OPTIMIZATION,
|
||||
force: 'preference',
|
||||
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
});
|
||||
expect(component.getRuleSummary(rule)).toContain('Quantité non placée');
|
||||
const announce = vi.spyOn(TestBed.inject(LiveAnnouncer), 'announce').mockResolvedValue();
|
||||
const buttons = fixture.nativeElement.querySelectorAll('.rule-item');
|
||||
const moveUp: HTMLButtonElement =
|
||||
buttons[buttons.length - 1].querySelector('.rule-actions button');
|
||||
moveUp.focus();
|
||||
moveUp.click();
|
||||
await fixture.whenStable();
|
||||
expect(component.rules[7].id).toBe(rule.id);
|
||||
expect(document.activeElement).toBe(moveUp);
|
||||
expect(announce).toHaveBeenCalledWith('Mes pertes : position 8 sur 9.');
|
||||
component.startEdit(rule, new Event('click'));
|
||||
component['editBuffer'].name = 'Pertes modifiées';
|
||||
component.saveEdit();
|
||||
expect(component.rules[7].name).toBe('Pertes modifiées');
|
||||
component.toggleRule(rule.id);
|
||||
expect(component.rules[7].enabled).toBe(false);
|
||||
component.deleteRule(rule.id, new Event('click'));
|
||||
expect(component.rules.some((candidate) => candidate.id === rule.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps saved optimization parameters when an edit is cancelled and reports invalid parameters', () => {
|
||||
component.startAddNew(RuleType.OPTIMIZATION);
|
||||
component.saveEdit();
|
||||
const rule = component.rules.at(-1)!;
|
||||
component.startEdit(rule, new Event('click'));
|
||||
const configuration = component['optimizationConfiguration']!;
|
||||
configuration.conditions = [];
|
||||
component.cancelEdit();
|
||||
expect(component.rules.at(-1)).toMatchObject({
|
||||
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
});
|
||||
component.startEdit(rule, new Event('click'));
|
||||
component.changeOptimizationMeasure('work-sequence-deficit');
|
||||
const invalid = component['optimizationConfiguration'];
|
||||
if (invalid?.measure !== 'work-sequence-deficit') throw new Error('Expected deficit');
|
||||
invalid.thresholdDays = 0;
|
||||
component.saveEdit();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain(
|
||||
'seuil entier positif',
|
||||
);
|
||||
expect(component.rules.at(-1)).toMatchObject({
|
||||
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps cancelled drops unchanged and announces the position after a completed drop', () => {
|
||||
const initialIds = component.rules.map((rule) => rule.id);
|
||||
const movedName = component.rules[0].name;
|
||||
@@ -133,8 +198,12 @@ describe('RulesPanel', () => {
|
||||
expect(announce).not.toHaveBeenCalled();
|
||||
|
||||
list.dropped.emit({ ...drop, isPointerOverContainer: true });
|
||||
expect(component.rules.map((rule) => rule.id)).toEqual([...initialIds].reverse());
|
||||
expect(announce).toHaveBeenCalledWith(`${movedName} : position 2 sur 2.`);
|
||||
expect(component.rules.map((rule) => rule.id)).toEqual([
|
||||
initialIds[1],
|
||||
initialIds[0],
|
||||
...initialIds.slice(2),
|
||||
]);
|
||||
expect(announce).toHaveBeenCalledWith(`${movedName} : position 2 sur 8.`);
|
||||
// CDK restores the original DOM before emitting dropped. The new order must
|
||||
// be rendered before this callback returns, without waiting for another frame.
|
||||
expect(fixture.nativeElement.querySelectorAll('.rule-name')[1].textContent).toContain(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RuleType,
|
||||
IfThenRule,
|
||||
TimeWindowRule,
|
||||
OptimizationRule,
|
||||
VacationRule,
|
||||
PlacementStrategy,
|
||||
} from '../../services/rule-service';
|
||||
@@ -25,7 +26,15 @@ import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
||||
import {
|
||||
OPTIMIZATION_CATALOG,
|
||||
OPTIMIZATION_PRESETS,
|
||||
validateOptimizationConfiguration,
|
||||
type OptimizationElements,
|
||||
type OptimizationMeasure,
|
||||
} from '../../services/planning-objectives';
|
||||
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
||||
@@ -43,6 +52,7 @@ import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/d
|
||||
MatSelectModule,
|
||||
MatDatepickerModule,
|
||||
FormsModule,
|
||||
NgTemplateOutlet,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
@@ -74,10 +84,67 @@ export class RulesPanel {
|
||||
protected readonly rewardDayTypes = Object.values(SelectableDayType).filter(
|
||||
(t) => t !== SelectableDayType.ANY,
|
||||
);
|
||||
protected readonly forceOptions = [
|
||||
{ value: 'obligation' as const, label: () => this.tr().rules.obligation },
|
||||
{ value: 'preference' as const, label: () => this.tr().rules.preference },
|
||||
protected get forceOptions() {
|
||||
const preference = {
|
||||
value: 'preference' as const,
|
||||
label: () => this.tr().rules.preference,
|
||||
};
|
||||
return this.editBuffer.type === RuleType.OPTIMIZATION
|
||||
? [preference]
|
||||
: [
|
||||
{ value: 'obligation' as const, label: () => this.tr().rules.obligation },
|
||||
preference,
|
||||
];
|
||||
}
|
||||
protected readonly optimizationElements = [
|
||||
...new Set(OPTIMIZATION_CATALOG.map((row) => row.elements)),
|
||||
];
|
||||
protected get optimizationConfiguration() {
|
||||
return this.editBuffer.type === RuleType.OPTIMIZATION
|
||||
? this.editBuffer.configuration
|
||||
: undefined;
|
||||
}
|
||||
protected get optimizationMeasures() {
|
||||
return OPTIMIZATION_CATALOG.filter(
|
||||
(row) => row.elements === this.optimizationConfiguration?.elements,
|
||||
);
|
||||
}
|
||||
protected get optimizationConditions() {
|
||||
return (
|
||||
this.optimizationMeasures.find(
|
||||
(row) => row.measure === this.optimizationConfiguration?.measure,
|
||||
)?.conditions ?? []
|
||||
);
|
||||
}
|
||||
protected readonly optimizationAggregations = ['sum', 'count', 'maximum'] as const;
|
||||
protected readonly optimizationDirections = ['minimize', 'maximize'] as const;
|
||||
protected validationError = signal('');
|
||||
|
||||
public changeOptimizationElements(elements: OptimizationElements) {
|
||||
this.changeOptimizationMeasure(
|
||||
OPTIMIZATION_CATALOG.find((row) => row.elements === elements)!.measure,
|
||||
);
|
||||
}
|
||||
|
||||
public changeOptimizationMeasure(measure: OptimizationMeasure) {
|
||||
if (this.editBuffer.type !== RuleType.OPTIMIZATION) return;
|
||||
const row = OPTIMIZATION_CATALOG.find((item) => item.measure === measure)!;
|
||||
const previous = this.editBuffer.configuration;
|
||||
const conditions: readonly string[] = row.conditions;
|
||||
this.editBuffer.configuration = {
|
||||
elements: row.elements,
|
||||
conditions:
|
||||
previous?.conditions.filter((condition) => conditions.includes(condition)) ?? [],
|
||||
aggregation: previous?.aggregation ?? 'sum',
|
||||
direction: previous?.direction ?? 'minimize',
|
||||
...(measure === 'work-sequence-deficit'
|
||||
? {
|
||||
measure,
|
||||
thresholdDays: previous?.measure === measure ? previous.thresholdDays : 5,
|
||||
}
|
||||
: { measure }),
|
||||
};
|
||||
}
|
||||
|
||||
protected get daysOfWeek() {
|
||||
const tr = this.tr();
|
||||
@@ -128,9 +195,20 @@ export class RulesPanel {
|
||||
|
||||
public moveRule(index: number, offset: -1 | 1, event: Event) {
|
||||
event.stopPropagation();
|
||||
const trigger = event.currentTarget;
|
||||
const destination = index + offset;
|
||||
if (destination >= 0 && destination < this.rules.length)
|
||||
if (destination >= 0 && destination < this.rules.length) {
|
||||
this.reorderRule(index, destination);
|
||||
this.changeDetector.detectChanges();
|
||||
if (trigger instanceof HTMLButtonElement) {
|
||||
const next = trigger.disabled
|
||||
? trigger.parentElement?.querySelector<HTMLButtonElement>(
|
||||
'button:not([disabled])',
|
||||
)
|
||||
: trigger;
|
||||
next?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private reorderRule(from: number, to: number) {
|
||||
@@ -152,16 +230,29 @@ export class RulesPanel {
|
||||
event.stopPropagation();
|
||||
this.editingRuleId.set(rule.id);
|
||||
this.isAddingNew.set(false);
|
||||
this.editBuffer = { ...rule };
|
||||
this.editBuffer = structuredClone(rule);
|
||||
this.validationError.set('');
|
||||
}
|
||||
|
||||
public cancelEdit() {
|
||||
this.editingRuleId.set(null);
|
||||
this.isAddingNew.set(false);
|
||||
this.editBuffer = {};
|
||||
this.validationError.set('');
|
||||
}
|
||||
|
||||
public saveEdit() {
|
||||
if (!this.editBuffer.name?.trim()) {
|
||||
this.validationError.set(this.tr().rules.optimization.nameRequired);
|
||||
return;
|
||||
}
|
||||
if (this.editBuffer.type === RuleType.OPTIMIZATION) {
|
||||
if (!validateOptimizationConfiguration(this.editBuffer.configuration).valid) {
|
||||
this.validationError.set(this.tr().rules.optimization.invalidConfiguration);
|
||||
return;
|
||||
}
|
||||
this.editBuffer.force = 'preference';
|
||||
}
|
||||
if (this.editBuffer.id) {
|
||||
this.ruleService.updateRule(this.editBuffer as VacationRule);
|
||||
} else {
|
||||
@@ -183,6 +274,7 @@ export class RulesPanel {
|
||||
this.isAddingNew.set(true);
|
||||
this.editingRuleId.set(null);
|
||||
this.newRuleType.set(type);
|
||||
this.validationError.set('');
|
||||
|
||||
if (type === RuleType.IF_THEN) {
|
||||
this.editBuffer = {
|
||||
@@ -196,6 +288,13 @@ export class RulesPanel {
|
||||
rewardDays: 1,
|
||||
rewardType: SelectableDayType.OTHER,
|
||||
} as Partial<IfThenRule>;
|
||||
} else if (type === RuleType.OPTIMIZATION) {
|
||||
this.editBuffer = {
|
||||
name: this.tr().rules.optimization.defaultName,
|
||||
type,
|
||||
force: 'preference',
|
||||
configuration: structuredClone(OPTIMIZATION_PRESETS.unplacedAtRisk),
|
||||
} satisfies Partial<OptimizationRule>;
|
||||
} else {
|
||||
this.editBuffer = {
|
||||
name: this.tr().rules.defaultNewPeriodName,
|
||||
@@ -216,6 +315,8 @@ export class RulesPanel {
|
||||
|
||||
public getRuleSummary(rule: VacationRule): string {
|
||||
const tr = this.tr();
|
||||
if (rule.type === RuleType.OPTIMIZATION)
|
||||
return this.translationService.optimizationSummary(rule.configuration);
|
||||
if (rule.type === RuleType.IF_THEN) {
|
||||
const ifThen = rule as IfThenRule;
|
||||
const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? '';
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||
import { vi } from 'vitest';
|
||||
import { RuleService, RuleType } from '../../services/rule-service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
import { calculatePlanningInternal } from '../../services/leave-planner.core';
|
||||
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
|
||||
import { RulesPanel } from './rules-panel';
|
||||
|
||||
describe('work-sequence rule editor and reload', () => {
|
||||
let fixture: ComponentFixture<RulesPanel>;
|
||||
let component: RulesPanel;
|
||||
|
||||
async function choose(label: string, text: string) {
|
||||
const fields: HTMLElement[] = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('mat-form-field'),
|
||||
);
|
||||
const select = fields
|
||||
.find((field) => field.querySelector('mat-label')?.textContent?.trim() === label)
|
||||
?.querySelector<HTMLElement>('mat-select');
|
||||
if (!select) throw new Error(`Missing select ${label}`);
|
||||
select.click();
|
||||
await fixture.whenStable();
|
||||
const option = Array.from(document.querySelectorAll<HTMLElement>('mat-option')).find(
|
||||
(candidate) => candidate.textContent?.trim() === text,
|
||||
);
|
||||
if (!option) throw new Error(`Missing option ${text}`);
|
||||
option.click();
|
||||
await fixture.whenStable();
|
||||
document.querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
async function setInput(selector: string, value: string) {
|
||||
const input: HTMLInputElement = fixture.nativeElement.querySelector(selector);
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input'));
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RulesPanel],
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
}).compileComponents();
|
||||
TestBed.inject(TranslationService).setLanguage('fr');
|
||||
fixture = TestBed.createComponent(RulesPanel);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
fixture.debugElement.query(By.directive(MatExpansionPanel)).componentInstance.open();
|
||||
});
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('creates threshold five, edits it to three, and restores its effect, order and activation after reload', async () => {
|
||||
component.startAddNew(RuleType.OPTIMIZATION);
|
||||
await fixture.whenStable();
|
||||
await setInput('input:not([type="number"])', 'Mes reprises');
|
||||
await choose('Éléments du planning', 'Séquences de travail');
|
||||
await choose('Mesure', 'Déficit des séquences de travail');
|
||||
await choose('Conditions (toutes doivent être vérifiées)', 'Internes');
|
||||
await choose('Conditions (toutes doivent être vérifiées)', 'Non vides');
|
||||
const threshold: HTMLInputElement =
|
||||
fixture.nativeElement.querySelector('input[type="number"]');
|
||||
expect(threshold.value).toBe('5');
|
||||
expect(threshold.min).toBe('1');
|
||||
expect(threshold.step).toBe('1');
|
||||
expect(fixture.nativeElement.textContent).toContain('Seuil de préférence en jours ouvrés');
|
||||
component.saveEdit();
|
||||
await fixture.whenStable();
|
||||
const rule = component.rules[0];
|
||||
expect(rule).toMatchObject({ configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit });
|
||||
expect(component.getRuleSummary(rule)).toContain(
|
||||
'Déficit des séquences de travail (jours ouvrés)',
|
||||
);
|
||||
const request = {
|
||||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
|
||||
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
|
||||
};
|
||||
expect(calculatePlanningInternal({ ...request, rules: component.rules })).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [{ date: '2024-01-09' }, { date: '2024-01-10' }, { date: '2024-01-11' }],
|
||||
});
|
||||
component.startEdit(rule, new Event('click'));
|
||||
await fixture.whenStable();
|
||||
await setInput('input[type="number"]', '3');
|
||||
component.saveEdit();
|
||||
await fixture.whenStable();
|
||||
expect(component.getRuleSummary(component.rules[0])).toContain(
|
||||
'seuil de préférence : 3 jours ouvrés',
|
||||
);
|
||||
expect(calculatePlanningInternal({ ...request, rules: component.rules })).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [],
|
||||
});
|
||||
|
||||
component.startAddNew(RuleType.OPTIMIZATION);
|
||||
await fixture.whenStable();
|
||||
await setInput('input:not([type="number"])', 'Ma séquence maximale');
|
||||
await choose('Éléments du planning', 'Séquences de travail');
|
||||
await choose('Agrégation', 'Maximum');
|
||||
component.saveEdit();
|
||||
await fixture.whenStable();
|
||||
const maximum = component.rules[1];
|
||||
expect(maximum).toMatchObject({ configuration: OPTIMIZATION_PRESETS.longestWorkSequence });
|
||||
component.toggleRule(maximum.id);
|
||||
await fixture.whenStable();
|
||||
const moveUp: HTMLButtonElement = fixture.nativeElement
|
||||
.querySelectorAll('.rule-actions')[1]
|
||||
.querySelector('button');
|
||||
moveUp.focus();
|
||||
moveUp.click();
|
||||
await fixture.whenStable();
|
||||
const saved = component.rules;
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
TestBed.resetTestingModule();
|
||||
const restored = TestBed.inject(RuleService).rules;
|
||||
expect(restored).toEqual(saved);
|
||||
expect(restored.map(({ id }) => id)).toEqual([maximum.id, rule.id]);
|
||||
expect(restored[0].enabled).toBe(false);
|
||||
expect(restored[1]).toMatchObject({
|
||||
name: 'Mes reprises',
|
||||
enabled: true,
|
||||
configuration: { thresholdDays: 3 },
|
||||
});
|
||||
expect(calculatePlanningInternal({ ...request, rules: restored })).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the saved threshold and shows an accessible error for fractional input', async () => {
|
||||
component.startAddNew(RuleType.OPTIMIZATION);
|
||||
component.changeOptimizationMeasure('work-sequence-deficit');
|
||||
await fixture.whenStable();
|
||||
await setInput('input[type="number"]', '1.5');
|
||||
component.saveEdit();
|
||||
await fixture.whenStable();
|
||||
expect(component.rules).toEqual([]);
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain(
|
||||
'seuil entier positif',
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('input[type="number"]').value).toBe('1.5');
|
||||
});
|
||||
|
||||
it('distinguishes aggregation units from the working-day threshold', () => {
|
||||
const translations = TestBed.inject(TranslationService);
|
||||
expect(
|
||||
translations.optimizationSummary(OPTIMIZATION_PRESETS.longestWorkSequence),
|
||||
).toContain('Longueur des séquences de travail (jours ouvrés)');
|
||||
expect(
|
||||
translations.optimizationSummary({
|
||||
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
aggregation: 'count',
|
||||
}),
|
||||
).toContain('Déficit des séquences de travail (Nombre)');
|
||||
expect(
|
||||
translations.optimizationSummary({
|
||||
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
aggregation: 'count',
|
||||
}),
|
||||
).toContain('seuil de préférence : 5 jours ouvrés');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createOptimizationPresetRules } from '../../services/rule-service';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
|
||||
@@ -14,6 +15,8 @@ import { CalendarSettingsService } from '../../services/calendar-settings-servic
|
||||
import { createPlanningCalculation, PlanningRequest } from '../../services/leave-planner.service';
|
||||
import { TranslationService } from '../../services/translation-service';
|
||||
|
||||
const presets = createOptimizationPresetRules();
|
||||
|
||||
describe('UserInput', () => {
|
||||
let component: UserInput;
|
||||
let fixture: ComponentFixture<UserInput>;
|
||||
@@ -145,7 +148,7 @@ describe('UserInput', () => {
|
||||
},
|
||||
],
|
||||
declaredDays: [{ date: '2024-06-07', type: 'cp' }],
|
||||
rules: [],
|
||||
rules: [presets[3]],
|
||||
});
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const card = root.querySelector<HTMLElement>('.warning-card')!;
|
||||
@@ -162,7 +165,7 @@ describe('UserInput', () => {
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelectorAll('.remaining-history > li').length).toBeGreaterThan(0);
|
||||
expect(details.textContent).toContain('Ces dates ont réellement été examinées');
|
||||
expect(details.textContent).toContain('Maximum de jours travaillés entre congés');
|
||||
expect(details.textContent).toContain(presets[3].name);
|
||||
expect(details.textContent).toContain('Évalué et rejeté');
|
||||
expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
|
||||
expect(JSON.stringify(userInputService.planningTraceSignal())).toBe(trace);
|
||||
@@ -527,7 +530,7 @@ describe('UserInput', () => {
|
||||
);
|
||||
await rule.uncheck();
|
||||
await expect.poll(() => localStorage.getItem('vacationRules')).not.toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem('vacationRules')!)[0].enabled).toBe(false);
|
||||
expect(JSON.parse(localStorage.getItem('vacationRules')!).rules[0].enabled).toBe(false);
|
||||
fixture.nativeElement.querySelector('.calculate-button').click();
|
||||
await fixture.whenStable();
|
||||
|
||||
|
||||
@@ -102,7 +102,8 @@ export class UserInput {
|
||||
const copy = this.tr().userInput;
|
||||
return plan.remainingBalances.map((balance) => {
|
||||
const code =
|
||||
incomplete && ['preserved', 'not-established'].includes(balance.reason.code)
|
||||
incomplete &&
|
||||
['preserved', 'active-priorities', 'not-established'].includes(balance.reason.code)
|
||||
? 'calculation-incomplete'
|
||||
: balance.reason.code;
|
||||
return {
|
||||
@@ -214,6 +215,7 @@ export class UserInput {
|
||||
|
||||
private criterionLabel(name: string): string | undefined {
|
||||
const id = name.replace(/^protected:/, '');
|
||||
if (id.startsWith('optimization:')) return this.ruleName(id.slice('optimization:'.length));
|
||||
const key =
|
||||
id === 'obligations'
|
||||
? 'obligation-validity'
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { Worker as NodeWorker } from 'node:worker_threads';
|
||||
import { vi } from 'vitest';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
import type { OptimizationPlanningRule } from './leave-planner.core';
|
||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||
|
||||
describe('calendar objectives in the real worker', () => {
|
||||
let directory: string;
|
||||
let bundle: string;
|
||||
const threads = new Set<NodeWorker>();
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'vac-calendar-worker-'));
|
||||
bundle = join(directory, 'worker.mjs');
|
||||
const entry = join(directory, 'entry.ts');
|
||||
writeFileSync(
|
||||
entry,
|
||||
`import { parentPort } from 'node:worker_threads';
|
||||
globalThis.self = { postMessage: (message) => parentPort.postMessage(message) };
|
||||
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
|
||||
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
`,
|
||||
);
|
||||
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(new Date('2024-04-01T12:00:00Z'));
|
||||
class BrowserWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
private readonly thread = new NodeWorker(bundle);
|
||||
constructor() {
|
||||
threads.add(this.thread);
|
||||
this.thread.on('message', (data) => this.onmessage?.({ data } as MessageEvent));
|
||||
this.thread.on('error', () => this.onerror?.());
|
||||
}
|
||||
postMessage(message: unknown) {
|
||||
this.thread.postMessage(message);
|
||||
}
|
||||
terminate() {
|
||||
void this.thread.terminate();
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Worker', BrowserWorker);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...threads].map((thread) => thread.terminate()));
|
||||
threads.clear();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
it.each([false, true])(
|
||||
'retains the submitted configuration, plan and artifact order (gain first: %s)',
|
||||
async (gainFirst) => {
|
||||
const holiday: OptimizationPlanningRule = {
|
||||
id: 'holiday',
|
||||
type: 'optimization' as const,
|
||||
name: 'Captured holiday rule',
|
||||
configuration: structuredClone(OPTIMIZATION_PRESETS.holidayWeekValue),
|
||||
};
|
||||
const gain: OptimizationPlanningRule = {
|
||||
id: 'gain',
|
||||
type: 'optimization' as const,
|
||||
name: 'Captured gain rule',
|
||||
configuration: structuredClone(OPTIMIZATION_PRESETS.nonWorkingDayGain),
|
||||
};
|
||||
const rules = gainFirst ? [gain, holiday] : [holiday, gain];
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-04-29', end: '2024-05-18' },
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 3,
|
||||
availableFrom: '2024-04-29',
|
||||
expiryDate: '2025-01-01',
|
||||
},
|
||||
],
|
||||
rules,
|
||||
});
|
||||
holiday.configuration = {
|
||||
...holiday.configuration,
|
||||
conditions: [],
|
||||
direction: 'minimize',
|
||||
};
|
||||
const plan = await calculation.outcome;
|
||||
if (plan.kind !== 'Plan')
|
||||
throw new Error(`Expected a worker plan, received ${plan.kind}`);
|
||||
expect(plan.metrics).toMatchObject(
|
||||
gainFirst
|
||||
? { holidayWeekValue: 0, nonWorkingDayGain: 9 }
|
||||
: { holidayWeekValue: 5, nonWorkingDayGain: 6 },
|
||||
);
|
||||
expect(calculation.snapshot.rules.find((rule) => rule.id === 'holiday')).toMatchObject({
|
||||
name: 'Captured holiday rule',
|
||||
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
|
||||
});
|
||||
const first = `optimization:${rules[0].id}`;
|
||||
const explanation = await calculation.explanation;
|
||||
if (explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected a worker explanation');
|
||||
expect(explanation.placements[0].firstDecisiveObjective).toBe(first);
|
||||
expect((await calculation.trace)?.phases.map((phase) => phase.name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
first,
|
||||
`optimization:${rules[1].id}`,
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
const comparison = await calculation.analyzeCounterfactual(plan.placements[0]);
|
||||
if (comparison.kind !== 'Counterfactual')
|
||||
throw new Error('Expected a worker comparison');
|
||||
expect(
|
||||
comparison.results.find((result) => result.date === plan.placements[0].date)
|
||||
?.comparison?.firstDecisiveObjective,
|
||||
).toBe(first);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'lets economy block or permit the complete holiday bundle in a real worker (economy first: %s)',
|
||||
async (economyFirst) => {
|
||||
const economy: OptimizationPlanningRule = {
|
||||
id: 'economy',
|
||||
name: 'Captured supporting economy',
|
||||
type: 'optimization',
|
||||
configuration: structuredClone(OPTIMIZATION_PRESETS.supportingPlacements),
|
||||
};
|
||||
const holiday: OptimizationPlanningRule = {
|
||||
id: 'holiday',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
|
||||
};
|
||||
const rules = economyFirst ? [economy, holiday] : [holiday, economy];
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2026-11-09', end: '2026-11-14' },
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 2,
|
||||
availableFrom: '2026-11-09',
|
||||
expiryDate: '2027-01-01',
|
||||
},
|
||||
],
|
||||
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
|
||||
rules,
|
||||
});
|
||||
economy.name = 'Edited later';
|
||||
economy.configuration.conditions = [];
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
|
||||
expect(outcome.metrics).toMatchObject(
|
||||
economyFirst
|
||||
? { holidayWeekValue: 0, supportingPlacements: 0 }
|
||||
: { holidayWeekValue: 3, supportingPlacements: 2 },
|
||||
);
|
||||
expect(calculation.snapshot.rules.find(({ id }) => id === 'economy')).toMatchObject({
|
||||
name: 'Captured supporting economy',
|
||||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
});
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.map(({ name }) => name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
`optimization:${rules[0].id}`,
|
||||
`optimization:${rules[1].id}`,
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
const candidates = trace?.phases.flatMap(({ decisions }) =>
|
||||
decisions.flatMap(({ candidates }) => candidates),
|
||||
);
|
||||
expect(candidates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: economyFirst ? 'evaluated-rejected' : 'chosen',
|
||||
dates: ['2026-11-12', '2026-11-13'],
|
||||
comparison: expect.objectContaining({
|
||||
firstDecisiveObjective: economyFirst
|
||||
? 'optimization:economy'
|
||||
: 'optimization:holiday',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const explanation = await calculation.explanation;
|
||||
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
|
||||
if (economyFirst) {
|
||||
expect(outcome.placements).toEqual([]);
|
||||
expect(outcome.remainingBalances[0]).toMatchObject({
|
||||
id: 'cp',
|
||||
quantity: 2,
|
||||
classification: 'deferred',
|
||||
});
|
||||
expect(explanation.placements).toEqual([]);
|
||||
return;
|
||||
}
|
||||
expect(outcome.remainingBalances).toEqual([]);
|
||||
expect(explanation.placements.every(({ supporting }) => supporting)).toBe(true);
|
||||
expect(explanation.placements[0].firstDecisiveObjective).toBe('optimization:holiday');
|
||||
expect(explanation.placements[0].objectiveVector.objectives).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'optimization:economy',
|
||||
value: 2,
|
||||
label: 'Captured supporting economy',
|
||||
}),
|
||||
);
|
||||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
|
||||
expect(
|
||||
analysis.results.find(({ date }) => date === outcome.placements[0].date)?.comparison
|
||||
?.after.objectives,
|
||||
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 2 }));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
analyzeCounterfactualInternal,
|
||||
calculatePlanningInternal,
|
||||
type OptimizationPlanningRule,
|
||||
type PlanningArtifacts,
|
||||
type PlanningRequest,
|
||||
} from './leave-planner.core';
|
||||
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
|
||||
|
||||
const holiday: OptimizationPlanningRule = {
|
||||
id: 'holiday',
|
||||
name: 'Complete public-holiday weeks',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
|
||||
};
|
||||
const gain: OptimizationPlanningRule = {
|
||||
id: 'gain',
|
||||
name: 'Enjoy weekends and public holidays',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
|
||||
};
|
||||
const samedi: PlanningRequest['rules'][number] = {
|
||||
id: 'samedi',
|
||||
type: 'if_then',
|
||||
dayOfWeek: 6,
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: 'other',
|
||||
};
|
||||
const concurrent: PlanningRequest = {
|
||||
horizon: { start: '2024-04-29', end: '2024-05-18' },
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 3,
|
||||
availableFrom: '2024-04-29',
|
||||
expiryDate: '2025-01-01',
|
||||
},
|
||||
],
|
||||
rules: [],
|
||||
};
|
||||
|
||||
function calculate(request: PlanningRequest, calculationDate = '2024-04-01') {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const plan = calculatePlanningInternal(
|
||||
{ ...request, calculationDate },
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (plan.kind !== 'Plan') throw new Error(`Expected a plan, received ${plan.kind}`);
|
||||
return { plan, artifacts };
|
||||
}
|
||||
|
||||
describe('composed calendar objectives', () => {
|
||||
it('follows either displayed order when a completed holiday week competes with greater natural gain', () => {
|
||||
const holidayFirst = calculate({ ...concurrent, rules: [holiday, gain] }).plan;
|
||||
const gainFirst = calculate({ ...concurrent, rules: [gain, holiday] }).plan;
|
||||
expect(holidayFirst.placements.map((placement) => placement.date)).toEqual([
|
||||
'2024-05-06',
|
||||
'2024-05-07',
|
||||
'2024-05-10',
|
||||
]);
|
||||
expect(holidayFirst.metrics).toMatchObject({ holidayWeekValue: 5, nonWorkingDayGain: 6 });
|
||||
expect(gainFirst.placements.map((placement) => placement.date)).toEqual([
|
||||
'2024-04-29',
|
||||
'2024-05-10',
|
||||
'2024-05-17',
|
||||
]);
|
||||
expect(gainFirst.metrics).toMatchObject({ holidayWeekValue: 0, nonWorkingDayGain: 9 });
|
||||
});
|
||||
|
||||
it('regroups existing placements as a complete week without leaving higher-priority at-risk leave unused', () => {
|
||||
const loss: OptimizationPlanningRule = {
|
||||
id: 'loss',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
};
|
||||
const { plan } = calculate({
|
||||
...concurrent,
|
||||
balances: [{ ...concurrent.balances[0], expiryDate: '2024-05-17' }],
|
||||
rules: [loss, holiday, gain],
|
||||
});
|
||||
expect(plan.metrics).toMatchObject({
|
||||
unplacedAtRisk: 0,
|
||||
holidayWeekValue: 5,
|
||||
nonWorkingDayGain: 6,
|
||||
});
|
||||
expect(plan.placements.map((placement) => placement.date)).toEqual([
|
||||
'2024-05-06',
|
||||
'2024-05-07',
|
||||
'2024-05-10',
|
||||
]);
|
||||
expect(plan.placements.every((placement) => !placement.supporting)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves an earlier preference that minimizes supporting placements', () => {
|
||||
const { plan } = calculate({
|
||||
...concurrent,
|
||||
rules: [
|
||||
{
|
||||
id: 'economy',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
},
|
||||
holiday,
|
||||
],
|
||||
});
|
||||
expect(plan.placements).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses edited holiday conditions and aggregation without dispatching by a preset identity', () => {
|
||||
const counted: OptimizationPlanningRule = {
|
||||
...holiday,
|
||||
id: 'my-complete-weeks',
|
||||
configuration: { ...holiday.configuration, aggregation: 'count' },
|
||||
};
|
||||
const { plan, artifacts } = calculate({ ...concurrent, rules: [counted] });
|
||||
expect(plan.metrics.holidayWeekValue).toBe(5);
|
||||
if (artifacts.explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected an explanation');
|
||||
expect(artifacts.explanation.placements[0].objectiveVector.objectives[1]).toMatchObject({
|
||||
id: 'optimization:my-complete-weeks',
|
||||
value: 1,
|
||||
});
|
||||
const allWeeks = calculate({
|
||||
...concurrent,
|
||||
rules: [{ ...counted, configuration: { ...counted.configuration, conditions: [] } }],
|
||||
}).plan;
|
||||
expect(allWeeks.placements).toEqual([]);
|
||||
});
|
||||
|
||||
it('attributes a coordinated holiday addition and its explanation to the active rule', () => {
|
||||
const { plan, artifacts } = calculate(
|
||||
{
|
||||
horizon: { start: '2026-11-09', end: '2026-11-14' },
|
||||
balances: [
|
||||
{
|
||||
id: 'deferred-cp',
|
||||
type: 'cp',
|
||||
quantity: 2,
|
||||
availableFrom: '2026-11-01',
|
||||
expiryDate: '2027-01-01',
|
||||
},
|
||||
],
|
||||
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [holiday],
|
||||
},
|
||||
'2026-11-01',
|
||||
);
|
||||
expect(plan.placements).toEqual([
|
||||
expect.objectContaining({ date: '2026-11-12', ruleIds: ['holiday'], supporting: true }),
|
||||
expect.objectContaining({ date: '2026-11-13', ruleIds: ['holiday'], supporting: true }),
|
||||
]);
|
||||
if (artifacts.explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected an explanation');
|
||||
for (const placement of artifacts.explanation.placements) {
|
||||
expect(placement.firstDecisiveObjective).toBe('optimization:holiday');
|
||||
expect(placement.contributions).toContainEqual(
|
||||
expect.objectContaining({ kind: 'rule', ruleId: 'holiday' }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each<OptimizationConfiguration>([
|
||||
{ ...gain.configuration, aggregation: 'maximum' },
|
||||
{ ...gain.configuration, aggregation: 'count', direction: 'minimize' },
|
||||
])(
|
||||
'bridges fixed periods for the personal configuration $aggregation/$direction',
|
||||
(configuration) => {
|
||||
const { plan, artifacts } = calculate({
|
||||
horizon: { start: '2024-01-05', end: '2024-01-13' },
|
||||
balances: [
|
||||
{
|
||||
id: 'rtt',
|
||||
type: 'rtt',
|
||||
quantity: 4,
|
||||
availableFrom: '2024-01-05',
|
||||
expiryDate: '2025-01-01',
|
||||
},
|
||||
],
|
||||
declaredDays: ['2024-01-05', '2024-01-12'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [
|
||||
{
|
||||
...gain,
|
||||
id: 'personal-maximum',
|
||||
configuration,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(plan.placements.map((placement) => placement.date)).toEqual([
|
||||
'2024-01-08',
|
||||
'2024-01-09',
|
||||
'2024-01-10',
|
||||
'2024-01-11',
|
||||
]);
|
||||
expect(plan.timeOffPeriods).toHaveLength(1);
|
||||
expect(plan.timeOffPeriods[0].nonWorkingDates).toEqual([
|
||||
'2024-01-06',
|
||||
'2024-01-07',
|
||||
'2024-01-13',
|
||||
'2024-01-14',
|
||||
]);
|
||||
expect(plan.metrics.nonWorkingDayGain).toBe(4);
|
||||
expect(
|
||||
plan.placements.every(({ ruleIds }) => ruleIds.includes('personal-maximum')),
|
||||
).toBe(true);
|
||||
if (artifacts.explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected an explanation');
|
||||
for (const placement of artifacts.explanation.placements)
|
||||
expect(placement.contributions).toContainEqual(
|
||||
expect.objectContaining({ kind: 'rule', ruleId: 'personal-maximum' }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'finishes a rejected bridge with an irrelevant typed rule: %s',
|
||||
(withSamedi) => {
|
||||
const { plan } = calculate({
|
||||
horizon: { start: '2024-01-05', end: '2024-02-06' },
|
||||
balances: (['cp', 'rtt', 'other'] as const).map((type) => ({
|
||||
id: type,
|
||||
type,
|
||||
quantity: 20,
|
||||
availableFrom: '2024-01-05',
|
||||
expiryDate: '2025-01-01',
|
||||
})),
|
||||
declaredDays: ['2024-01-05', '2024-02-05'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [
|
||||
{
|
||||
id: 'economy',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
},
|
||||
{
|
||||
...gain,
|
||||
configuration: {
|
||||
...gain.configuration,
|
||||
aggregation: 'count',
|
||||
direction: 'minimize',
|
||||
},
|
||||
},
|
||||
...(withSamedi ? [samedi] : []),
|
||||
],
|
||||
deadlineMs: 2_000,
|
||||
});
|
||||
expect(plan.placements).toEqual([]);
|
||||
expect(plan.timeOffPeriods).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not label a period-count bridge supporting when it adds no domain calendar benefit', () => {
|
||||
const { plan } = calculate({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-31' },
|
||||
balances: [{ ...concurrent.balances[0], quantity: 5, availableFrom: '2024-01-02' }],
|
||||
declaredDays: ['2024-01-05', '2024-01-15'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [
|
||||
{
|
||||
...gain,
|
||||
configuration: {
|
||||
...gain.configuration,
|
||||
aggregation: 'count',
|
||||
direction: 'minimize',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(plan.timeOffPeriods).toHaveLength(1);
|
||||
expect(plan.placements).toHaveLength(5);
|
||||
expect(plan.placements.every((placement) => !placement.supporting)).toBe(true);
|
||||
expect(plan.metrics.nonWorkingDayGain).toBe(4);
|
||||
});
|
||||
|
||||
it('can place the holiday preference before or after Samedi Malin', () => {
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2021-11-01', end: '2021-12-29' },
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 4,
|
||||
availableFrom: '2021-11-01',
|
||||
expiryDate: '2022-01-01',
|
||||
},
|
||||
],
|
||||
rules: [],
|
||||
};
|
||||
const first = calculate({ ...request, rules: [holiday, samedi] }, '2021-10-01').plan;
|
||||
const second = calculate({ ...request, rules: [samedi, holiday] }, '2021-10-01').plan;
|
||||
expect(first.placements.map((placement) => placement.date)).toEqual([
|
||||
'2021-11-08',
|
||||
'2021-11-09',
|
||||
'2021-11-10',
|
||||
'2021-11-12',
|
||||
]);
|
||||
expect(first.metrics.holidayWeekValue).toBe(2);
|
||||
expect(first.preferences[0].satisfied).toBe(0);
|
||||
expect(second.placements.map((placement) => placement.date)).toEqual([
|
||||
'2021-12-23',
|
||||
'2021-12-24',
|
||||
]);
|
||||
expect(second.metrics.holidayWeekValue).toBe(0);
|
||||
expect(second.preferences[0].satisfied).toBe(1);
|
||||
expect(second.remainingBalances.find((balance) => balance.earned)).toMatchObject({
|
||||
id: 'earned:samedi:2021-12-25',
|
||||
quantity: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['obligation', 'preference'] as const)(
|
||||
'preserves a higher %s when completing a week would take its last balance',
|
||||
(force) => {
|
||||
const { plan } = calculate({
|
||||
...concurrent,
|
||||
rules: [
|
||||
{
|
||||
id: 'monday',
|
||||
type: 'time_window',
|
||||
force,
|
||||
startDate: '2024-04-29',
|
||||
endDate: '2024-04-29',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
holiday,
|
||||
],
|
||||
});
|
||||
expect(plan.placements).toMatchObject([{ date: '2024-04-29', ruleIds: ['monday'] }]);
|
||||
expect(plan.metrics.holidayWeekValue).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('adds no residual loss-prevention or calendar placements when their rules are disabled', () => {
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-04-29', end: '2024-05-04' },
|
||||
balances: [{ ...concurrent.balances[0], quantity: 5, expiryDate: '2024-05-03' }],
|
||||
rules: [holiday],
|
||||
};
|
||||
const absent = calculate(request).plan;
|
||||
const disabled = calculate({
|
||||
...request,
|
||||
rules: [
|
||||
holiday,
|
||||
{
|
||||
id: 'loss',
|
||||
type: 'optimization',
|
||||
enabled: false,
|
||||
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
},
|
||||
{ ...gain, enabled: false },
|
||||
],
|
||||
}).plan;
|
||||
expect(disabled).toEqual(absent);
|
||||
expect(absent.metrics.unplacedAtRisk).toBe(1);
|
||||
expect(absent.metrics.holidayWeekValue).toBe(3);
|
||||
expect(absent.placements).toHaveLength(4);
|
||||
expect(
|
||||
calculate({ ...request, rules: [{ ...holiday, enabled: false }] }).plan.placements,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ start: '2024-05-06', end: '2024-05-11' },
|
||||
{ start: '2024-05-10', end: '2024-05-11' },
|
||||
])(
|
||||
'uses declarations outside the horizon to complete a genuine holiday week ($start)',
|
||||
(horizon) => {
|
||||
const { plan } = calculate({
|
||||
horizon,
|
||||
balances: [{ ...concurrent.balances[0], quantity: 1, availableFrom: '2024-05-06' }],
|
||||
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [holiday],
|
||||
});
|
||||
expect(plan.placements).toMatchObject([{ date: '2024-05-10', supporting: true }]);
|
||||
expect(plan.metrics).toMatchObject({ holidayWeekValue: 5, nonWorkingDayGain: 6 });
|
||||
},
|
||||
);
|
||||
|
||||
it('cannot fill missing weekdays outside the horizon or with unavailable balances', () => {
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-05-10', end: '2024-05-11' },
|
||||
balances: [{ ...concurrent.balances[0], quantity: 1 }],
|
||||
rules: [holiday],
|
||||
};
|
||||
expect(calculate(request).plan.placements).toEqual([]);
|
||||
const unavailable = calculate({
|
||||
...request,
|
||||
balances: [{ ...request.balances[0], availableFrom: '2024-05-11' }],
|
||||
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
|
||||
}).plan;
|
||||
expect(unavailable.placements).toEqual([]);
|
||||
expect(unavailable.metrics.holidayWeekValue).toBe(0);
|
||||
});
|
||||
|
||||
it('measures only distinct natural dates and leaves ordinary declared weeks ordinary', () => {
|
||||
const christmas = calculate({
|
||||
horizon: { start: '2021-12-24', end: '2021-12-25' },
|
||||
balances: [],
|
||||
declaredDays: [{ date: '2021-12-24', type: 'other' }],
|
||||
rules: [holiday, gain],
|
||||
}).plan;
|
||||
expect(christmas.metrics).toMatchObject({ holidayWeekValue: 0, nonWorkingDayGain: 2 });
|
||||
expect(christmas.timeOffPeriods[0].nonWorkingDates).toEqual(['2021-12-25', '2021-12-26']);
|
||||
const ordinary = calculate({
|
||||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||||
balances: [],
|
||||
declaredDays: ['08', '09', '10', '11', '12'].map((day) => ({
|
||||
date: `2024-01-${day}`,
|
||||
type: 'other',
|
||||
})),
|
||||
rules: [holiday],
|
||||
}).plan;
|
||||
expect(ordinary.metrics.holidayWeekValue).toBe(0);
|
||||
});
|
||||
|
||||
it('uses the same first calendar objective in the plan explanation, trace and date comparison', () => {
|
||||
for (const rules of [
|
||||
[holiday, gain],
|
||||
[gain, holiday],
|
||||
]) {
|
||||
const request = { ...concurrent, rules };
|
||||
const { plan, artifacts } = calculate(request);
|
||||
const explanation = artifacts.explanation;
|
||||
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
|
||||
const first = `optimization:${rules[0].id}`;
|
||||
expect(explanation.placements[0].firstDecisiveObjective).toBe(first);
|
||||
expect(
|
||||
explanation.placements[0].objectiveVector.objectives.map(
|
||||
(objective) => objective.id,
|
||||
),
|
||||
).toEqual([
|
||||
'obligation-validity',
|
||||
first,
|
||||
`optimization:${rules[1].id}`,
|
||||
'balance-expiry',
|
||||
'placement-date',
|
||||
]);
|
||||
const comparisons = artifacts.trace?.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.flatMap((decision) => decision.candidates)
|
||||
.filter((candidate) => candidate.status === 'chosen' && candidate.comparison);
|
||||
expect(
|
||||
comparisons?.some(
|
||||
(candidate) => candidate.comparison?.firstDecisiveObjective === first,
|
||||
),
|
||||
).toBe(true);
|
||||
const placement = rules[0].id === 'holiday' ? plan.placements[1] : plan.placements[0];
|
||||
const alternative = rules[0].id === 'holiday' ? '2024-04-30' : '2024-05-02';
|
||||
const comparison = analyzeCounterfactualInternal(
|
||||
request,
|
||||
plan,
|
||||
placement,
|
||||
artifacts.finalState,
|
||||
{ removeDate: placement.date, compareOriginalState: true },
|
||||
);
|
||||
expect(
|
||||
comparison.results.find((result) => result.date === alternative)?.comparison
|
||||
?.firstDecisiveObjective,
|
||||
).toBe(first);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createOptimizationPresetRules } from './rule-service';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
createPlanningCalculation,
|
||||
@@ -14,6 +15,8 @@ const request = {
|
||||
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const presets = createOptimizationPresetRules();
|
||||
|
||||
describe('user cancellation', () => {
|
||||
const workers: CancellableWorker[] = [];
|
||||
const currentWorker = () => workers[workers.length - 1];
|
||||
@@ -51,7 +54,7 @@ describe('user cancellation', () => {
|
||||
const submitted = {
|
||||
horizon: { start: '2024-01-02', end: '2024-01-09' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
rules: [presets[2]],
|
||||
declaredDays: [{ date: '2024-01-05', type: 'other' as const }],
|
||||
};
|
||||
let interrupted = false;
|
||||
@@ -60,7 +63,7 @@ describe('user cancellation', () => {
|
||||
{
|
||||
...submitted,
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'non-working-day-gain') interrupted = true;
|
||||
if (stage === 'optimization:default-non-working-day-gain') interrupted = true;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
@@ -115,14 +118,17 @@ describe('user cancellation', () => {
|
||||
{ date: '2024-01-11', type: 'rtt' as const },
|
||||
],
|
||||
rules: [
|
||||
{ id: 'exact-cp', force: 'obligation', placeDays: 2, placeType: 'cp' },
|
||||
{ id: 'preferred-rtt', force: 'preference', placeDays: 1, placeType: 'rtt' },
|
||||
].map((rule) => ({
|
||||
...rule,
|
||||
type: 'time_window',
|
||||
startDate: '2024-01-08',
|
||||
endDate: '2024-01-12',
|
||||
})),
|
||||
presets[1],
|
||||
...[
|
||||
{ id: 'exact-cp', force: 'obligation', placeDays: 2, placeType: 'cp' },
|
||||
{ id: 'preferred-rtt', force: 'preference', placeDays: 1, placeType: 'rtt' },
|
||||
].map((rule) => ({
|
||||
...rule,
|
||||
type: 'time_window',
|
||||
startDate: '2024-01-08',
|
||||
endDate: '2024-01-12',
|
||||
})),
|
||||
],
|
||||
};
|
||||
let interrupted = false;
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
@@ -130,7 +136,7 @@ describe('user cancellation', () => {
|
||||
{
|
||||
...submitted,
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'holiday-week-value') interrupted = true;
|
||||
if (stage === 'optimization:default-holiday-week-value') interrupted = true;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
@@ -201,18 +207,22 @@ describe('user cancellation', () => {
|
||||
horizon: request.horizon,
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
|
||||
rules: [
|
||||
{
|
||||
id: 'january',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
},
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'january',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
...presets.slice(1),
|
||||
],
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'longest-work-sequence') cancelled = true;
|
||||
if (stage === 'optimization:default-longest-work-sequence') cancelled = true;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
@@ -287,7 +297,7 @@ describe('user cancellation', () => {
|
||||
const submitted = {
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
};
|
||||
const cancelled = await core.calculatePlanningAsyncInternal(
|
||||
{
|
||||
@@ -295,7 +305,7 @@ describe('user cancellation', () => {
|
||||
traceProgress: (_: number, update: PlanningTraceUpdate) =>
|
||||
updates.push(structuredClone(update)),
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'non-working-day-gain') interrupted = true;
|
||||
if (stage === 'optimization:default-non-working-day-gain') interrupted = true;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createOptimizationPresetRules } from './rule-service';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPlanningCalculation, applyReplayDeltas } from './leave-planner.service';
|
||||
|
||||
@@ -15,6 +16,8 @@ afterEach(() => vi.unstubAllGlobals());
|
||||
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const presets = createOptimizationPresetRules();
|
||||
|
||||
describe('planner explanation and trace seam', () => {
|
||||
it('replays a reported occurrence without borrowing its future balance from the final plan', async () => {
|
||||
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
|
||||
@@ -180,7 +183,7 @@ describe('planner explanation and trace seam', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('removes an entitlement from the original replay when a real quality move shortens its generator into today', async () => {
|
||||
it('replays an already-reported entitlement without inventing a transient earned balance under explicit priorities', async () => {
|
||||
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
|
||||
const core = await import('./leave-planner.core');
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
@@ -201,17 +204,21 @@ describe('planner explanation and trace seam', () => {
|
||||
{ date: '2024-05-03', type: 'cp' },
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force: 'preference',
|
||||
dayOfWeek: 3,
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
placement: 'after',
|
||||
rewardDays: 1,
|
||||
rewardType: 'other',
|
||||
},
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force: 'preference',
|
||||
dayOfWeek: 3,
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
placement: 'after',
|
||||
rewardDays: 1,
|
||||
rewardType: 'other',
|
||||
},
|
||||
],
|
||||
...presets.slice(1),
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
@@ -226,11 +233,16 @@ describe('planner explanation and trace seam', () => {
|
||||
added: false,
|
||||
});
|
||||
const decisions = artifacts.trace.phases.flatMap(({ decisions }) => decisions);
|
||||
expect(decisions.flatMap(({ deltas }) => deltas)).toContainEqual({
|
||||
kind: 'balance-removed',
|
||||
balanceId: 'earned:producer:2024-05-01',
|
||||
remainingBefore: 1,
|
||||
});
|
||||
const deltas = decisions.flatMap(({ deltas }) => deltas);
|
||||
expect(deltas).toContainEqual(
|
||||
expect.objectContaining({ kind: 'balance-changed', balanceId: 'cp' }),
|
||||
);
|
||||
expect(deltas).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'balance-changed',
|
||||
balanceId: 'earned:producer:2024-05-01',
|
||||
}),
|
||||
);
|
||||
const initial = artifacts.trace.initialState;
|
||||
const replay = {
|
||||
balances: initial.balances.map((balance) => ({ ...balance })),
|
||||
@@ -355,14 +367,16 @@ describe('planner explanation and trace seam', () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-07')],
|
||||
rules: [],
|
||||
rules: [presets[0]],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
|
||||
const atRisk = trace.phases.find((phase) => phase.name === 'at-risk');
|
||||
const atRisk = trace.phases.find(
|
||||
(phase) => phase.name === 'optimization:default-unplaced-at-risk',
|
||||
);
|
||||
const jan2 = atRisk?.decisions.find((decision) =>
|
||||
decision.deltas.some(
|
||||
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-02',
|
||||
@@ -374,22 +388,17 @@ describe('planner explanation and trace seam', () => {
|
||||
),
|
||||
);
|
||||
expect(jan2?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
|
||||
expect(jan2?.candidates[0]).toMatchObject({
|
||||
expect(jan2?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
|
||||
technicalComparison: [
|
||||
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
|
||||
{
|
||||
name: 'preference-date-conflict',
|
||||
name: `optimization:${presets[0].id}`,
|
||||
direction: 'minimize',
|
||||
before: 0,
|
||||
after: 0,
|
||||
},
|
||||
{
|
||||
name: 'placement-date',
|
||||
direction: 'minimize',
|
||||
before: '2024-01-02',
|
||||
after: '2024-01-03',
|
||||
before: 2,
|
||||
after: 1,
|
||||
},
|
||||
],
|
||||
firstDecisiveComparator: 'placement-date',
|
||||
firstDecisiveComparator: `optimization:${presets[0].id}`,
|
||||
});
|
||||
expect(jan2?.deltas).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -399,22 +408,17 @@ describe('planner explanation and trace seam', () => {
|
||||
}),
|
||||
);
|
||||
expect(jan3?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
|
||||
expect(jan3?.candidates[0]).toMatchObject({
|
||||
expect(jan3?.candidates.find((candidate) => candidate.status === 'chosen')).toMatchObject({
|
||||
technicalComparison: [
|
||||
{ name: 'obligation-validity', direction: 'minimize', before: 0, after: 0 },
|
||||
{
|
||||
name: 'preference-date-conflict',
|
||||
name: `optimization:${presets[0].id}`,
|
||||
direction: 'minimize',
|
||||
before: 0,
|
||||
before: 1,
|
||||
after: 0,
|
||||
},
|
||||
{
|
||||
name: 'placement-date',
|
||||
direction: 'minimize',
|
||||
before: '2024-01-03',
|
||||
after: '2024-01-04',
|
||||
},
|
||||
],
|
||||
firstDecisiveComparator: 'placement-date',
|
||||
firstDecisiveComparator: `optimization:${presets[0].id}`,
|
||||
});
|
||||
expect(jan3?.deltas).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -541,29 +545,39 @@ describe('planner explanation and trace seam', () => {
|
||||
?.decisions.flatMap((decision) => decision.candidates)
|
||||
.find((item) => item.status === 'chosen' && item.date === '2024-01-03');
|
||||
expect(candidate).toMatchObject({
|
||||
technicalComparison: [
|
||||
{
|
||||
name: 'shared-placement-type',
|
||||
direction: 'maximize',
|
||||
before: 0,
|
||||
after: 0,
|
||||
},
|
||||
{
|
||||
name: 'protected-preference-type',
|
||||
direction: 'maximize',
|
||||
before: 1,
|
||||
after: 1,
|
||||
},
|
||||
{
|
||||
name: 'balance-expiry',
|
||||
direction: 'minimize',
|
||||
before: '2025-01-05',
|
||||
after: '2025-01-06',
|
||||
},
|
||||
],
|
||||
firstDecisiveComparator: 'balance-expiry',
|
||||
firstDecisiveComparator: 'preference:rtt-preference',
|
||||
placement: expect.objectContaining({ balanceId: 'rtt-first' }),
|
||||
});
|
||||
const compared = trace.phases
|
||||
.find((phase) => phase.name === 'preference:any-preference')
|
||||
?.decisions.flatMap((decision) => decision.candidates);
|
||||
expect(compared).toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: 'evaluated-rejected',
|
||||
placement: expect.objectContaining({ balanceId: 'rtt-second' }),
|
||||
technicalComparison: expect.arrayContaining([
|
||||
{
|
||||
name: 'preference:any-preference',
|
||||
direction: 'maximize',
|
||||
before: 1,
|
||||
after: 1,
|
||||
},
|
||||
{
|
||||
name: 'preference:rtt-preference',
|
||||
direction: 'maximize',
|
||||
before: 1,
|
||||
after: 1,
|
||||
},
|
||||
{
|
||||
name: 'balance-expiry',
|
||||
direction: 'minimize',
|
||||
before: '2025-01-05',
|
||||
after: '2025-01-06',
|
||||
},
|
||||
]),
|
||||
firstDecisiveComparator: 'balance-expiry',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records actual IfThen leaf comparisons and selected branch identity', async () => {
|
||||
@@ -593,8 +607,8 @@ describe('planner explanation and trace seam', () => {
|
||||
.flatMap((decision) =>
|
||||
decision.candidates
|
||||
.filter((candidate) =>
|
||||
candidate.technicalComparison?.some((item) =>
|
||||
item.name.startsWith('complete-occurrences:if-then-preference'),
|
||||
candidate.technicalComparison?.some(
|
||||
(item) => item.name === 'preference:if-then-preference',
|
||||
),
|
||||
)
|
||||
.map((candidate) => ({ candidate, decision })),
|
||||
@@ -605,8 +619,8 @@ describe('planner explanation and trace seam', () => {
|
||||
.find((phase) => phase.name === 'preference:if-then-preference')
|
||||
?.decisions.flatMap((decision) => decision.candidates)
|
||||
.filter((candidate) =>
|
||||
candidate.technicalComparison?.some((item) =>
|
||||
item.name.startsWith('complete-occurrences:if-then-preference'),
|
||||
candidate.technicalComparison?.some(
|
||||
(item) => item.name === 'preference:if-then-preference',
|
||||
),
|
||||
) ?? [];
|
||||
expect(preferenceLeaves.length).toBeGreaterThan(1);
|
||||
@@ -620,16 +634,14 @@ describe('planner explanation and trace seam', () => {
|
||||
expect(chosen?.candidate.technicalComparison).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'complete-occurrences:if-then-preference',
|
||||
name: 'preference:if-then-preference',
|
||||
direction: 'maximize',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'future-pattern-conflict-score:if-then-preference',
|
||||
direction: 'minimize',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(chosen?.decision.branch?.deltas.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
(chosen?.decision.branch?.deltas ?? chosen?.decision.deltas ?? []).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
chosen?.candidate.dates?.every((date) =>
|
||||
outcome.placements.some((p) => p.date === date),
|
||||
@@ -749,46 +761,52 @@ describe('planner explanation and trace seam', () => {
|
||||
horizon: { start: '2024-04-25', end: '2024-05-08' },
|
||||
balances: [
|
||||
balance('cp1', 'cp', 1, '2025-12-31'),
|
||||
balance('cp2', 'cp', 2, '2024-05-07'),
|
||||
{ ...balance('cp2', 'cp', 2, '2024-05-07'), availableFrom: '2024-04-26' },
|
||||
balance('rtt', 'rtt', 0, '2026-01-01'),
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'anchor',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-04-25',
|
||||
endDate: '2024-04-25',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force: 'obligation',
|
||||
dayOfWeek: 3,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'after',
|
||||
rewardDays: 1,
|
||||
rewardType: 'rtt',
|
||||
},
|
||||
{
|
||||
id: 'consumer',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-05-06',
|
||||
endDate: '2024-05-06',
|
||||
placeDays: 1,
|
||||
placeType: 'rtt',
|
||||
},
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'anchor',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-04-25',
|
||||
endDate: '2024-04-25',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force: 'obligation',
|
||||
dayOfWeek: 3,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'after',
|
||||
rewardDays: 1,
|
||||
rewardType: 'rtt',
|
||||
},
|
||||
{
|
||||
id: 'consumer',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-05-06',
|
||||
endDate: '2024-05-06',
|
||||
placeDays: 1,
|
||||
placeType: 'rtt',
|
||||
},
|
||||
],
|
||||
...presets.slice(1),
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
const atRisk = outcome.placements.find(
|
||||
(placement) => placement.balanceId === 'cp2' && !placement.ruleIds.length,
|
||||
(placement) =>
|
||||
placement.balanceId === 'cp2' &&
|
||||
placement.ruleIds.every((id) => id.startsWith('default-')),
|
||||
);
|
||||
expect(atRisk?.date).toBeDefined();
|
||||
if (!atRisk) return;
|
||||
@@ -1006,7 +1024,11 @@ describe('planner explanation and trace seam', () => {
|
||||
expect((await calculation.explanation)?.kind).toBe('ConflictExplanation');
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.some((phase) => phase.name === 'obligations')).toBe(true);
|
||||
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
|
||||
expect(
|
||||
trace?.phases.some(
|
||||
(phase) => phase.name === 'optimization:default-holiday-week-value',
|
||||
),
|
||||
).toBe(false);
|
||||
if (index === 1)
|
||||
expect(
|
||||
trace?.phases
|
||||
@@ -1076,9 +1098,10 @@ describe('planner explanation and trace seam', () => {
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-31')],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'holiday-week-value') throw new Error('interrupted after checkpoint');
|
||||
if (stage === 'optimization:default-holiday-week-value')
|
||||
throw new Error('interrupted after checkpoint');
|
||||
},
|
||||
};
|
||||
const outcome = core.calculatePlanningInternal(request, (plan, state) => {
|
||||
@@ -1128,8 +1151,8 @@ describe('planner explanation and trace seam', () => {
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-15' },
|
||||
balances: [balance('cp', 'cp', 3, '2024-01-31')],
|
||||
rules: [],
|
||||
deadlineMs: 1_000,
|
||||
rules: presets,
|
||||
deadlineMs: 100_000,
|
||||
};
|
||||
const plain = core.calculatePlanningInternal(request);
|
||||
clock = 0;
|
||||
@@ -1156,6 +1179,7 @@ describe('planner explanation and trace seam', () => {
|
||||
expect(artifacts?.trace?.status).toBe('ready');
|
||||
expect(artifacts?.timings?.planningWorkMs).toBeLessThan(request.deadlineMs);
|
||||
expect(artifacts?.timings?.traceTransferMs).toBeGreaterThanOrEqual(callbackCount * 10_000);
|
||||
expect(artifacts?.timings?.traceTransferMs).toBeGreaterThan(request.deadlineMs);
|
||||
const decisions = artifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? [];
|
||||
const candidateUpdates = operations.filter((operation) => operation.kind === 'candidate');
|
||||
expect(candidateUpdates.some((operation) => operation.mode === 'replace')).toBe(true);
|
||||
@@ -1180,7 +1204,7 @@ describe('planner explanation and trace seam', () => {
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-08' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-07')],
|
||||
rules: [],
|
||||
rules: [presets[0]],
|
||||
};
|
||||
let plainArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||||
const plain = core.calculatePlanningInternal(request, undefined, (value) => {
|
||||
@@ -1210,6 +1234,7 @@ describe('planner explanation and trace seam', () => {
|
||||
candidate,
|
||||
})),
|
||||
);
|
||||
expect(candidates.length).toBeGreaterThan(0);
|
||||
const candidateOperations = operations.filter(
|
||||
(operation): operation is Extract<typeof operation, { kind: 'candidate' }> =>
|
||||
operation.kind === 'candidate',
|
||||
@@ -1232,7 +1257,7 @@ describe('planner explanation and trace seam', () => {
|
||||
const early = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2024-01-31')],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
deadlineMs: 0,
|
||||
});
|
||||
const earlyOutcome = await early.outcome;
|
||||
@@ -1247,16 +1272,18 @@ describe('planner explanation and trace seam', () => {
|
||||
expect(earlyTrace?.phases.find((phase) => phase.name === 'obligations')?.status).toBe(
|
||||
'interrupted',
|
||||
);
|
||||
expect(earlyTrace?.phases.find((phase) => phase.name === 'at-risk')?.status).toBe(
|
||||
'not-run',
|
||||
);
|
||||
expect(
|
||||
earlyTrace?.phases.find(
|
||||
(phase) => phase.name === 'optimization:default-unplaced-at-risk',
|
||||
)?.status,
|
||||
).toBe('not-run');
|
||||
|
||||
const quality = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [balance('cp', 'cp', 1, '2024-01-31')],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
progress: (stage) => {
|
||||
if (stage === 'holiday-week-value') {
|
||||
if (stage === 'optimization:default-holiday-week-value') {
|
||||
throw new Error('deliberate interruption in quality phase');
|
||||
}
|
||||
},
|
||||
@@ -1266,13 +1293,21 @@ describe('planner explanation and trace seam', () => {
|
||||
expect(qualityOutcome.kind).toBe('PlanningIncomplete');
|
||||
if (qualityOutcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(qualityOutcome.reasons[0].code).toBe('planner-error');
|
||||
expect(qualityOutcome.completedStages).toEqual(['validation', 'obligations', 'at-risk']);
|
||||
expect(qualityOutcome.completedStages).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
'optimization:default-unplaced-at-risk',
|
||||
]);
|
||||
expect(qualityTrace?.completedStages).toEqual(qualityOutcome.completedStages);
|
||||
expect(
|
||||
qualityTrace?.phases.find((phase) => phase.name === 'holiday-week-value')?.status,
|
||||
qualityTrace?.phases.find(
|
||||
(phase) => phase.name === 'optimization:default-holiday-week-value',
|
||||
)?.status,
|
||||
).toBe('interrupted');
|
||||
expect(
|
||||
qualityTrace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status,
|
||||
qualityTrace?.phases.find(
|
||||
(phase) => phase.name === 'optimization:default-non-working-day-gain',
|
||||
)?.status,
|
||||
).toBe('not-run');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { createOptimizationPresetRules } from './rule-service';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calculatePlanningInternal, type PlanningArtifacts } from './leave-planner.core';
|
||||
import {
|
||||
evaluateOptimization,
|
||||
OPTIMIZATION_PRESETS,
|
||||
type OptimizationObservations,
|
||||
} from './planning-objectives';
|
||||
|
||||
const presets = createOptimizationPresetRules();
|
||||
|
||||
describe('planning objective observations', () => {
|
||||
it.each([
|
||||
{ start: '2024-05-06', end: '2024-05-13' },
|
||||
{ start: '2024-05-10', end: '2024-05-11' },
|
||||
])(
|
||||
'evaluates the same declared holiday week and supporting placement at horizon boundaries',
|
||||
(horizon) => {
|
||||
const result = calculatePlanningInternal({
|
||||
horizon,
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-06',
|
||||
expiryDate: '2025-05-06',
|
||||
},
|
||||
],
|
||||
rules: presets,
|
||||
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
|
||||
});
|
||||
expect(result.kind).toBe('Plan');
|
||||
if (result.kind !== 'Plan') return;
|
||||
expect(result.placements).toEqual([
|
||||
{
|
||||
date: '2024-05-10',
|
||||
balanceId: 'cp',
|
||||
type: 'cp',
|
||||
ruleIds: ['default-holiday-week-value'],
|
||||
supporting: true,
|
||||
},
|
||||
]);
|
||||
const observations: OptimizationObservations = {
|
||||
leaveBalances: [{ unplacedQuantity: 0, atRisk: false }],
|
||||
publicHolidayWeeks: [{ value: 5, complete: true }],
|
||||
timeOffPeriods: result.timeOffPeriods,
|
||||
workSequences: [
|
||||
{ length: 0, internal: false },
|
||||
{ length: 0, internal: false },
|
||||
],
|
||||
leavePlacements: result.placements,
|
||||
};
|
||||
expect(
|
||||
Object.values(OPTIMIZATION_PRESETS).map((configuration) =>
|
||||
evaluateOptimization(configuration, observations),
|
||||
),
|
||||
).toEqual([0, 5, 6, 0, 0, 1]);
|
||||
expect(result.metrics).toMatchObject({
|
||||
unplacedAtRisk: 0,
|
||||
holidayWeekValue: 5,
|
||||
nonWorkingDayGain: 6,
|
||||
longestWorkSequence: 0,
|
||||
shortSequenceDeficit: 0,
|
||||
supportingPlacements: 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('uses internal working-day deficits while retaining both horizon edges in the maximum', () => {
|
||||
const result = calculatePlanningInternal({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-18' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
declaredDays: ['2024-01-05', '2024-01-09'].map((date) => ({ date, type: 'other' })),
|
||||
});
|
||||
expect(result.kind).toBe('Plan');
|
||||
if (result.kind !== 'Plan') return;
|
||||
expect(result.metrics).toMatchObject({
|
||||
longestWorkSequence: 6,
|
||||
shortSequenceDeficit: 4,
|
||||
nonWorkingDayGain: 2,
|
||||
});
|
||||
const observations: OptimizationObservations = {
|
||||
leaveBalances: [],
|
||||
publicHolidayWeeks: [],
|
||||
timeOffPeriods: result.timeOffPeriods,
|
||||
workSequences: [
|
||||
{ length: 3, internal: false },
|
||||
{ length: 1, internal: true },
|
||||
{ length: 6, internal: false },
|
||||
],
|
||||
leavePlacements: result.placements,
|
||||
};
|
||||
expect(evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations)).toBe(
|
||||
4,
|
||||
);
|
||||
expect(
|
||||
evaluateOptimization(
|
||||
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays: 3 },
|
||||
observations,
|
||||
),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['2024-01-01', '2024-01-06', ['2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05'], 1],
|
||||
['2018-04-30', '2018-05-05', ['2018-04-30', '2018-05-02', '2018-05-03', '2018-05-04'], 2],
|
||||
['2024-04-29', '2024-05-04', ['2024-04-29', '2024-04-30', '2024-05-02', '2024-05-03'], 3],
|
||||
['2024-08-12', '2024-08-17', ['2024-08-12', '2024-08-13', '2024-08-14', '2024-08-16'], 2],
|
||||
['2024-10-28', '2024-11-02', ['2024-10-28', '2024-10-29', '2024-10-30', '2024-10-31'], 1],
|
||||
] as const)(
|
||||
'retains the weekday holiday weight in the completed week starting %s',
|
||||
(start, end, dates, value) => {
|
||||
const result = calculatePlanningInternal({
|
||||
horizon: { start, end },
|
||||
balances: [],
|
||||
rules: [],
|
||||
declaredDays: dates.map((date) => ({ date, type: 'other' })),
|
||||
});
|
||||
expect(result.kind).toBe('Plan');
|
||||
if (result.kind === 'Plan') expect(result.metrics.holidayWeekValue).toBe(value);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the historical objective order in the explanation of a jointly valid plan', () => {
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
const result = calculatePlanningInternal(
|
||||
{
|
||||
horizon: { start: '2024-01-02', end: '2024-01-18' },
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-01-02',
|
||||
expiryDate: '2024-01-17',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'fixed',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-05',
|
||||
endDate: '2024-01-05',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
{
|
||||
id: 'personal',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2024-01-09',
|
||||
endDate: '2024-01-09',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
...presets.slice(1),
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(result.kind).toBe('Plan');
|
||||
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
|
||||
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
|
||||
const placement = artifacts.explanation.placements[0];
|
||||
expect(placement.objectiveVector.objectives.map((objective) => objective.id)).toEqual([
|
||||
'obligation-validity',
|
||||
'optimization:default-unplaced-at-risk',
|
||||
'preference:personal',
|
||||
'optimization:default-holiday-week-value',
|
||||
'optimization:default-non-working-day-gain',
|
||||
'optimization:default-longest-work-sequence',
|
||||
'optimization:default-short-sequence-deficit',
|
||||
'optimization:default-supporting-placements',
|
||||
'balance-expiry',
|
||||
'placement-date',
|
||||
]);
|
||||
expect(placement.firstDecisiveObjective).toBe('obligation-validity');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,116 @@
|
||||
import { createOptimizationPresetRules } from './rule-service';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
import {
|
||||
calculatePlanningInternal,
|
||||
type PlanningArtifacts,
|
||||
type PlanningTraceCandidate,
|
||||
type PlanningTraceUpdate,
|
||||
} from './leave-planner.core';
|
||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||
|
||||
beforeEach(() => vi.stubGlobal('window', undefined));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const presets = createOptimizationPresetRules();
|
||||
|
||||
describe('quality calculation trace', () => {
|
||||
it('keeps emitted comparison metadata unchanged when candidate snapshots are replaced', () => {
|
||||
const updates: PlanningTraceUpdate[] = [];
|
||||
const captured: string[] = [];
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
let clock = 0;
|
||||
const clockSpy = vi.spyOn(performance, 'now').mockImplementation(() => (clock += 30));
|
||||
try {
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
calculationDate: '2026-11-01',
|
||||
horizon: { start: '2026-11-03', end: '2026-11-14' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2027-01-01' }],
|
||||
declaredDays: [
|
||||
{ date: '2026-11-09', type: 'other' },
|
||||
{ date: '2026-11-10', type: 'other' },
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'holiday',
|
||||
name: 'Holiday weeks',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
|
||||
},
|
||||
...[3, 5].map((thresholdDays) => ({
|
||||
id: `deficit-${thresholdDays}`,
|
||||
name: `Deficit ${thresholdDays} days`,
|
||||
type: 'optimization',
|
||||
configuration: {
|
||||
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
conditions: ['non-empty'],
|
||||
thresholdDays,
|
||||
},
|
||||
})),
|
||||
],
|
||||
traceProgress: (_elapsed: number, update: PlanningTraceUpdate) => {
|
||||
updates.push(update);
|
||||
captured.push(JSON.stringify(update));
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
expect(artifacts.trace?.status).toBe('ready');
|
||||
expect(updates.map((update) => JSON.stringify(update))).toEqual(captured);
|
||||
const emitted = new Map<string, PlanningTraceCandidate>();
|
||||
let replacements = 0;
|
||||
for (const update of updates)
|
||||
for (const operation of update.operations) {
|
||||
if (operation.kind !== 'candidate') continue;
|
||||
if (operation.mode === 'replace') replacements++;
|
||||
emitted.set(
|
||||
`${operation.phaseId}/${operation.decisionId}/${operation.index}`,
|
||||
operation.candidate,
|
||||
);
|
||||
}
|
||||
expect(replacements).toBeGreaterThan(0);
|
||||
expect(emitted.size).toBeGreaterThan(0);
|
||||
for (const phase of artifacts.trace!.phases)
|
||||
for (const decision of phase.decisions)
|
||||
decision.candidates.forEach((candidate, index) =>
|
||||
expect(emitted.get(`${phase.id}/${decision.id}/${index}`)).toEqual(
|
||||
candidate,
|
||||
),
|
||||
);
|
||||
const comparison = [...emitted.values()].find(
|
||||
(candidate) => candidate.comparison,
|
||||
)?.comparison;
|
||||
expect(comparison?.after.objectives).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'optimization:deficit-3',
|
||||
label: 'Deficit 3 days',
|
||||
direction: 'minimize',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'optimization:deficit-5',
|
||||
label: 'Deficit 5 days',
|
||||
direction: 'minimize',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
[...emitted.values()].some(({ comparison }) => {
|
||||
const values = comparison?.after.objectives;
|
||||
return (
|
||||
values?.find(({ id }) => id === 'optimization:deficit-3')?.value === 0 &&
|
||||
values?.find(({ id }) => id === 'optimization:deficit-5')?.value === 1
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
clockSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('separates source effects from the actual incumbent comparison', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
@@ -17,71 +123,47 @@ describe('quality calculation trace', () => {
|
||||
expiryDate: '2024-01-31',
|
||||
},
|
||||
],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect((await calculation.outcome).kind).toBe('Plan');
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
expect(trace?.status).toBe('ready');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
|
||||
const phase = trace.phases.find((item) => item.name === 'non-working-day-gain');
|
||||
const jan3Decision = phase?.decisions.find((decision) =>
|
||||
decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-03'),
|
||||
const phase = trace?.phases.find(({ name }) => name === `optimization:${presets[0].id}`);
|
||||
const decision = phase?.decisions.find(({ candidates }) =>
|
||||
candidates.some(({ status, date }) => status === 'chosen' && date === '2024-01-02'),
|
||||
);
|
||||
const jan2Decision = phase?.decisions.find((decision) =>
|
||||
decision.candidates.some((candidate) => candidate.sourceDate === '2024-01-02'),
|
||||
const rejected = decision?.candidates.find(
|
||||
({ date, comparison }) => date === '2024-01-05' && comparison,
|
||||
);
|
||||
const jan5 = jan3Decision?.candidates.find(
|
||||
(candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-05',
|
||||
);
|
||||
const jan8 = jan3Decision?.candidates.find(
|
||||
(candidate) => candidate.sourceDate === '2024-01-03' && candidate.date === '2024-01-08',
|
||||
);
|
||||
|
||||
expect(jan2Decision).toBeDefined();
|
||||
expect(jan3Decision).toBeDefined();
|
||||
expect(jan3Decision).not.toBe(jan2Decision);
|
||||
expect(jan3Decision?.dates).toEqual(['2024-01-03', '2024-01-05']);
|
||||
expect(jan5?.status).toBe('chosen');
|
||||
expect(jan8?.status).toBe('evaluated-rejected');
|
||||
expect(jan3Decision?.deltas).toContainEqual(
|
||||
expect(rejected?.status).toBe('evaluated-rejected');
|
||||
const sourceValue = (state: 'before' | 'after', id: string) =>
|
||||
rejected?.comparison?.[state].objectives.find((objective) => objective.id === id)
|
||||
?.value;
|
||||
expect(sourceValue('before', `optimization:${presets[0].id}`)).toBe(2);
|
||||
expect(sourceValue('after', `optimization:${presets[0].id}`)).toBe(1);
|
||||
expect(sourceValue('before', `optimization:${presets[2].id}`)).toBe(0);
|
||||
expect(sourceValue('after', `optimization:${presets[2].id}`)).toBe(2);
|
||||
expect(rejected?.comparison?.firstDecisiveObjective).toBe(`optimization:${presets[0].id}`);
|
||||
// The January 2 candidate already gains three natural non-working days.
|
||||
// January 5 improves its source state, but loses to that current best candidate.
|
||||
expect(rejected?.technicalComparison).toContainEqual({
|
||||
name: `optimization:${presets[2].id}`,
|
||||
direction: 'maximize',
|
||||
before: 3,
|
||||
after: 2,
|
||||
});
|
||||
expect(
|
||||
rejected?.technicalComparison?.some(
|
||||
({ name }) => name === `optimization:${presets[3].id}`,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(rejected?.firstDecisiveComparator).toBe(`optimization:${presets[2].id}`);
|
||||
expect(rejected?.reason?.message).toContain('did not improve the current best result');
|
||||
expect(decision?.deltas).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'placement-moved',
|
||||
fromDate: '2024-01-03',
|
||||
toDate: '2024-01-05',
|
||||
kind: 'placement-added',
|
||||
date: '2024-01-02',
|
||||
}),
|
||||
);
|
||||
|
||||
const sourceValue = (
|
||||
candidate: typeof jan5,
|
||||
state: 'before' | 'after',
|
||||
objective: string,
|
||||
) => candidate?.comparison?.[state].objectives.find((item) => item.id === objective)?.value;
|
||||
expect(sourceValue(jan5, 'before', 'non-working-day-gain')).toBe(3);
|
||||
expect(sourceValue(jan5, 'after', 'non-working-day-gain')).toBe(5);
|
||||
expect(sourceValue(jan5, 'before', 'longest-work-sequence')).toBe(20);
|
||||
expect(sourceValue(jan5, 'after', 'longest-work-sequence')).toBe(18);
|
||||
expect(sourceValue(jan8, 'before', 'non-working-day-gain')).toBe(3);
|
||||
expect(sourceValue(jan8, 'after', 'non-working-day-gain')).toBe(5);
|
||||
expect(sourceValue(jan8, 'before', 'longest-work-sequence')).toBe(20);
|
||||
expect(sourceValue(jan8, 'after', 'longest-work-sequence')).toBe(17);
|
||||
expect(jan8?.comparison?.firstDecisiveObjective).toBe('non-working-day-gain');
|
||||
|
||||
expect(jan8?.technicalComparison).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'non-working-day-gain',
|
||||
direction: 'maximize',
|
||||
before: 5,
|
||||
after: 5,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
jan8?.technicalComparison?.some((item) => item.name === 'longest-work-sequence'),
|
||||
).toBe(false);
|
||||
expect(jan8?.firstDecisiveComparator).toBeUndefined();
|
||||
expect(jan8?.reason?.message).toContain('did not improve the current best result');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,823 @@
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
calculatePlanningAsyncInternal,
|
||||
calculatePlanningInternal,
|
||||
type OptimizationPlanningRule,
|
||||
type PlanOutcome,
|
||||
type PlanningArtifacts,
|
||||
type PlanningRequest,
|
||||
type PlanningTraceUpdate,
|
||||
} from './leave-planner.core';
|
||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||
import { applyReplayDeltas, createPlanningCalculation } from './leave-planner.service';
|
||||
|
||||
const economy: OptimizationPlanningRule = {
|
||||
id: 'economy',
|
||||
name: 'Économiser les jours d’appoint',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
};
|
||||
const gain: OptimizationPlanningRule = {
|
||||
id: 'gain',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
|
||||
};
|
||||
const requested: PlanningRequest['rules'][number] = {
|
||||
id: 'requested',
|
||||
type: 'time_window',
|
||||
startDate: '2024-01-05',
|
||||
endDate: '2024-01-05',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
};
|
||||
const weekend: PlanningRequest = {
|
||||
horizon: { start: '2024-01-05', end: '2024-01-10' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
|
||||
rules: [],
|
||||
};
|
||||
const bundles: {
|
||||
name: string;
|
||||
rule: OptimizationPlanningRule;
|
||||
request: PlanningRequest;
|
||||
dates: string[];
|
||||
}[] = [
|
||||
{
|
||||
name: 'complete public-holiday week',
|
||||
rule: {
|
||||
id: 'holiday',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
|
||||
},
|
||||
request: {
|
||||
horizon: { start: '2026-11-09', end: '2026-11-14' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2027-01-01' }],
|
||||
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [],
|
||||
},
|
||||
dates: ['2026-11-12', '2026-11-13'],
|
||||
},
|
||||
{
|
||||
name: 'non-working-day gain',
|
||||
rule: gain,
|
||||
request: weekend,
|
||||
dates: ['2024-01-05'],
|
||||
},
|
||||
{
|
||||
name: 'maximum non-working gain of a joined period',
|
||||
rule: {
|
||||
...gain,
|
||||
configuration: { ...gain.configuration, aggregation: 'maximum' },
|
||||
},
|
||||
request: {
|
||||
horizon: { start: '2024-01-05', end: '2024-01-13' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2025-01-01' }],
|
||||
declaredDays: ['2024-01-05', '2024-01-12'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [],
|
||||
},
|
||||
dates: ['2024-01-08', '2024-01-09', '2024-01-10', '2024-01-11'],
|
||||
},
|
||||
{
|
||||
name: 'complete short internal work sequence',
|
||||
rule: {
|
||||
id: 'deficit',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
},
|
||||
request: {
|
||||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
|
||||
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [],
|
||||
},
|
||||
dates: ['2024-01-09', '2024-01-10', '2024-01-11'],
|
||||
},
|
||||
{
|
||||
name: 'tied longest work sequences',
|
||||
rule: {
|
||||
id: 'longest',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.longestWorkSequence,
|
||||
},
|
||||
request: {
|
||||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-02-01' }],
|
||||
declaredDays: [{ date: '2024-01-10', type: 'other' }],
|
||||
rules: [],
|
||||
},
|
||||
dates: ['2024-01-08', '2024-01-11'],
|
||||
},
|
||||
];
|
||||
|
||||
function plan(request: PlanningRequest): PlanOutcome {
|
||||
const outcome = calculatePlanningInternal(request);
|
||||
if (outcome.kind !== 'Plan') throw new Error(`Expected a plan, got ${outcome.kind}`);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
describe('supporting placement economy and provenance', () => {
|
||||
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it.each(bundles)(
|
||||
'compares the whole $name bundle against economy in either order',
|
||||
({ rule, request, dates }) => {
|
||||
const improve = plan({ ...request, rules: [rule, economy] });
|
||||
const preserve = plan({ ...request, rules: [economy, rule] });
|
||||
expect(improve.placements.map(({ date }) => date)).toEqual(dates);
|
||||
expect(improve.placements.every(({ supporting }) => supporting)).toBe(true);
|
||||
expect(improve.metrics.supportingPlacements).toBe(dates.length);
|
||||
expect(improve.remainingBalances).toEqual([]);
|
||||
expect(preserve.placements).toEqual([]);
|
||||
expect(preserve.metrics.supportingPlacements).toBe(0);
|
||||
expect(preserve.remainingBalances[0]).toMatchObject({
|
||||
id: 'cp',
|
||||
quantity: request.balances[0].quantity,
|
||||
classification: 'deferred',
|
||||
reason: { code: 'preserved' },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(bundles)(
|
||||
'makes disabled economy identical to absence for the $name bundle',
|
||||
({ rule, request }) => {
|
||||
const absent = plan({ ...request, rules: [rule] });
|
||||
for (const rules of [
|
||||
[{ ...economy, enabled: false }, rule],
|
||||
[rule, { ...economy, enabled: false }],
|
||||
])
|
||||
expect(plan({ ...request, rules })).toEqual(absent);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not add placements merely because economy is disabled or removed', () => {
|
||||
for (const rules of [[], [economy], [{ ...economy, enabled: false }]]) {
|
||||
const outcome = plan({ ...weekend, rules });
|
||||
expect(outcome.placements).toEqual([]);
|
||||
expect(outcome.remainingBalances[0].quantity).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('credits an earlier supporting placement to a later explicit window without counting an extra day', () => {
|
||||
const outcome = plan({
|
||||
...weekend,
|
||||
rules: [gain, economy, requested],
|
||||
});
|
||||
expect(outcome.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-01-05',
|
||||
balanceId: 'cp',
|
||||
ruleIds: ['gain', 'requested'],
|
||||
supporting: false,
|
||||
}),
|
||||
]);
|
||||
expect(outcome.metrics).toMatchObject({ nonWorkingDayGain: 2, supportingPlacements: 0 });
|
||||
expect(outcome.preferences[0].satisfied).toBe(1);
|
||||
});
|
||||
|
||||
it('exempts only the requested window quantity, including fixed declared days', () => {
|
||||
const outcome = plan({
|
||||
...weekend,
|
||||
horizon: { start: '2024-01-05', end: '2024-01-20' },
|
||||
balances: [{ ...weekend.balances[0], quantity: 3 }],
|
||||
declaredDays: [{ date: '2024-01-09', type: 'cp' }],
|
||||
rules: [gain, economy, { ...requested, endDate: '2024-01-19', placeDays: 2 }],
|
||||
});
|
||||
expect(outcome.placements.map(({ date, supporting }) => ({ date, supporting }))).toEqual([
|
||||
{ date: '2024-01-05', supporting: false },
|
||||
{ date: '2024-01-12', supporting: true },
|
||||
{ date: '2024-01-19', supporting: true },
|
||||
]);
|
||||
expect(outcome.metrics.supportingPlacements).toBe(2);
|
||||
expect(outcome.timeWindowContributions?.[0]).toMatchObject({
|
||||
requested: 2,
|
||||
declaredDays: [{ date: '2024-01-09', type: 'cp' }],
|
||||
placementDates: ['2024-01-05'],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'bounds explicit calendar credit before accepting further window improvements (declared: %s)',
|
||||
(declared) => {
|
||||
const windowRule = { ...requested, endDate: '2024-01-19' };
|
||||
const request: PlanningRequest = {
|
||||
...weekend,
|
||||
horizon: { start: '2024-01-05', end: '2024-01-20' },
|
||||
balances: [{ ...weekend.balances[0], quantity: 4 }],
|
||||
declaredDays: declared ? [{ date: '2024-01-09', type: 'cp' }] : [],
|
||||
rules: [economy, gain, windowRule],
|
||||
};
|
||||
const preserve = plan(request);
|
||||
expect(preserve.placements).toEqual(
|
||||
declared
|
||||
? []
|
||||
: [
|
||||
expect.objectContaining({
|
||||
date: '2024-01-05',
|
||||
ruleIds: ['requested'],
|
||||
supporting: false,
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(preserve.metrics.supportingPlacements).toBe(0);
|
||||
expect(preserve.preferences[0].satisfied).toBe(1);
|
||||
expect(preserve.remainingBalances[0]).toMatchObject({
|
||||
id: 'cp',
|
||||
quantity: declared ? 4 : 3,
|
||||
});
|
||||
const improve = plan({ ...request, rules: [gain, economy, windowRule] });
|
||||
expect(improve.placements.map(({ date }) => date)).toEqual([
|
||||
'2024-01-05',
|
||||
'2024-01-12',
|
||||
'2024-01-19',
|
||||
]);
|
||||
expect(
|
||||
improve.placements.filter(({ ruleIds }) => ruleIds.includes('requested')),
|
||||
).toHaveLength(declared ? 0 : 1);
|
||||
expect(improve.metrics.supportingPlacements).toBe(declared ? 3 : 2);
|
||||
expect(
|
||||
plan({ ...request, rules: [{ ...economy, enabled: false }, gain, windowRule] }),
|
||||
).toEqual(plan({ ...request, rules: [gain, windowRule] }));
|
||||
},
|
||||
);
|
||||
|
||||
it('restores supporting classification when a moved placement loses its explicit calendar request', () => {
|
||||
const outcome = plan({
|
||||
...weekend,
|
||||
horizon: { start: '2024-01-05', end: '2024-01-20' },
|
||||
balances: [{ ...weekend.balances[0], quantity: 4 }],
|
||||
rules: [
|
||||
gain,
|
||||
{ ...requested, startDate: '2024-01-08', endDate: '2024-01-09' },
|
||||
bundles[4].rule,
|
||||
economy,
|
||||
],
|
||||
});
|
||||
expect(outcome.metrics).toMatchObject({
|
||||
nonWorkingDayGain: 6,
|
||||
longestWorkSequence: 3,
|
||||
supportingPlacements: 3,
|
||||
});
|
||||
expect(outcome.placements.filter(({ supporting }) => !supporting)).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-01-08',
|
||||
ruleIds: ['gain', 'requested'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['preference', 'obligation'] as const)(
|
||||
'keeps explicitly requested deferred days outside economy (%s)',
|
||||
(force) => {
|
||||
const outcome = plan({ ...weekend, rules: [economy, { ...requested, force }, gain] });
|
||||
expect(outcome.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-01-05',
|
||||
ruleIds: ['requested'],
|
||||
supporting: false,
|
||||
}),
|
||||
]);
|
||||
expect(outcome.metrics).toMatchObject({
|
||||
nonWorkingDayGain: 2,
|
||||
supportingPlacements: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('credits the complete explicit IF_THEN pattern without treating its deferred day as support', () => {
|
||||
const outcome = plan({
|
||||
horizon: { start: '2024-05-06', end: '2024-05-11' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
|
||||
rules: [
|
||||
gain,
|
||||
economy,
|
||||
{
|
||||
id: 'thursday',
|
||||
type: 'if_then',
|
||||
dayOfWeek: 4,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'after',
|
||||
rewardDays: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(outcome.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-05-10',
|
||||
ruleIds: ['gain', 'thursday'],
|
||||
supporting: false,
|
||||
}),
|
||||
]);
|
||||
expect(outcome.metrics.supportingPlacements).toBe(0);
|
||||
expect(outcome.preferences[0].satisfied).toBe(1);
|
||||
});
|
||||
|
||||
it.each(['preference', 'obligation'] as const)(
|
||||
'limits IF_THEN credit to its declared producer pattern when another complete pattern improves rest (%s)',
|
||||
(force) => {
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-05-06', end: '2024-05-11' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2025-01-01' }],
|
||||
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
|
||||
rules: [
|
||||
economy,
|
||||
gain,
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force,
|
||||
dayOfWeek: 3,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: 'rtt',
|
||||
},
|
||||
],
|
||||
};
|
||||
const preserve = plan(request);
|
||||
expect(preserve.placements).toEqual([]);
|
||||
expect(preserve.metrics.supportingPlacements).toBe(0);
|
||||
expect(preserve.remainingBalances).toEqual([
|
||||
expect.objectContaining({ id: 'cp', quantity: 4 }),
|
||||
expect.objectContaining({
|
||||
id: 'earned:producer:2024-05-08',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-10',
|
||||
}),
|
||||
]);
|
||||
expect(preserve.ifThenContributions?.[0]).toMatchObject({
|
||||
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
|
||||
placementDates: [],
|
||||
earnedEntitlement: { availableFrom: '2024-05-10', added: true },
|
||||
});
|
||||
const improve = plan({ ...request, rules: [gain, economy, request.rules[2]] });
|
||||
expect(improve.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-05-06',
|
||||
ruleIds: ['gain'],
|
||||
supporting: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2024-05-10',
|
||||
ruleIds: ['gain'],
|
||||
supporting: true,
|
||||
}),
|
||||
]);
|
||||
expect(improve.metrics).toMatchObject({
|
||||
nonWorkingDayGain: 6,
|
||||
supportingPlacements: 2,
|
||||
});
|
||||
expect(improve.remainingBalances).toEqual([
|
||||
expect.objectContaining({ id: 'cp', quantity: 2 }),
|
||||
expect.objectContaining({
|
||||
id: 'earned:producer:2024-05-08',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-13',
|
||||
}),
|
||||
]);
|
||||
expect(improve.ifThenContributions?.[0]).toMatchObject({
|
||||
declaredDays: [{ date: '2024-05-07', type: 'cp' }],
|
||||
placementDates: [],
|
||||
earnedEntitlement: { availableFrom: '2024-05-13', added: true },
|
||||
});
|
||||
expect(
|
||||
plan({
|
||||
...request,
|
||||
rules: [{ ...economy, enabled: false }, gain, request.rules[2]],
|
||||
}),
|
||||
).toEqual(plan({ ...request, rules: [gain, request.rules[2]] }));
|
||||
},
|
||||
);
|
||||
|
||||
it('uses expiry then dates when active rules tie, without restoring a removed gain objective', () => {
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-01-09', end: '2024-01-13' },
|
||||
balances: [
|
||||
{ id: 'later', type: 'cp', quantity: 1, expiryDate: '2024-03-01' },
|
||||
{ id: 'earlier', type: 'cp', quantity: 1, expiryDate: '2024-02-01' },
|
||||
],
|
||||
rules: [economy, { ...requested, startDate: '2024-01-09', endDate: '2024-01-12' }],
|
||||
};
|
||||
const outcome = plan(request);
|
||||
expect(outcome.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-01-09',
|
||||
balanceId: 'earlier',
|
||||
supporting: false,
|
||||
}),
|
||||
]);
|
||||
expect(outcome.metrics.nonWorkingDayGain).toBe(0);
|
||||
expect(
|
||||
plan({ ...request, rules: [{ ...gain, enabled: false }, ...request.rules] }),
|
||||
).toEqual(outcome);
|
||||
});
|
||||
|
||||
it('reclassifies a deferred support addition when an eligible earlier-expiring balance replaces it', () => {
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
horizon: { start: '2024-05-06', end: '2024-05-11' },
|
||||
balances: [
|
||||
{ id: 'deferred', type: 'cp', quantity: 3, expiryDate: '2025-01-01' },
|
||||
{
|
||||
id: 'at-risk',
|
||||
type: 'rtt',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-07',
|
||||
expiryDate: '2024-05-07',
|
||||
},
|
||||
],
|
||||
rules: [bundles[0].rule, economy],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
|
||||
expect(
|
||||
outcome.placements.map(({ date, balanceId, supporting }) => ({
|
||||
date,
|
||||
balanceId,
|
||||
supporting,
|
||||
})),
|
||||
).toEqual([
|
||||
{ date: '2024-05-06', balanceId: 'deferred', supporting: true },
|
||||
{ date: '2024-05-07', balanceId: 'at-risk', supporting: false },
|
||||
{ date: '2024-05-10', balanceId: 'deferred', supporting: true },
|
||||
]);
|
||||
expect(outcome.metrics.supportingPlacements).toBe(2);
|
||||
expect(outcome.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 1 });
|
||||
expect(
|
||||
artifacts?.trace?.phases.flatMap(({ decisions }) =>
|
||||
decisions.flatMap(({ deltas }) => deltas),
|
||||
),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'placement-updated',
|
||||
previousPlacement: expect.objectContaining({
|
||||
balanceId: 'deferred',
|
||||
supporting: true,
|
||||
}),
|
||||
placement: expect.objectContaining({ balanceId: 'at-risk', supporting: false }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('compares new deferred support when at-risk holiday placements relocate into an explicit window', async () => {
|
||||
const holiday = bundles[0].rule;
|
||||
const early: PlanningRequest['rules'][number] = {
|
||||
id: 'early',
|
||||
type: 'time_window',
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-03',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
};
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2026-11-02', end: '2026-11-14' },
|
||||
balances: [
|
||||
{ id: 'at-risk', type: 'cp', quantity: 2, expiryDate: '2026-11-13' },
|
||||
{
|
||||
id: 'deferred',
|
||||
type: 'cp',
|
||||
quantity: 2,
|
||||
availableFrom: '2026-11-12',
|
||||
expiryDate: '2027-01-01',
|
||||
},
|
||||
],
|
||||
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [],
|
||||
};
|
||||
const economyFirst = plan({ ...request, rules: [holiday, economy, early] });
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
const updates: { update: PlanningTraceUpdate; snapshot: string }[] = [];
|
||||
const windowFirst = calculatePlanningInternal(
|
||||
{
|
||||
...request,
|
||||
rules: [holiday, early, economy],
|
||||
traceProgress: (_time: number, update: PlanningTraceUpdate) =>
|
||||
updates.push({ update, snapshot: JSON.stringify(update) }),
|
||||
},
|
||||
(checkpoint, state) => expect(state?.placements).toEqual(checkpoint.placements),
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (windowFirst.kind !== 'Plan') throw new Error('Expected a plan');
|
||||
expect(economyFirst.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2026-11-12',
|
||||
balanceId: 'at-risk',
|
||||
supporting: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2026-11-13',
|
||||
balanceId: 'at-risk',
|
||||
supporting: false,
|
||||
}),
|
||||
]);
|
||||
expect(economyFirst.metrics.supportingPlacements).toBe(0);
|
||||
expect(economyFirst.preferences[0].satisfied).toBe(0);
|
||||
expect(economyFirst.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 2 });
|
||||
expect(windowFirst.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2026-11-02',
|
||||
balanceId: 'at-risk',
|
||||
ruleIds: ['early'],
|
||||
supporting: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2026-11-03',
|
||||
balanceId: 'at-risk',
|
||||
ruleIds: ['early'],
|
||||
supporting: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2026-11-12',
|
||||
balanceId: 'deferred',
|
||||
ruleIds: ['holiday'],
|
||||
supporting: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2026-11-13',
|
||||
balanceId: 'deferred',
|
||||
ruleIds: ['holiday'],
|
||||
supporting: true,
|
||||
}),
|
||||
]);
|
||||
expect(windowFirst.metrics.supportingPlacements).toBe(2);
|
||||
expect(windowFirst.preferences[0].satisfied).toBe(2);
|
||||
expect(windowFirst.metrics.holidayWeekValue).toBe(3);
|
||||
if (
|
||||
artifacts?.explanation?.kind !== 'PlanExplanation' ||
|
||||
!artifacts.finalState ||
|
||||
!artifacts.trace
|
||||
)
|
||||
throw new Error('Expected matching explanation and trace artifacts');
|
||||
expect(artifacts.finalState.placements).toEqual(windowFirst.placements);
|
||||
expect(artifacts.explanation.placements.map(({ placement }) => placement)).toEqual(
|
||||
windowFirst.placements,
|
||||
);
|
||||
for (const placement of artifacts.explanation.placements) {
|
||||
const contribution = expect.objectContaining({ kind: 'rule', ruleId: 'holiday' });
|
||||
if (placement.supporting) {
|
||||
expect(placement.ruleIds).toEqual(['holiday']);
|
||||
expect(placement.contributions).toContainEqual(contribution);
|
||||
expect(placement.firstDecisiveObjective).toBe('optimization:holiday');
|
||||
} else {
|
||||
expect(placement.ruleIds).toEqual(['early']);
|
||||
expect(placement.contributions).not.toContainEqual(contribution);
|
||||
}
|
||||
}
|
||||
const replay = {
|
||||
balances: artifacts.trace.initialState.balances.map((balance) => ({ ...balance })),
|
||||
placements: new Map(
|
||||
artifacts.trace.initialState.placements.map((placement) => [
|
||||
placement.date,
|
||||
placement,
|
||||
]),
|
||||
),
|
||||
earnedActivations: [...artifacts.trace.initialState.earnedActivations],
|
||||
};
|
||||
for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions))
|
||||
applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
|
||||
expect(
|
||||
[...replay.placements.values()].sort((a, b) => a.date.localeCompare(b.date)),
|
||||
).toEqual(windowFirst.placements);
|
||||
expect(
|
||||
artifacts.trace.phases
|
||||
.filter(({ name }) => name === 'preference:early')
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates)),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: 'chosen',
|
||||
placement: expect.objectContaining({ date: '2026-11-03', ruleIds: ['early'] }),
|
||||
}),
|
||||
);
|
||||
expect(updates.length).toBeGreaterThan(0);
|
||||
for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
|
||||
let cancelled = false;
|
||||
let cancelledArtifacts: PlanningArtifacts | undefined;
|
||||
let published: PlanOutcome | undefined;
|
||||
const partial = await calculatePlanningAsyncInternal(
|
||||
{
|
||||
...request,
|
||||
rules: [holiday, early, economy],
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'optimization:economy') cancelled = true;
|
||||
},
|
||||
traceProgress: (_time: number, update: PlanningTraceUpdate) =>
|
||||
updates.push({ update, snapshot: JSON.stringify(update) }),
|
||||
},
|
||||
(checkpoint, state) => {
|
||||
expect(state?.placements).toEqual(checkpoint.placements);
|
||||
if (checkpoint.lastCompletedStage === 'preference:early') published = checkpoint;
|
||||
},
|
||||
(value) => (cancelledArtifacts = value),
|
||||
undefined,
|
||||
undefined,
|
||||
() => cancelled,
|
||||
);
|
||||
if (
|
||||
partial.kind !== 'PlanningIncomplete' ||
|
||||
!partial.incumbent ||
|
||||
cancelledArtifacts?.explanation?.kind !== 'PlanExplanation'
|
||||
)
|
||||
throw new Error('Expected an explained cancelled incumbent');
|
||||
expect(partial.reasons[0].code).toBe('user-cancelled');
|
||||
expect(partial.incumbent.placements).toEqual(windowFirst.placements);
|
||||
expect(partial.incumbent.placements).toEqual(published?.placements);
|
||||
expect(partial.incumbent.preferences).toMatchObject([{ ruleId: 'early', satisfied: 2 }]);
|
||||
expect(cancelledArtifacts.finalState?.placements).toEqual(partial.incumbent.placements);
|
||||
expect(cancelledArtifacts.explanation.placements.map(({ placement }) => placement)).toEqual(
|
||||
partial.incumbent.placements,
|
||||
);
|
||||
for (const placement of cancelledArtifacts.explanation.placements.slice(0, 2)) {
|
||||
expect(placement.ruleIds).toEqual(['early']);
|
||||
expect(placement.contributions).toContainEqual(
|
||||
expect.objectContaining({ kind: 'rule', ruleId: 'early' }),
|
||||
);
|
||||
}
|
||||
for (const { update, snapshot } of updates) expect(JSON.stringify(update)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('keeps established maximum credit when a later earned period masks its local contribution', () => {
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
horizon: { start: '2024-05-01', end: '2024-05-11' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-05-03' }],
|
||||
rules: [
|
||||
{ ...gain, configuration: { ...gain.configuration, aggregation: 'maximum' } },
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
dayOfWeek: 3,
|
||||
placement: 'after',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
rewardDays: 2,
|
||||
rewardType: 'rtt',
|
||||
},
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
if (outcome.kind !== 'Plan' || artifacts?.explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected an explained plan');
|
||||
expect(outcome.placements).toMatchObject([
|
||||
{ date: '2024-05-02', ruleIds: ['gain', 'producer'], supporting: false },
|
||||
{ date: '2024-05-03', ruleIds: ['gain'], supporting: false },
|
||||
{ date: '2024-05-10', ruleIds: ['gain'], supporting: true },
|
||||
]);
|
||||
for (const placement of artifacts.explanation.placements.slice(0, 2)) {
|
||||
expect(placement.comparison.delta).toContainEqual(
|
||||
expect.objectContaining({ id: 'optimization:gain', value: 0 }),
|
||||
);
|
||||
expect(placement.contributions).toContainEqual(
|
||||
expect.objectContaining({ kind: 'rule', ruleId: 'gain' }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('counts reverse deferred reassignment for an edited optimization without explicit calendar credit', () => {
|
||||
const request = bundles[0].request;
|
||||
const forgoAtRisk: OptimizationPlanningRule = {
|
||||
id: 'forgo',
|
||||
type: 'optimization',
|
||||
configuration: { ...OPTIMIZATION_PRESETS.unplacedAtRisk, direction: 'maximize' },
|
||||
};
|
||||
const outcome = plan({
|
||||
...request,
|
||||
balances: [
|
||||
{ ...request.balances[0], id: 'at-risk', expiryDate: '2026-11-13' },
|
||||
{ ...request.balances[0], id: 'deferred' },
|
||||
],
|
||||
rules: [bundles[0].rule, forgoAtRisk, economy],
|
||||
});
|
||||
expect(
|
||||
outcome.placements.map(({ balanceId, supporting }) => ({ balanceId, supporting })),
|
||||
).toEqual([
|
||||
{ balanceId: 'deferred', supporting: true },
|
||||
{ balanceId: 'deferred', supporting: true },
|
||||
]);
|
||||
expect(outcome.metrics).toMatchObject({
|
||||
holidayWeekValue: 3,
|
||||
unplacedAtRisk: 2,
|
||||
supportingPlacements: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('records the calendar credit and classification change in the original calendar-rule phase', () => {
|
||||
let artifacts: PlanningArtifacts | undefined;
|
||||
calculatePlanningInternal(
|
||||
{ ...weekend, rules: [gain, economy, requested] },
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
const changes = artifacts?.trace?.phases
|
||||
.find(({ name }) => name === 'preference:requested')
|
||||
?.decisions.flatMap(({ deltas }) => deltas);
|
||||
expect(changes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'placement-updated',
|
||||
previousPlacement: expect.objectContaining({ supporting: true }),
|
||||
placement: expect.objectContaining({
|
||||
ruleIds: ['gain', 'requested'],
|
||||
supporting: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps earned support chronological and preserves the captured economy rule in explanations and counterfactuals', async () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const capturedEconomy = structuredClone(economy);
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-04-29', end: '2024-05-08' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
|
||||
rules: [
|
||||
gain,
|
||||
capturedEconomy,
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
force: 'obligation',
|
||||
dayOfWeek: 3,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'after',
|
||||
rewardDays: 1,
|
||||
rewardType: 'rtt',
|
||||
},
|
||||
],
|
||||
};
|
||||
const calculation = createPlanningCalculation(request);
|
||||
capturedEconomy.configuration.conditions = [];
|
||||
capturedEconomy.name = 'Changed later';
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
|
||||
expect(outcome.placements).toEqual([
|
||||
expect.objectContaining({
|
||||
date: '2024-05-02',
|
||||
ruleIds: ['producer'],
|
||||
supporting: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: '2024-05-06',
|
||||
balanceId: 'earned:producer:2024-05-01',
|
||||
supporting: true,
|
||||
}),
|
||||
]);
|
||||
expect(outcome.metrics).toMatchObject({
|
||||
nonWorkingDayGain: 3,
|
||||
supportingPlacements: 1,
|
||||
});
|
||||
expect(calculation.snapshot.rules[1]).toMatchObject({
|
||||
name: 'Économiser les jours d’appoint',
|
||||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
});
|
||||
const explanation = await calculation.explanation;
|
||||
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
|
||||
const supporting = explanation.placements.find(({ supporting }) => supporting)!;
|
||||
expect(supporting.firstDecisiveObjective).toBe('optimization:gain');
|
||||
expect(supporting.objectiveVector.objectives.map(({ id }) => id)).toEqual([
|
||||
'obligation-validity',
|
||||
'optimization:gain',
|
||||
'optimization:economy',
|
||||
'balance-expiry',
|
||||
'placement-date',
|
||||
]);
|
||||
expect(supporting.contributions).toContainEqual(
|
||||
expect.objectContaining({ kind: 'supporting' }),
|
||||
);
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.map(({ name }) => name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
'optimization:gain',
|
||||
'optimization:economy',
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[1]);
|
||||
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
|
||||
expect(analysis.results.find(({ date }) => date === '2024-05-03')).toMatchObject({
|
||||
status: 'ineligible',
|
||||
reason: { code: 'unavailable' },
|
||||
});
|
||||
expect(
|
||||
analysis.results.find(({ date }) => date === '2024-05-06')?.comparison?.after
|
||||
.objectives,
|
||||
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 1 }));
|
||||
expect(
|
||||
analysis.results.find(({ date }) => date === '2024-05-07')?.comparison?.after
|
||||
.objectives,
|
||||
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 1 }));
|
||||
expect(outcome.placements[1]).toMatchObject({ date: '2024-05-06', supporting: true });
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
calculatePlanningInternal,
|
||||
type OptimizationPlanningRule,
|
||||
type PlanOutcome,
|
||||
type PlanningRequest,
|
||||
} from './leave-planner.core';
|
||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
|
||||
const deficit: OptimizationPlanningRule = {
|
||||
id: 'deficit',
|
||||
name: 'Internal work-sequence deficit',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
};
|
||||
const longest: OptimizationPlanningRule = {
|
||||
id: 'longest',
|
||||
name: 'Longest work sequence',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.longestWorkSequence,
|
||||
};
|
||||
const gap: PlanningRequest = {
|
||||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
|
||||
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [deficit],
|
||||
};
|
||||
|
||||
function plan(request: PlanningRequest): PlanOutcome {
|
||||
const outcome = calculatePlanningInternal(request);
|
||||
if (outcome.kind !== 'Plan') throw new Error(`Expected a plan, got ${outcome.kind}`);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
describe('composed work-sequence preferences', () => {
|
||||
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('fills a complete internal sequence despite the worse deficit of partial additions', () => {
|
||||
const outcome = plan(gap);
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual([
|
||||
'2024-01-09',
|
||||
'2024-01-10',
|
||||
'2024-01-11',
|
||||
]);
|
||||
expect(outcome.placements.every(({ supporting }) => supporting)).toBe(true);
|
||||
expect(outcome.declaredDays).toEqual(gap.declaredDays);
|
||||
expect(outcome.metrics.shortSequenceDeficit).toBe(0);
|
||||
expect(outcome.remainingBalances).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits tied longest sequences together when neither single addition improves the maximum', () => {
|
||||
const outcome = plan({
|
||||
...gap,
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-02-01' }],
|
||||
declaredDays: [{ date: '2024-01-10', type: 'other' }],
|
||||
rules: [longest],
|
||||
});
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-08', '2024-01-11']);
|
||||
expect(outcome.metrics.longestWorkSequence).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves a three-day internal sequence intact at threshold three instead of filling it at five', () => {
|
||||
const outcome = plan({
|
||||
...gap,
|
||||
rules: [
|
||||
{
|
||||
...deficit,
|
||||
configuration: {
|
||||
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
thresholdDays: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(outcome.placements).toEqual([]);
|
||||
expect(outcome.remainingBalances[0].quantity).toBe(3);
|
||||
expect(outcome.metrics.shortSequenceDeficit).toBe(2);
|
||||
});
|
||||
|
||||
it('counts selected internal sequences even when their measured deficit is zero', () => {
|
||||
const outcome = plan({
|
||||
...gap,
|
||||
rules: [
|
||||
{
|
||||
...deficit,
|
||||
configuration: {
|
||||
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
thresholdDays: 3,
|
||||
aggregation: 'count',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual([
|
||||
'2024-01-09',
|
||||
'2024-01-10',
|
||||
'2024-01-11',
|
||||
]);
|
||||
});
|
||||
|
||||
it('evaluates two parameterized configurations separately and follows their order', () => {
|
||||
const maximizeThree: OptimizationPlanningRule = {
|
||||
...deficit,
|
||||
id: 'three',
|
||||
configuration: {
|
||||
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
thresholdDays: 3,
|
||||
direction: 'maximize',
|
||||
},
|
||||
};
|
||||
const fiveFirst = plan({ ...gap, rules: [deficit, maximizeThree] });
|
||||
const threeFirst = plan({ ...gap, rules: [maximizeThree, deficit] });
|
||||
expect(fiveFirst.placements.map(({ date }) => date)).toEqual([
|
||||
'2024-01-09',
|
||||
'2024-01-10',
|
||||
'2024-01-11',
|
||||
]);
|
||||
expect(threeFirst.placements.map(({ date }) => date)).toEqual(['2024-01-10']);
|
||||
expect(threeFirst.metrics.shortSequenceDeficit).toBe(8);
|
||||
});
|
||||
|
||||
it('obeys the order between the longest-sequence preference and a personal window', () => {
|
||||
const personal = {
|
||||
id: 'personal',
|
||||
type: 'time_window' as const,
|
||||
startDate: '2024-01-08',
|
||||
endDate: '2024-01-08',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
};
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-01-08', end: '2024-01-20' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-02-01' }],
|
||||
rules: [],
|
||||
};
|
||||
const longestFirst = plan({ ...request, rules: [longest, personal] });
|
||||
const personalFirst = plan({ ...request, rules: [personal, longest] });
|
||||
expect(longestFirst.placements.map(({ date }) => date)).toEqual(['2024-01-12']);
|
||||
expect(longestFirst.metrics.longestWorkSequence).toBe(5);
|
||||
expect(personalFirst.placements.map(({ date }) => date)).toEqual(['2024-01-08']);
|
||||
expect(personalFirst.metrics.longestWorkSequence).toBe(9);
|
||||
});
|
||||
|
||||
it('keeps obligations and earlier preferences when a complete gap would consume their balance', () => {
|
||||
const personal = {
|
||||
id: 'personal',
|
||||
type: 'time_window' as const,
|
||||
startDate: '2024-01-15',
|
||||
endDate: '2024-01-15',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
};
|
||||
const request = { ...gap, horizon: { start: '2024-01-08', end: '2024-01-16' } };
|
||||
expect(
|
||||
plan({ ...request, rules: [deficit, personal] }).placements.map(({ date }) => date),
|
||||
).toEqual(['2024-01-09', '2024-01-10', '2024-01-11']);
|
||||
for (const force of ['preference', 'obligation'] as const) {
|
||||
const outcome = plan({ ...request, rules: [{ ...personal, force }, deficit] });
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-15']);
|
||||
expect(outcome.metrics.shortSequenceDeficit).toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses only eligible balance dates when completing the entire gap', () => {
|
||||
const outcome = plan({
|
||||
...gap,
|
||||
balances: [
|
||||
{ id: 'early', type: 'rtt', quantity: 1, expiryDate: '2024-01-09' },
|
||||
{
|
||||
id: 'late',
|
||||
type: 'cp',
|
||||
quantity: 2,
|
||||
availableFrom: '2024-01-10',
|
||||
expiryDate: '2024-02-01',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(outcome.placements.map(({ date, balanceId }) => ({ date, balanceId }))).toEqual([
|
||||
{ date: '2024-01-09', balanceId: 'early' },
|
||||
{ date: '2024-01-10', balanceId: 'late' },
|
||||
{ date: '2024-01-11', balanceId: 'late' },
|
||||
]);
|
||||
expect(outcome.placements.map(({ supporting }) => supporting)).toEqual([false, true, true]);
|
||||
const unavailable = plan({
|
||||
...gap,
|
||||
balances: [
|
||||
{
|
||||
id: 'late',
|
||||
type: 'cp',
|
||||
quantity: 3,
|
||||
availableFrom: '2024-01-10',
|
||||
expiryDate: '2024-02-01',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(unavailable.placements).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds no neutral placement when the budget cannot split all tied maxima', () => {
|
||||
const outcome = plan({
|
||||
...gap,
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-02-01' }],
|
||||
declaredDays: [{ date: '2024-01-10', type: 'other' }],
|
||||
rules: [longest],
|
||||
});
|
||||
expect(outcome.placements).toEqual([]);
|
||||
expect(outcome.metrics.longestWorkSequence).toBe(2);
|
||||
});
|
||||
|
||||
it('splits tied sequences at eligible dates when their centers are unavailable', () => {
|
||||
const outcome = plan({
|
||||
horizon: { start: '2024-01-08', end: '2024-01-19' },
|
||||
declaredDays: [{ date: '2024-01-12', type: 'other' }],
|
||||
balances: [
|
||||
{ id: 'early', type: 'cp', quantity: 1, expiryDate: '2024-01-08' },
|
||||
{
|
||||
id: 'late',
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-01-18',
|
||||
expiryDate: '2024-02-01',
|
||||
},
|
||||
],
|
||||
rules: [longest],
|
||||
});
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-08', '2024-01-18']);
|
||||
expect(outcome.metrics.longestWorkSequence).toBe(3);
|
||||
});
|
||||
|
||||
it('coordinates scarce balances when individually eligible centers cannot be funded together', () => {
|
||||
const outcome = plan({
|
||||
horizon: { start: '2024-01-08', end: '2024-01-19' },
|
||||
declaredDays: [{ date: '2024-01-12', type: 'other' }],
|
||||
balances: [
|
||||
{ id: 'early', type: 'cp', quantity: 1, expiryDate: '2024-01-08' },
|
||||
{ id: 'flexible', type: 'cp', quantity: 1, expiryDate: '2024-02-01' },
|
||||
],
|
||||
rules: [longest],
|
||||
});
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-08', '2024-01-15']);
|
||||
expect(outcome.metrics.longestWorkSequence).toBe(3);
|
||||
});
|
||||
|
||||
it('eliminates tied maximum deficits as one complete gap bundle', () => {
|
||||
const outcome = plan({
|
||||
horizon: { start: '2024-01-08', end: '2024-01-17' },
|
||||
declaredDays: ['2024-01-08', '2024-01-11', '2024-01-16'].map((date) => ({
|
||||
date,
|
||||
type: 'other',
|
||||
})),
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2024-02-01' }],
|
||||
rules: [
|
||||
{
|
||||
...deficit,
|
||||
configuration: {
|
||||
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
aggregation: 'maximum',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual([
|
||||
'2024-01-09',
|
||||
'2024-01-10',
|
||||
'2024-01-12',
|
||||
'2024-01-15',
|
||||
]);
|
||||
expect(outcome.metrics.shortSequenceDeficit).toBe(0);
|
||||
});
|
||||
|
||||
it('includes both horizon edges in the maximum while excluding them from the deficit', () => {
|
||||
const outcome = plan({
|
||||
horizon: { start: '2024-01-08', end: '2024-01-25' },
|
||||
balances: [],
|
||||
declaredDays: ['2024-01-11', '2024-01-15', '2024-01-22'].map((date) => ({
|
||||
date,
|
||||
type: 'other',
|
||||
})),
|
||||
rules: [longest, deficit],
|
||||
});
|
||||
expect(outcome.metrics).toMatchObject({ longestWorkSequence: 4, shortSequenceDeficit: 5 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ horizon: { start: '2024-05-06', end: '2024-05-15' }, dates: [], longest: 5 },
|
||||
{
|
||||
horizon: { start: '2024-01-08', end: '2024-01-11' },
|
||||
dates: ['2024-01-08', '2024-01-09', '2024-01-10'],
|
||||
longest: 0,
|
||||
},
|
||||
{ horizon: { start: '2024-01-06', end: '2024-01-08' }, dates: [], longest: 0 },
|
||||
{
|
||||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||||
dates: ['2024-01-09', '2024-01-10'],
|
||||
longest: 2,
|
||||
},
|
||||
])(
|
||||
'keeps holiday/weekend continuity and zero for empty sequences: $horizon.start',
|
||||
({ horizon, dates, longest: expected }) => {
|
||||
const outcome = plan({
|
||||
horizon,
|
||||
balances: [],
|
||||
declaredDays: dates.map((date) => ({ date, type: 'other' })),
|
||||
rules: [longest, deficit],
|
||||
});
|
||||
expect(outcome.metrics.longestWorkSequence).toBe(expected);
|
||||
expect(outcome.metrics.shortSequenceDeficit).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('disabling work-sequence rules removes their influence even with deferred balances', () => {
|
||||
const absent = plan({ ...gap, rules: [] });
|
||||
const disabled = plan({
|
||||
...gap,
|
||||
rules: [
|
||||
{ ...deficit, enabled: false },
|
||||
{ ...longest, enabled: false },
|
||||
],
|
||||
});
|
||||
expect(disabled).toEqual(absent);
|
||||
const personal = {
|
||||
id: 'personal',
|
||||
type: 'time_window' as const,
|
||||
startDate: '2024-01-09',
|
||||
endDate: '2024-01-09',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
};
|
||||
const requested = plan({ ...gap, rules: [{ ...deficit, enabled: false }, personal] });
|
||||
expect(requested.placements.map(({ date }) => date)).toEqual(['2024-01-09']);
|
||||
expect(requested.metrics.shortSequenceDeficit).toBe(3);
|
||||
});
|
||||
|
||||
it('finishes a rejected tied-sequence bundle without retrying equivalent balance allocations', () => {
|
||||
const outcome = plan({
|
||||
deadlineMs: 1500,
|
||||
horizon: { start: '2024-01-08', end: '2024-02-10' },
|
||||
declaredDays: [
|
||||
'2024-01-08',
|
||||
'2024-01-10',
|
||||
'2024-01-12',
|
||||
'2024-01-16',
|
||||
'2024-01-18',
|
||||
'2024-01-22',
|
||||
'2024-01-24',
|
||||
'2024-01-26',
|
||||
'2024-01-30',
|
||||
'2024-02-01',
|
||||
'2024-02-05',
|
||||
'2024-02-07',
|
||||
'2024-02-09',
|
||||
].map((date) => ({ date, type: 'other' })),
|
||||
balances: ['cp', 'rtt', 'other'].map((type) => ({
|
||||
id: type,
|
||||
type,
|
||||
quantity: 12,
|
||||
expiryDate: '2024-03-01',
|
||||
})),
|
||||
rules: [
|
||||
{
|
||||
id: 'support',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
},
|
||||
longest,
|
||||
{
|
||||
id: 'typed',
|
||||
type: 'time_window',
|
||||
startDate: '2024-01-09',
|
||||
endDate: '2024-01-09',
|
||||
placeType: 'cp',
|
||||
placeDays: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-09']);
|
||||
expect(outcome.metrics.longestWorkSequence).toBe(1);
|
||||
expect(outcome.metrics.supportingPlacements).toBe(0);
|
||||
});
|
||||
|
||||
it.each([0, -1, 1.5, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])(
|
||||
'rejects an invalid preference threshold %s at the planning boundary',
|
||||
(thresholdDays) => {
|
||||
const outcome = calculatePlanningInternal({
|
||||
...gap,
|
||||
rules: [
|
||||
{
|
||||
...deficit,
|
||||
configuration: {
|
||||
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
thresholdDays,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'InvalidInput',
|
||||
reasons: [
|
||||
expect.objectContaining({
|
||||
code: 'invalid-optimization-configuration',
|
||||
ruleIds: ['deficit'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('captures distinct threshold values in explanations, atomic trace and counterfactual comparisons', async () => {
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const five = structuredClone(deficit);
|
||||
const three: OptimizationPlanningRule = {
|
||||
...deficit,
|
||||
id: 'three',
|
||||
configuration: { ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays: 3 },
|
||||
};
|
||||
const calculation = createPlanningCalculation({ ...gap, rules: [three, five] });
|
||||
if (five.configuration.measure !== 'work-sequence-deficit')
|
||||
throw new Error('Expected deficit');
|
||||
five.configuration.thresholdDays = 1;
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
|
||||
expect(outcome.placements).toHaveLength(3);
|
||||
expect(
|
||||
calculation.snapshot.rules.map((rule) =>
|
||||
'configuration' in rule ? rule.configuration : undefined,
|
||||
),
|
||||
).toMatchObject([{ thresholdDays: 3 }, { thresholdDays: 5 }]);
|
||||
const explanation = await calculation.explanation;
|
||||
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
|
||||
expect(
|
||||
explanation.placements[0].comparison.before.objectives
|
||||
.filter(({ id }) => id.startsWith('optimization:'))
|
||||
.map(({ value }) => value),
|
||||
).toEqual([2, 4]);
|
||||
const trace = await calculation.trace;
|
||||
const chosen = trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.find(
|
||||
(candidate) => candidate.status === 'chosen' && candidate.dates?.length === 3,
|
||||
);
|
||||
expect(
|
||||
chosen?.comparison?.before.objectives
|
||||
.filter(({ id }) => id.startsWith('optimization:'))
|
||||
.map(({ value }) => value),
|
||||
).toEqual([0, 2]);
|
||||
expect(chosen?.comparison?.firstDecisiveObjective).toBe('optimization:deficit');
|
||||
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
if (counterfactual.kind !== 'Counterfactual')
|
||||
throw new Error('Expected a counterfactual');
|
||||
expect(
|
||||
counterfactual.baseline.objectives
|
||||
.filter(({ id }) => id.startsWith('optimization:'))
|
||||
.map(({ id, value }) => ({ id, value })),
|
||||
).toEqual([
|
||||
{ id: 'optimization:three', value: 2 },
|
||||
{ id: 'optimization:deficit', value: 4 },
|
||||
]);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
+2347
-2505
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
import { createOptimizationPresetRules } from './rule-service';
|
||||
import { vi } from 'vitest';
|
||||
import {
|
||||
applyReplayDeltas,
|
||||
@@ -15,6 +16,18 @@ import type {
|
||||
PlanningTraceUpdate,
|
||||
} from './leave-planner.service';
|
||||
import type { CounterfactualBaselineOptions } from './leave-planner.core';
|
||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||
|
||||
const supportingEconomy = {
|
||||
id: 'economy',
|
||||
type: 'optimization' as const,
|
||||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
};
|
||||
const nonWorkingGain = {
|
||||
id: 'gain',
|
||||
type: 'optimization' as const,
|
||||
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
|
||||
};
|
||||
|
||||
const horizon = { start: '2024-01-01', end: '2025-01-01' };
|
||||
const balance = (
|
||||
@@ -48,6 +61,8 @@ async function runInNonBrowser(request: Parameters<typeof planLeave>[0]) {
|
||||
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const presets = createOptimizationPresetRules();
|
||||
|
||||
describe('calculated work-sequence and time-off-period statistics', () => {
|
||||
it('keeps fractional work means and merges consecutive declared days across a weekend', async () => {
|
||||
const result = isPlan(
|
||||
@@ -1292,7 +1307,7 @@ describe('planLeave', () => {
|
||||
await runInNonBrowser({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-06' },
|
||||
balances: [balance('remaining-cp', 'cp', 4, '2024-01-05')],
|
||||
rules: [],
|
||||
rules: [presets[0]],
|
||||
declaredDays,
|
||||
}),
|
||||
);
|
||||
@@ -1358,7 +1373,7 @@ describe('planLeave', () => {
|
||||
balances: [
|
||||
{ ...balance('cp', 'cp', 1, '2024-05-13'), availableFrom: '2024-05-13' },
|
||||
],
|
||||
rules: [],
|
||||
rules: [presets[0]],
|
||||
declaredDays,
|
||||
}),
|
||||
);
|
||||
@@ -1438,7 +1453,7 @@ describe('planLeave', () => {
|
||||
balances: [
|
||||
{ ...balance('cp', 'cp', 2, '2024-01-04'), availableFrom: '2024-01-04' },
|
||||
],
|
||||
rules: [],
|
||||
rules: [presets[0]],
|
||||
declaredDays,
|
||||
}),
|
||||
);
|
||||
@@ -1466,7 +1481,7 @@ describe('planLeave', () => {
|
||||
await runInNonBrowser({
|
||||
horizon: selectedHorizon,
|
||||
balances: [],
|
||||
rules: [],
|
||||
rules: [presets[0]],
|
||||
declaredDays: declaredDays.filter(({ date }) => date !== '2024-01-03'),
|
||||
}),
|
||||
);
|
||||
@@ -1521,7 +1536,7 @@ describe('planLeave', () => {
|
||||
const result = isPlan(
|
||||
await runInNonBrowser({
|
||||
horizon: { start, end },
|
||||
rules: [],
|
||||
rules: [presets[0]],
|
||||
declaredDays,
|
||||
balances: mixed
|
||||
? [{ ...balance('cp', 'cp', 1, '2024-05-10'), availableFrom: '2024-05-10' }]
|
||||
@@ -1602,7 +1617,7 @@ describe('planLeave', () => {
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2026-11-01', end: '2026-12-01' },
|
||||
balances: [balance('cp', 'cp', 2, '2026-11-30')],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
declaredDays: [
|
||||
{ date: '2026-11-09', type: 'other' },
|
||||
{ date: '2026-11-10', type: 'rtt' },
|
||||
@@ -1619,15 +1634,19 @@ describe('planLeave', () => {
|
||||
await runInNonBrowser({
|
||||
...request,
|
||||
rules: [
|
||||
{
|
||||
id: 'preferred-friday',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2026-11-20',
|
||||
endDate: '2026-11-20',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'preferred-friday',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2026-11-20',
|
||||
endDate: '2026-11-20',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
...presets.slice(1),
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -1927,7 +1946,7 @@ describe('planLeave', () => {
|
||||
await runInNonBrowser({
|
||||
horizon,
|
||||
balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 1)],
|
||||
rules,
|
||||
rules: [presets[0], ...rules],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2015,15 +2034,18 @@ describe('planLeave', () => {
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'rtt-obligation',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-03',
|
||||
placeDays: 1,
|
||||
placeType: 'rtt',
|
||||
},
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'rtt-obligation',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-03',
|
||||
placeDays: 1,
|
||||
placeType: 'rtt',
|
||||
},
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -2256,20 +2278,28 @@ describe('planLeave', () => {
|
||||
await runInNonBrowser({
|
||||
horizon: { start: '2024-01-05', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2025-01-01')],
|
||||
rules: [],
|
||||
rules: [nonWorkingGain, supportingEconomy],
|
||||
}),
|
||||
);
|
||||
const supporting = result.placements.find((placement) => placement.supporting);
|
||||
expect(supporting?.ruleIds).toEqual([]);
|
||||
expect(supporting?.ruleIds).toEqual(['gain']);
|
||||
expect(supporting?.balanceId).toBe('cp');
|
||||
});
|
||||
|
||||
it('does not trade a short-sequence deficit for supporting placement gain', async () => {
|
||||
it('does not trade an earlier short-sequence deficit for supporting placement gain', async () => {
|
||||
const result = isPlan(
|
||||
await runInNonBrowser({
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [balance('cp-four', 'cp', 4, '2025-01-01')],
|
||||
rules: [],
|
||||
rules: [
|
||||
{
|
||||
id: 'deficit',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
},
|
||||
nonWorkingGain,
|
||||
supportingEconomy,
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.metrics.shortSequenceDeficit).toBe(0);
|
||||
@@ -2280,7 +2310,15 @@ describe('planLeave', () => {
|
||||
await runInNonBrowser({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-29' },
|
||||
balances: [balance('cp-six', 'cp', 6, '2025-01-01')],
|
||||
rules: [],
|
||||
rules: [
|
||||
nonWorkingGain,
|
||||
{
|
||||
id: 'longest',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.longestWorkSequence,
|
||||
},
|
||||
supportingEconomy,
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.metrics.supportingPlacements).toBe(
|
||||
@@ -2295,19 +2333,24 @@ describe('planLeave', () => {
|
||||
await runInNonBrowser({
|
||||
horizon: { start: '2024-02-01', end: '2024-03-01' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-02-29')],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
}),
|
||||
);
|
||||
const fourDayPlan = isPlan(
|
||||
await runInNonBrowser({
|
||||
horizon: { start: '2024-02-01', end: '2024-03-01' },
|
||||
balances: [balance('cp', 'cp', 4, '2024-02-29')],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
}),
|
||||
);
|
||||
expect(twoDayPlan.metrics).toMatchObject({
|
||||
nonWorkingDayGain: 4,
|
||||
longestWorkSequence: 9,
|
||||
shortSequenceDeficit: 0,
|
||||
});
|
||||
expect(twoDayPlan.placements.map((placement) => placement.date)).toEqual([
|
||||
'2024-02-09',
|
||||
'2024-02-19',
|
||||
'2024-02-02',
|
||||
'2024-02-16',
|
||||
]);
|
||||
expect(fourDayPlan.placements.map((placement) => placement.date)).toEqual([
|
||||
'2024-02-02',
|
||||
@@ -2335,7 +2378,7 @@ describe('planLeave', () => {
|
||||
await runInNonBrowser({
|
||||
horizon: holidayHorizon,
|
||||
balances: [balance('cp', 'cp', 4, expiryDate)],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2344,7 +2387,7 @@ describe('planLeave', () => {
|
||||
expect(result.timeOffPeriods[0].duration).toBe(9);
|
||||
});
|
||||
|
||||
// Async calculation yields to the test timer; allow the planner's ten-second budget.
|
||||
// This full configured year is measured separately; allow instrumentation overhead without a planning deadline.
|
||||
it('uses the 11 November and 14 July weeks in the representative default plan', async () => {
|
||||
const result = isPlan(
|
||||
await runInNonBrowser({
|
||||
@@ -2364,28 +2407,32 @@ describe('planLeave', () => {
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'default-samedi-malin',
|
||||
name: 'Samedi Malin',
|
||||
type: 'if_then',
|
||||
force: 'preference',
|
||||
dayOfWeek: 6,
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: 'other',
|
||||
},
|
||||
{
|
||||
id: 'default-christmas',
|
||||
name: 'Christmas holidays',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2026-12-20',
|
||||
endDate: '2027-01-03',
|
||||
placeDays: 5,
|
||||
placeType: 'any',
|
||||
},
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'default-samedi-malin',
|
||||
name: 'Samedi Malin',
|
||||
type: 'if_then',
|
||||
force: 'preference',
|
||||
dayOfWeek: 6,
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: 'other',
|
||||
},
|
||||
{
|
||||
id: 'default-christmas',
|
||||
name: 'Christmas holidays',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2026-12-20',
|
||||
endDate: '2027-01-03',
|
||||
placeDays: 5,
|
||||
placeType: 'any',
|
||||
},
|
||||
],
|
||||
...presets.slice(1),
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -2402,7 +2449,7 @@ describe('planLeave', () => {
|
||||
'2027-07-16',
|
||||
]),
|
||||
);
|
||||
}, 15_000);
|
||||
}, 120_000);
|
||||
|
||||
it('moves a satisfied time-window preference to complete a holiday week', async () => {
|
||||
const result = isPlan(
|
||||
@@ -2410,15 +2457,19 @@ describe('planLeave', () => {
|
||||
horizon: { start: '2027-07-01', end: '2027-08-01' },
|
||||
balances: [balance('cp', 'cp', 4, '2027-07-31')],
|
||||
rules: [
|
||||
{
|
||||
id: 'july',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2027-07-01',
|
||||
endDate: '2027-07-31',
|
||||
placeDays: 4,
|
||||
placeType: 'any',
|
||||
},
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'july',
|
||||
type: 'time_window',
|
||||
force: 'preference',
|
||||
startDate: '2027-07-01',
|
||||
endDate: '2027-07-31',
|
||||
placeDays: 4,
|
||||
placeType: 'any',
|
||||
},
|
||||
],
|
||||
...presets.slice(1),
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -2437,7 +2488,14 @@ describe('planLeave', () => {
|
||||
await runInNonBrowser({
|
||||
horizon: { start: '2027-07-01', end: '2027-08-01' },
|
||||
balances: [balance('cp', 'cp', 4, '2028-07-31')],
|
||||
rules: [],
|
||||
rules: [
|
||||
{
|
||||
id: 'holiday',
|
||||
type: 'optimization',
|
||||
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
|
||||
},
|
||||
supportingEconomy,
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2456,15 +2514,19 @@ describe('planLeave', () => {
|
||||
horizon: { start: '2024-01-08', end: '2024-01-15' },
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [
|
||||
{
|
||||
id: 'window',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-09',
|
||||
endDate: '2024-01-12',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'window',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-09',
|
||||
endDate: '2024-01-12',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
...presets.slice(1),
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -2749,15 +2811,19 @@ describe('planLeave', () => {
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
id: 'fixed',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-05-06',
|
||||
endDate: '2024-05-06',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'fixed',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-05-06',
|
||||
endDate: '2024-05-06',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
...presets.slice(1),
|
||||
],
|
||||
};
|
||||
const expected = isPlan(await runInNonBrowser(request));
|
||||
@@ -2767,7 +2833,8 @@ describe('planLeave', () => {
|
||||
const outcome = await runInNonBrowser({
|
||||
...request,
|
||||
progress: (stage) => {
|
||||
if (stage === 'at-risk') queueMicrotask(() => (elapsed = 20_000));
|
||||
if (stage === 'optimization:default-unplaced-at-risk')
|
||||
queueMicrotask(() => (elapsed = 20_000));
|
||||
},
|
||||
});
|
||||
expect(elapsed).toBeGreaterThan(10_000);
|
||||
@@ -2953,8 +3020,8 @@ describe('planLeave transport', () => {
|
||||
if (expected.kind !== 'Plan' || !checkpointState) return;
|
||||
checkpoint = {
|
||||
...expected,
|
||||
completedStages: ['validation', 'obligations', 'at-risk'],
|
||||
lastCompletedStage: 'at-risk',
|
||||
completedStages: ['validation', 'obligations', 'optimization:default-unplaced-at-risk'],
|
||||
lastCompletedStage: 'optimization:default-unplaced-at-risk',
|
||||
};
|
||||
const initialState: PlanningTraceState = {
|
||||
horizon: request.horizon,
|
||||
@@ -3271,7 +3338,7 @@ describe('planning calculation context', () => {
|
||||
const calculation = createPlanningCalculation(submitted);
|
||||
submitted.declaredDays![0].date = '2024-01-11';
|
||||
submitted.declaredDays![0].type = 'rtt';
|
||||
submitted.rules[0].placeDays = 4;
|
||||
if ('placeDays' in submitted.rules[0]) submitted.rules[0].placeDays = 4;
|
||||
if ('endDate' in submitted.rules[0]) submitted.rules[0].endDate = '2024-01-19';
|
||||
submitted.horizon.end = '2024-01-11';
|
||||
submitted.labels = { exact: 'Edited later' };
|
||||
@@ -3378,7 +3445,7 @@ describe('planning calculation context', () => {
|
||||
balances: [
|
||||
{ ...balance('cp', 'cp', 1, '2024-12-30'), availableFrom: '2024-12-30' },
|
||||
],
|
||||
rules: [],
|
||||
rules: [presets[0]],
|
||||
declaredDays,
|
||||
});
|
||||
const outcome = isPlan(await calculation.outcome);
|
||||
@@ -3407,7 +3474,7 @@ describe('planning calculation context', () => {
|
||||
const submitted: PlanningRequest = {
|
||||
horizon: { start: '2026-11-01', end: '2026-12-01' },
|
||||
balances: [balance('cp', 'cp', 2, '2026-11-30')],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
declaredDays: [
|
||||
{ date: '2026-11-09', type: 'other' },
|
||||
{ date: '2026-11-10', type: 'rtt' },
|
||||
@@ -3427,7 +3494,9 @@ describe('planning calculation context', () => {
|
||||
for (const placement of explanation.placements) {
|
||||
expect(placement.timeOffPeriod).toEqual(outcome.timeOffPeriods[0]);
|
||||
expect(placement.finalMetrics).toEqual(outcome.metrics);
|
||||
expect(placement.firstDecisiveObjective).toBe('unplaced-at-risk');
|
||||
expect(placement.firstDecisiveObjective).toBe(
|
||||
'optimization:default-unplaced-at-risk',
|
||||
);
|
||||
}
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.initialState.declaredDays).toEqual(original);
|
||||
@@ -3446,11 +3515,13 @@ describe('planning calculation context', () => {
|
||||
(candidate) =>
|
||||
candidate.comparison?.after.objectives.some(
|
||||
(objective) =>
|
||||
objective.id === 'holiday-week-value' && objective.value === 3,
|
||||
objective.id === 'optimization:default-holiday-week-value' &&
|
||||
objective.value === 3,
|
||||
) &&
|
||||
candidate.comparison.after.objectives.some(
|
||||
(objective) =>
|
||||
objective.id === 'non-working-day-gain' && objective.value === 5,
|
||||
objective.id === 'optimization:default-non-working-day-gain' &&
|
||||
objective.value === 5,
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
@@ -3463,10 +3534,16 @@ describe('planning calculation context', () => {
|
||||
const alternative = analysis.results.find(({ date }) => date === '2026-11-20');
|
||||
expect(alternative?.status).toBe('eligible');
|
||||
expect(alternative?.comparison?.after.objectives).toContainEqual(
|
||||
expect.objectContaining({ id: 'holiday-week-value', value: 0 }),
|
||||
expect.objectContaining({
|
||||
id: 'optimization:default-holiday-week-value',
|
||||
value: 0,
|
||||
}),
|
||||
);
|
||||
expect(alternative?.comparison?.after.objectives).toContainEqual(
|
||||
expect.objectContaining({ id: 'non-working-day-gain', value: 7 }),
|
||||
expect.objectContaining({
|
||||
id: 'optimization:default-non-working-day-gain',
|
||||
value: 7,
|
||||
}),
|
||||
);
|
||||
expect(outcome).toEqual(before);
|
||||
} finally {
|
||||
@@ -3481,7 +3558,7 @@ describe('planning calculation context', () => {
|
||||
const submitted: PlanningRequest = {
|
||||
horizon: { start: '2024-01-02', end: '2024-02-01' },
|
||||
balances: [balance('cp', 'cp', 4, '2024-01-05')],
|
||||
rules: [],
|
||||
rules: [presets[0]],
|
||||
declaredDays: [
|
||||
{ date: '2024-01-02', type: 'cp' },
|
||||
{ date: '2024-01-03', type: 'rtt' },
|
||||
@@ -3828,7 +3905,7 @@ describe('planning calculation context', () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2024-12-31')],
|
||||
rules: [],
|
||||
rules: [presets[3]],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
@@ -3886,7 +3963,7 @@ describe('planning calculation context', () => {
|
||||
if (!firstOccurrence || !secondOccurrence) return;
|
||||
|
||||
const alternateSecondDate =
|
||||
secondOccurrence.date === '2024-05-07' ? '2024-05-09' : '2024-05-07';
|
||||
secondOccurrence.date === '2024-05-07' ? '2024-05-10' : '2024-05-07';
|
||||
const analysis = await calculation.analyzeCounterfactual(firstOccurrence);
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind !== 'Counterfactual') return;
|
||||
@@ -3976,7 +4053,7 @@ describe('planning calculation context', () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-05', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2025-01-01')],
|
||||
rules: [],
|
||||
rules: [nonWorkingGain, supportingEconomy],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
@@ -3991,7 +4068,7 @@ describe('planning calculation context', () => {
|
||||
const restored = analysis.results.find((result) => result.date === selected.date);
|
||||
expect(
|
||||
restored?.comparison?.after.objectives.find(
|
||||
(objective) => objective.id === 'supporting-placements',
|
||||
(objective) => objective.id === 'optimization:economy',
|
||||
)?.value,
|
||||
).toBe(1);
|
||||
} finally {
|
||||
@@ -4097,7 +4174,7 @@ describe('planning calculation context', () => {
|
||||
const older = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
rules: [presets[3]],
|
||||
});
|
||||
const olderOutcome = await older.outcome;
|
||||
expect(olderOutcome.kind).toBe('Plan');
|
||||
@@ -4116,7 +4193,7 @@ describe('planning calculation context', () => {
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
rules: [presets[3]],
|
||||
};
|
||||
const expected = isPlan(await runInNonBrowser(request));
|
||||
const selected = expected.placements[0];
|
||||
@@ -4213,7 +4290,7 @@ describe('planning calculation context', () => {
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
rules: [presets[3]],
|
||||
};
|
||||
const expected = isPlan(await runInNonBrowser(request));
|
||||
const trace = {
|
||||
@@ -4343,15 +4420,18 @@ describe('planning calculation context', () => {
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [balance('cp', 'cp', 2, '2024-01-31')],
|
||||
rules: [
|
||||
{
|
||||
id: 'wide',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'wide',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-01',
|
||||
endDate: '2024-01-31',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
@@ -4404,7 +4484,7 @@ describe('planning calculation context', () => {
|
||||
availableFrom: '2024-01-04',
|
||||
},
|
||||
],
|
||||
rules: [],
|
||||
rules: [presets[0]],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
@@ -4466,7 +4546,7 @@ describe('planning calculation context', () => {
|
||||
const request = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1)],
|
||||
rules: [],
|
||||
rules: [presets[3]],
|
||||
};
|
||||
const plan = isPlan(await runInNonBrowser(request));
|
||||
const firstPlacement = plan.placements[0];
|
||||
@@ -4745,81 +4825,98 @@ describe('planning calculation context', () => {
|
||||
});
|
||||
|
||||
it('does not inherit unrelated rule-invalid history into a trace decision', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2024-01-31')],
|
||||
rules: [
|
||||
{
|
||||
id: 'early-window',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-04',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
{
|
||||
id: 'late-window',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-03',
|
||||
endDate: '2024-01-05',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan' || !trace) return;
|
||||
const decision = trace.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.find((candidate) =>
|
||||
candidate.deltas.some((delta) => delta.kind === 'placement-added'),
|
||||
);
|
||||
const placement = decision?.deltas.find(
|
||||
(delta) => delta.kind === 'placement-added',
|
||||
)?.placement;
|
||||
expect(decision).toBeDefined();
|
||||
expect(placement).toBeDefined();
|
||||
if (!decision || !placement) return;
|
||||
|
||||
const candidates = trace.phases.flatMap((phase) =>
|
||||
phase.decisions.flatMap((item) => item.candidates),
|
||||
);
|
||||
const decisionDates = new Set(
|
||||
decision.candidates.flatMap(
|
||||
(candidate) => candidate.dates ?? (candidate.date ? [candidate.date] : []),
|
||||
const core = await import('./leave-planner.core');
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||||
balances: [balance('cp', 'cp', 1, '2024-01-10')],
|
||||
rules: [presets[0]],
|
||||
};
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||
const outcome = core.calculatePlanningInternal(
|
||||
request,
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
const trace = artifacts.trace && {
|
||||
...artifacts.trace,
|
||||
phases: [...artifacts.trace.phases],
|
||||
};
|
||||
artifacts = { ...artifacts, trace };
|
||||
if (outcome.kind !== 'Plan' || !trace)
|
||||
throw new Error('Expected an actual plan and trace.');
|
||||
const decision = trace.phases
|
||||
.flatMap((phase) => phase.decisions)
|
||||
.find((item) => item.deltas.some((delta) => delta.kind === 'placement-added'));
|
||||
const placement = decision?.deltas.find(
|
||||
(delta) => delta.kind === 'placement-added',
|
||||
)?.placement;
|
||||
if (!decision || !placement) throw new Error('Expected an actual placement decision.');
|
||||
// An unrelated decision's rejection must not become the selected decision's history.
|
||||
const unrelatedDate = '2024-01-04';
|
||||
decision.candidates = decision.candidates.filter(
|
||||
(candidate) =>
|
||||
!(candidate.dates ?? (candidate.date ? [candidate.date] : [])).includes(
|
||||
unrelatedDate,
|
||||
),
|
||||
);
|
||||
const unrelatedIneligible = candidates.find(
|
||||
(candidate) =>
|
||||
candidate.status === 'ineligible' &&
|
||||
candidate.reason?.code === 'rule-invalid' &&
|
||||
candidate.date &&
|
||||
!decisionDates.has(candidate.date),
|
||||
);
|
||||
expect(unrelatedIneligible).toBeDefined();
|
||||
if (!unrelatedIneligible?.date) return;
|
||||
|
||||
);
|
||||
trace.phases.push({
|
||||
id: 'unrelated-phase',
|
||||
name: 'unrelated',
|
||||
completed: true,
|
||||
status: 'completed',
|
||||
decisions: [
|
||||
{
|
||||
id: 'unrelated-decision',
|
||||
action: 'reject',
|
||||
dates: [unrelatedDate],
|
||||
deltas: [],
|
||||
candidates: [
|
||||
{
|
||||
status: 'ineligible',
|
||||
date: unrelatedDate,
|
||||
reason: {
|
||||
code: 'rule-invalid',
|
||||
message: 'Another decision rejected this date.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
class HistoricalTraceWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
postMessage(): void {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({ data: { type: 'result', outcome } } as MessageEvent);
|
||||
this.onmessage?.({ data: { type: 'artifacts', artifacts } } as MessageEvent);
|
||||
});
|
||||
}
|
||||
terminate(): void {}
|
||||
}
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', {});
|
||||
vi.stubGlobal('Worker', HistoricalTraceWorker);
|
||||
try {
|
||||
const calculation = createPlanningCalculation(request);
|
||||
await calculation.trace;
|
||||
vi.stubGlobal('window', undefined);
|
||||
const analysis = await calculation.analyzeCounterfactual({
|
||||
decisionId: decision.id,
|
||||
placement,
|
||||
});
|
||||
expect(analysis.kind).toBe('Counterfactual');
|
||||
if (analysis.kind !== 'Counterfactual') return;
|
||||
expect(
|
||||
analysis.results.find((result) => result.date === unrelatedIneligible.date)
|
||||
?.historicalStatus,
|
||||
).toBe('not-examined');
|
||||
expect(analysis.results.find((result) => result.date === unrelatedDate)).toMatchObject({
|
||||
historicalStatus: 'not-examined',
|
||||
historicalReason: undefined,
|
||||
});
|
||||
expect(
|
||||
analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus,
|
||||
).toBe('ineligible');
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
});
|
||||
@@ -4831,7 +4928,7 @@ describe('planning calculation context', () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [balance('cp', 'cp', 4, '2024-01-31')],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
const trace = await calculation.trace;
|
||||
@@ -4854,12 +4951,12 @@ describe('planning calculation context', () => {
|
||||
expect(trace.phases.map((phase) => phase.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'obligations',
|
||||
'at-risk',
|
||||
'holiday-week-value',
|
||||
'non-working-day-gain',
|
||||
'longest-work-sequence',
|
||||
'short-sequence-deficit',
|
||||
'supporting-placements',
|
||||
'optimization:default-unplaced-at-risk',
|
||||
'optimization:default-holiday-week-value',
|
||||
'optimization:default-non-working-day-gain',
|
||||
'optimization:default-longest-work-sequence',
|
||||
'optimization:default-short-sequence-deficit',
|
||||
'optimization:default-supporting-placements',
|
||||
'expiry',
|
||||
'dates',
|
||||
]),
|
||||
@@ -4881,7 +4978,7 @@ describe('planning calculation context', () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2024-04-01' },
|
||||
balances: [balance('cp', 'cp', 25, '2024-03-31')],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
});
|
||||
expect((await calculation.outcome).kind).toBe('Plan');
|
||||
const trace = await calculation.trace;
|
||||
@@ -4907,16 +5004,18 @@ describe('planning calculation context', () => {
|
||||
);
|
||||
expect(chosenComparisons.length).toBeGreaterThan(0);
|
||||
for (const { decision, candidate: chosen } of chosenComparisons) {
|
||||
const changedDates = chosen.dates ?? (chosen.date ? [chosen.date] : []);
|
||||
expect(
|
||||
decision.deltas.some(
|
||||
(delta) =>
|
||||
(delta.kind === 'placement-moved' &&
|
||||
delta.toDate === chosen.date &&
|
||||
delta.balanceId === chosen.placement?.balanceId) ||
|
||||
changedDates.includes(delta.toDate ?? '')) ||
|
||||
(delta.kind === 'placement-added' &&
|
||||
delta.date === chosen.date &&
|
||||
delta.balanceId === chosen.placement?.balanceId) ||
|
||||
(delta.kind === 'placement-removed' && delta.date === chosen.date),
|
||||
changedDates.includes(delta.date ?? '')) ||
|
||||
(delta.kind === 'placement-removed' &&
|
||||
changedDates.includes(delta.date ?? '')) ||
|
||||
(delta.kind === 'placement-updated' &&
|
||||
changedDates.includes(delta.date ?? '')),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
@@ -4942,7 +5041,7 @@ describe('planning calculation context', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('records every eligible balance when a preference keeps the first assignment', async () => {
|
||||
it('evaluates every eligible balance and retains the earlier expiry for an equal preference', async () => {
|
||||
const browserWindow = globalThis.window;
|
||||
vi.stubGlobal('window', undefined);
|
||||
try {
|
||||
@@ -4980,10 +5079,12 @@ describe('planning calculation context', () => {
|
||||
status: candidate.status,
|
||||
reason: candidate.reason?.code,
|
||||
})) ?? [];
|
||||
expect(candidates.slice(0, 2)).toEqual([
|
||||
{ balanceId: 'earlier', status: 'chosen', reason: undefined },
|
||||
{ balanceId: 'later', status: 'not-examined', reason: 'first-compatible-balance' },
|
||||
]);
|
||||
expect(candidates).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ balanceId: 'earlier', status: 'chosen', reason: undefined },
|
||||
{ balanceId: 'later', status: 'evaluated-rejected', reason: undefined },
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
@@ -5026,7 +5127,11 @@ describe('planning calculation context', () => {
|
||||
{ id: 'cp-only', label: 'Paid leave only' },
|
||||
]);
|
||||
const trace = await conflictCalculation.trace;
|
||||
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
|
||||
expect(
|
||||
trace?.phases.some(
|
||||
(phase) => phase.name === 'optimization:default-holiday-week-value',
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
vi.stubGlobal('window', browserWindow);
|
||||
}
|
||||
@@ -5056,28 +5161,41 @@ describe('planning calculation context', () => {
|
||||
|
||||
it('explains an incomplete incumbent without reusing the expired planning deadline', async () => {
|
||||
const core = await import('./leave-planner.core');
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||||
const outcome = core.calculatePlanningInternal(
|
||||
{
|
||||
horizon,
|
||||
balances: Array.from({ length: 12 }, (_, index) =>
|
||||
balance(`balance-${index}`, index % 2 ? 'rtt' : 'cp', 12),
|
||||
),
|
||||
rules: [],
|
||||
deadlineMs: 20,
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||||
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(outcome.incumbent).toBeDefined();
|
||||
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
|
||||
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
|
||||
expect(artifacts.explanation.incomplete?.reason.code).toBe('deadline');
|
||||
expect(artifacts.explanation.placements).toHaveLength(
|
||||
outcome.incumbent?.placements.length ?? 0,
|
||||
);
|
||||
let clock = 0;
|
||||
let expired = false;
|
||||
const clockSpy = vi.spyOn(performance, 'now').mockImplementation(() => {
|
||||
if (expired) clock += 100;
|
||||
return clock;
|
||||
});
|
||||
try {
|
||||
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||||
const outcome = core.calculatePlanningInternal(
|
||||
{
|
||||
horizon,
|
||||
balances: Array.from({ length: 12 }, (_, index) =>
|
||||
balance(`balance-${index}`, index % 2 ? 'rtt' : 'cp', 12),
|
||||
),
|
||||
rules: presets,
|
||||
deadlineMs: 20,
|
||||
progress: (stage: string) => {
|
||||
if (stage === `optimization:${presets[0].id}`) expired = true;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||||
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(outcome.incumbent).toBeDefined();
|
||||
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
|
||||
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
|
||||
expect(artifacts.explanation.incomplete?.reason.code).toBe('deadline');
|
||||
expect(artifacts.explanation.placements).toHaveLength(
|
||||
outcome.incumbent?.placements.length ?? 0,
|
||||
);
|
||||
} finally {
|
||||
clockSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('publishes the worker outcome before delayed explanation artifacts', async () => {
|
||||
|
||||
@@ -80,6 +80,7 @@ export type {
|
||||
TimeOffPeriod,
|
||||
TimeWindowContribution,
|
||||
TimeWindowPlanningRule,
|
||||
OptimizationPlanningRule,
|
||||
TraceCounterfactualSelection,
|
||||
TraceCandidateStatus,
|
||||
} from './leave-planner.core';
|
||||
@@ -117,6 +118,7 @@ interface UntrustedRecord {
|
||||
placement?: unknown;
|
||||
rewardDays?: unknown;
|
||||
rewardType?: unknown;
|
||||
configuration?: unknown;
|
||||
}
|
||||
|
||||
interface TransportSnapshot {
|
||||
@@ -238,6 +240,12 @@ function snapshotRequest(request: unknown): TransportSnapshot {
|
||||
enabled: value?.enabled,
|
||||
force: value?.force,
|
||||
};
|
||||
if (
|
||||
typeof value?.type === 'string' &&
|
||||
value.type.toLowerCase() === 'optimization'
|
||||
) {
|
||||
return { ...base, configuration: cloneUnknown(value.configuration) };
|
||||
}
|
||||
if (typeof value?.type === 'string' && value.type.toLowerCase() === 'time_window') {
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -15,11 +15,14 @@ import { CalendarYear } from '../components/calendar-year/calendar-year';
|
||||
import { CalendarService, SelectableDayType } from './calendar-service';
|
||||
import { CalendarSettingsService } from './calendar-settings-service';
|
||||
import { UserInputService } from './user-input-service';
|
||||
import { RuleService, RuleType } from './rule-service';
|
||||
import { RuleService, RuleType, createOptimizationPresetRules } from './rule-service';
|
||||
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
|
||||
|
||||
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const presets = createOptimizationPresetRules();
|
||||
|
||||
describe('real planning worker cancellation', () => {
|
||||
let directory: string;
|
||||
let bundle: string;
|
||||
@@ -43,7 +46,7 @@ const originalNow = performance.now.bind(performance);
|
||||
if (workerData.simulateLongCalculation)
|
||||
Object.defineProperty(performance, 'now', { value: () => originalNow() + elapsed });
|
||||
globalThis.self = { postMessage: (message) => {
|
||||
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'at-risk')
|
||||
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'optimization:default-unplaced-at-risk')
|
||||
setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0);
|
||||
parentPort.postMessage(message);
|
||||
} };
|
||||
@@ -109,6 +112,123 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
});
|
||||
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
it('preserves a complete optimization configuration and active order in the real worker snapshot', async () => {
|
||||
const configuration: OptimizationConfiguration = structuredClone(
|
||||
OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
);
|
||||
configuration.aggregation = 'maximum';
|
||||
const submitted = structuredClone(configuration);
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-05' },
|
||||
balances: [{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-04' }],
|
||||
rules: [
|
||||
{
|
||||
id: 'personal-loss',
|
||||
name: 'Captured loss rule',
|
||||
type: 'optimization',
|
||||
force: 'preference',
|
||||
configuration,
|
||||
},
|
||||
],
|
||||
});
|
||||
configuration.conditions = [];
|
||||
configuration.direction = 'maximize';
|
||||
configuration.aggregation = 'count';
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
|
||||
expect(outcome.placements).toHaveLength(1);
|
||||
expect(outcome.metrics.unplacedAtRisk).toBe(0);
|
||||
expect(calculation.snapshot.rules[0]).toMatchObject({
|
||||
name: 'Captured loss rule',
|
||||
configuration: submitted,
|
||||
});
|
||||
const explanation = await calculation.explanation;
|
||||
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected worker explanation');
|
||||
expect(
|
||||
explanation.placements[0].objectiveVector.objectives.map((objective) => objective.id),
|
||||
).toEqual([
|
||||
'obligation-validity',
|
||||
'optimization:personal-loss',
|
||||
'balance-expiry',
|
||||
'placement-date',
|
||||
]);
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.map((phase) => phase.name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
'optimization:personal-loss',
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
if (comparison.kind !== 'Counterfactual') throw new Error('Expected worker comparison');
|
||||
expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual([
|
||||
'obligation-validity',
|
||||
'optimization:personal-loss',
|
||||
'balance-expiry',
|
||||
'placement-date',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the submitted work-sequence threshold in the real worker, trace and counterfactuals', async () => {
|
||||
const configuration: OptimizationConfiguration = structuredClone(
|
||||
OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
);
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
|
||||
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [
|
||||
{
|
||||
id: 'deficit',
|
||||
name: 'Captured five-day preference',
|
||||
type: 'optimization',
|
||||
configuration,
|
||||
},
|
||||
],
|
||||
});
|
||||
configuration.thresholdDays = 3;
|
||||
configuration.conditions = [];
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
|
||||
expect(outcome.placements.map(({ date }) => date)).toEqual([
|
||||
'2024-01-09',
|
||||
'2024-01-10',
|
||||
'2024-01-11',
|
||||
]);
|
||||
expect(calculation.snapshot.rules[0]).toMatchObject({
|
||||
configuration: { thresholdDays: 5, conditions: ['internal', 'non-empty'] },
|
||||
});
|
||||
const explanation = await calculation.explanation;
|
||||
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
|
||||
expect(
|
||||
explanation.placements[0].comparison.before.objectives.find(
|
||||
({ id }) => id === 'optimization:deficit',
|
||||
)?.value,
|
||||
).toBe(4);
|
||||
const trace = await calculation.trace;
|
||||
const chosen = trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.find((candidate) => candidate.status === 'chosen' && candidate.dates?.length === 3);
|
||||
expect(chosen?.comparison?.firstDecisiveObjective).toBe('optimization:deficit');
|
||||
expect(
|
||||
chosen?.comparison?.before.objectives.find(({ id }) => id === 'optimization:deficit')
|
||||
?.value,
|
||||
).toBe(2);
|
||||
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
if (counterfactual.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
|
||||
expect(
|
||||
counterfactual.baseline.objectives.find(({ id }) => id === 'optimization:deficit')
|
||||
?.value,
|
||||
).toBe(4);
|
||||
expect(
|
||||
counterfactual.results
|
||||
.find(({ date }) => date === '2024-01-09')
|
||||
?.comparison?.after.objectives.find(({ id }) => id === 'optimization:deficit')
|
||||
?.value,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[true, false],
|
||||
@@ -120,7 +240,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
localStorage.clear();
|
||||
localStorage.setItem('app.language', '"fr"');
|
||||
localStorage.setItem('leaveBalances', '[]');
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
vi.setSystemTime(new Date('2024-05-01T21:59:59Z'));
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
@@ -131,6 +251,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
for (const date of ['2024-04-29', '2024-04-30']) input.toggleDeclaredDay(date, 'cp');
|
||||
input.toggleDeclaredDay('2024-05-06', 'other');
|
||||
const rules = TestBed.inject(RuleService);
|
||||
rules.addRule(presets[0]);
|
||||
rules.addRule({
|
||||
id: 'producer',
|
||||
name: 'CP autour du férié',
|
||||
@@ -155,6 +276,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
placeDays: 2,
|
||||
placeType: SelectableDayType.OTHER,
|
||||
});
|
||||
for (const preset of presets.slice(1)) rules.addRule(preset);
|
||||
const calendar = TestBed.inject(CalendarService);
|
||||
const original = calendar.createPlanningCalculation.bind(calendar);
|
||||
let calculation!: PlanningCalculationContext;
|
||||
@@ -168,7 +290,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
...request,
|
||||
progress: (stage) => {
|
||||
request.progress?.(stage);
|
||||
if (cancelled && stage === 'non-working-day-gain') {
|
||||
if (
|
||||
cancelled &&
|
||||
stage === 'optimization:default-non-working-day-gain'
|
||||
) {
|
||||
fixture.detectChanges();
|
||||
fixture.nativeElement
|
||||
.querySelector('button.cancel-calculation')
|
||||
@@ -249,7 +374,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
expect(dialog.textContent?.includes('Planning partiel')).toBe(cancelled);
|
||||
const originalDeclarations = structuredClone(calculation.snapshot.declaredDays);
|
||||
input.toggleDeclaredDay('2024-04-30', 'rtt');
|
||||
rules.updateRule({ ...rules.rules[0], name: 'Règle modifiée après calcul' });
|
||||
rules.updateRule({
|
||||
...rules.rules.find(({ id }) => id === 'producer')!,
|
||||
name: 'Règle modifiée après calcul',
|
||||
});
|
||||
rules.deleteRule('producer');
|
||||
input.updateBalances(() => []);
|
||||
if (worker) {
|
||||
@@ -355,7 +483,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
balances: [
|
||||
{ id: 'cp', type: 'cp' as const, quantity: 4, expiryDate: '2024-01-05' },
|
||||
],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
declaredDays: [
|
||||
{ date: '2024-01-02', type: 'cp' as const },
|
||||
{ date: '2024-01-03', type: 'rtt' as const },
|
||||
@@ -370,7 +498,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
{
|
||||
...submitted,
|
||||
progress: (stage: string) => {
|
||||
if (stage === 'non-working-day-gain') interruption = true;
|
||||
if (stage === 'optimization:default-non-working-day-gain')
|
||||
interruption = true;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
@@ -383,7 +512,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
const calculation = createPlanningCalculation({
|
||||
...submitted,
|
||||
progress: (stage) => {
|
||||
if (cancelled && stage === 'non-working-day-gain') calculation.cancel();
|
||||
if (cancelled && stage === 'optimization:default-non-working-day-gain')
|
||||
calculation.cancel();
|
||||
},
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
@@ -546,26 +676,30 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
|
||||
rules: [
|
||||
{
|
||||
id: 'fixed',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-02',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
presets[0],
|
||||
...[
|
||||
{
|
||||
id: 'fixed',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-02',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
],
|
||||
...presets.slice(1),
|
||||
],
|
||||
progress: (stage) => {
|
||||
if (stage === 'non-working-day-gain') calculation.cancel();
|
||||
if (stage === 'optimization:default-non-working-day-gain') calculation.cancel();
|
||||
},
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||||
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||
expect(outcome.reasons[0].code).toBe('user-cancelled');
|
||||
expect(outcome.completedStages).toContain('holiday-week-value');
|
||||
expect(outcome.completedStages).not.toContain('non-working-day-gain');
|
||||
expect(outcome.completedStages).toContain('optimization:default-holiday-week-value');
|
||||
expect(outcome.completedStages).not.toContain('optimization:default-non-working-day-gain');
|
||||
const incumbent = outcome.incumbent!;
|
||||
expect(incumbent.placements).toHaveLength(3);
|
||||
expect(incumbent.placements).toContainEqual({
|
||||
@@ -585,9 +719,11 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.status).toBe('incomplete');
|
||||
expect(trace?.error?.code).toBe('user-cancelled');
|
||||
expect(trace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status).toBe(
|
||||
'interrupted',
|
||||
);
|
||||
expect(
|
||||
trace?.phases.find(
|
||||
(phase) => phase.name === 'optimization:default-non-working-day-gain',
|
||||
)?.status,
|
||||
).toBe('interrupted');
|
||||
const frozenOutcome = structuredClone(outcome);
|
||||
calculation.cancel();
|
||||
expect(await calculation.outcome).toEqual(frozenOutcome);
|
||||
@@ -605,7 +741,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
expiryDate: '2026-12-24',
|
||||
},
|
||||
],
|
||||
rules: [],
|
||||
rules: presets,
|
||||
};
|
||||
const { calculatePlanningInternal } = await import('./leave-planner.core');
|
||||
const expected = calculatePlanningInternal(submitted);
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculatePlanningInternal,
|
||||
type PlanningArtifacts,
|
||||
type PlanningRequest,
|
||||
} from './leave-planner.core';
|
||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||
import { createOptimizationPresetRules } from './rule-service';
|
||||
|
||||
const presets = createOptimizationPresetRules();
|
||||
const composedRequest: PlanningRequest & { calculationDate: string } = {
|
||||
calculationDate: '2024-04-01',
|
||||
horizon: { start: '2024-05-06', end: '2024-05-18' },
|
||||
balances: [
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 3,
|
||||
availableFrom: '2024-05-06',
|
||||
expiryDate: '2024-05-17',
|
||||
},
|
||||
],
|
||||
rules: presets,
|
||||
};
|
||||
|
||||
describe('integrated optimization and earned leave', () => {
|
||||
it.each(presets)(
|
||||
'makes disabled $name identical to its absence in the complete configuration',
|
||||
(disabled) => {
|
||||
const absent = calculatePlanningInternal({
|
||||
...composedRequest,
|
||||
rules: presets.filter(({ id }) => id !== disabled.id),
|
||||
});
|
||||
const inactive = calculatePlanningInternal({
|
||||
...composedRequest,
|
||||
rules: presets.map((rule) =>
|
||||
rule.id === disabled.id ? { ...rule, enabled: false } : rule,
|
||||
),
|
||||
});
|
||||
expect(absent.kind).toBe('Plan');
|
||||
expect(inactive).toEqual(absent);
|
||||
},
|
||||
);
|
||||
|
||||
it('runs the complete configuration once after the sole obligation state and records exact reused evaluations', () => {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
composedRequest,
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [{ date: '2024-05-06' }, { date: '2024-05-07' }, { date: '2024-05-10' }],
|
||||
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
|
||||
});
|
||||
const trace = artifacts.trace;
|
||||
expect(trace?.status).toBe('ready');
|
||||
expect(
|
||||
trace?.phases
|
||||
.find(({ name }) => name === 'obligations')
|
||||
?.decisions.flatMap(({ candidates }) => candidates)
|
||||
.some(({ comparison }) => comparison),
|
||||
).toBe(false);
|
||||
expect(
|
||||
trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.some(({ reason }) => reason?.code === 'unchanged-rule-state'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.some(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' && reason?.code === 'unchanged-generic-state',
|
||||
),
|
||||
).toBe(true);
|
||||
if (artifacts.explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected an explanation.');
|
||||
expect(
|
||||
artifacts.explanation.placements[0].objectiveVector.objectives.map(({ id }) => id),
|
||||
).toEqual([
|
||||
'obligation-validity',
|
||||
'optimization:default-unplaced-at-risk',
|
||||
'optimization:default-holiday-week-value',
|
||||
'optimization:default-non-working-day-gain',
|
||||
'optimization:default-longest-work-sequence',
|
||||
'optimization:default-short-sequence-deficit',
|
||||
'optimization:default-supporting-placements',
|
||||
'balance-expiry',
|
||||
'placement-date',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves the first equivalent funder while evaluating typed window alternatives', () => {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
...composedRequest,
|
||||
balances: ['first-cp', 'second-cp', 'rtt'].map((id) => ({
|
||||
id,
|
||||
type: id === 'rtt' ? 'rtt' : 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-05-06',
|
||||
expiryDate: '2024-05-17',
|
||||
})),
|
||||
rules: [
|
||||
presets[0],
|
||||
{
|
||||
id: 'typed-window',
|
||||
type: 'time_window',
|
||||
startDate: '2024-05-07',
|
||||
endDate: '2024-05-07',
|
||||
placeDays: 1,
|
||||
placeType: 'rtt',
|
||||
},
|
||||
...presets.slice(1),
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [
|
||||
{ date: '2024-05-06', balanceId: 'second-cp' },
|
||||
{ date: '2024-05-07', balanceId: 'rtt' },
|
||||
{ date: '2024-05-10', balanceId: 'first-cp' },
|
||||
],
|
||||
preferences: [{ ruleId: 'typed-window', satisfied: 1 }],
|
||||
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
|
||||
});
|
||||
const candidates = artifacts.trace?.phases
|
||||
.find(({ name }) => name === `optimization:${presets[0].id}`)
|
||||
?.decisions.flatMap(({ candidates }) => candidates);
|
||||
const skipped = candidates?.filter(
|
||||
({ reason }) => reason?.code === 'equivalent-date-funding',
|
||||
);
|
||||
expect(skipped?.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
skipped?.every(({ status, comparison }) => status === 'not-examined' && !comparison),
|
||||
).toBe(true);
|
||||
expect(
|
||||
skipped?.some(
|
||||
({ date, placement }) => date === '2024-05-07' && placement?.balanceId === 'rtt',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
candidates?.some(
|
||||
({ date, placement, comparison }) =>
|
||||
date === '2024-05-07' && placement?.balanceId === 'rtt' && comparison,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['maximum', 'unsafe-total'] as const)(
|
||||
'keeps distinct funding evaluations when equivalence cannot be proved (%s)',
|
||||
(control) => {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
calculationDate: '2024-01-01',
|
||||
horizon: { start: '2024-01-02', end: '2024-01-03' },
|
||||
balances: [
|
||||
{
|
||||
id: 'first',
|
||||
type: 'cp',
|
||||
quantity: control === 'maximum' ? 1 : Number.MAX_SAFE_INTEGER,
|
||||
expiryDate: '2024-01-02',
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
type: 'rtt',
|
||||
quantity: control === 'maximum' ? 2 : 1,
|
||||
expiryDate: '2024-01-02',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
presets[0],
|
||||
...(control === 'maximum'
|
||||
? [
|
||||
{
|
||||
id: 'largest-balance',
|
||||
type: 'optimization',
|
||||
configuration: {
|
||||
...OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
aggregation: 'maximum',
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [{ balanceId: control === 'maximum' ? 'second' : 'first' }],
|
||||
});
|
||||
const candidates = artifacts.trace?.phases
|
||||
.find(({ name }) => name === `optimization:${presets[0].id}`)
|
||||
?.decisions.flatMap(({ candidates }) => candidates);
|
||||
expect(
|
||||
candidates?.some(({ reason }) => reason?.code === 'equivalent-date-funding'),
|
||||
).toBe(false);
|
||||
for (const balanceId of ['first', 'second'])
|
||||
expect(
|
||||
candidates?.some(
|
||||
({ placement, comparison }) =>
|
||||
placement?.balanceId === balanceId && comparison,
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('finishes a renamed first quantity rule at zero and still evaluates later priorities and final ties', () => {
|
||||
const loss = { ...presets[0], id: 'personal-quantity', name: 'Personal quantity' };
|
||||
const run = (rules: PlanningRequest['rules']) => {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
{ ...composedRequest, rules },
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
return { outcome, trace: artifacts.trace };
|
||||
};
|
||||
const window = {
|
||||
id: 'already-satisfied',
|
||||
type: 'time_window',
|
||||
startDate: '2024-05-06',
|
||||
endDate: '2024-05-06',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
};
|
||||
const first = run([loss, window, ...presets.slice(1)]);
|
||||
expect(first.outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [{ date: '2024-05-06' }, { date: '2024-05-07' }, { date: '2024-05-10' }],
|
||||
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
|
||||
});
|
||||
const stopped = first.trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.filter(({ reason }) => reason?.code === 'quantity-prefix-lower-bound');
|
||||
expect(stopped?.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
stopped?.every(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' && reason?.ruleIds?.includes(loss.id),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
first.trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.some(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' &&
|
||||
reason?.code === 'satisfied-window-prefix-bound' &&
|
||||
reason.ruleIds?.includes(window.id),
|
||||
),
|
||||
).toBe(true);
|
||||
const expiry = first.trace?.phases.find(({ name }) => name === 'expiry');
|
||||
expect(expiry?.completed).toBe(true);
|
||||
expect(
|
||||
expiry?.decisions.some(({ candidates }) =>
|
||||
candidates.some(({ comparison }) => comparison),
|
||||
),
|
||||
).toBe(true);
|
||||
const reordered = run([presets[1], loss, ...presets.slice(2)]);
|
||||
expect(reordered.outcome.kind).toBe('Plan');
|
||||
expect(
|
||||
reordered.trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.some(({ reason }) => reason?.code === 'quantity-prefix-lower-bound'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['sum', 'count', 'maximum'] as const)(
|
||||
'finishes a configured maximize holiday prefix at its evaluator bound (%s)',
|
||||
(aggregation) => {
|
||||
const holiday = {
|
||||
...presets[1],
|
||||
id: 'personal-holiday',
|
||||
configuration: { ...OPTIMIZATION_PRESETS.holidayWeekValue, aggregation },
|
||||
};
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
{ ...composedRequest, rules: [presets[0], holiday, ...presets.slice(2)] },
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [
|
||||
{ date: '2024-05-06' },
|
||||
{ date: '2024-05-07' },
|
||||
{ date: '2024-05-10' },
|
||||
],
|
||||
metrics: { unplacedAtRisk: 0, holidayWeekValue: 5, nonWorkingDayGain: 6 },
|
||||
});
|
||||
expect(
|
||||
artifacts.trace?.phases
|
||||
.flatMap(({ decisions }) => decisions.flatMap(({ candidates }) => candidates))
|
||||
.some(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' &&
|
||||
reason?.code === 'satisfied-holiday-prefix-bound' &&
|
||||
reason.ruleIds?.includes(holiday.id),
|
||||
),
|
||||
).toBe(true);
|
||||
const final = artifacts.trace?.phases.find(({ name }) => name === 'expiry');
|
||||
expect(final?.completed).toBe(true);
|
||||
expect(
|
||||
final?.decisions.some(({ candidates }) =>
|
||||
candidates.some(({ comparison }) => comparison),
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('commits a strict holiday bundle before deferring old generic alternatives and preserves higher rules', () => {
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
{
|
||||
calculationDate: '2026-11-01',
|
||||
horizon: { start: '2026-11-02', end: '2026-11-14' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2026-11-30' }],
|
||||
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
|
||||
rules: [
|
||||
{
|
||||
id: 'mandatory',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-02',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
presets[0],
|
||||
{
|
||||
id: 'earlier-window',
|
||||
type: 'time_window',
|
||||
startDate: '2026-11-02',
|
||||
endDate: '2026-11-02',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
...presets.slice(1),
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [{ date: '2026-11-02' }, { date: '2026-11-12' }, { date: '2026-11-13' }],
|
||||
preferences: [{ ruleId: 'earlier-window', satisfied: 1 }],
|
||||
metrics: { unplacedAtRisk: 0, holidayWeekValue: 3 },
|
||||
});
|
||||
const holiday = artifacts.trace?.phases.find(
|
||||
({ name }) => name === `optimization:${presets[1].id}`,
|
||||
);
|
||||
const decisions = holiday?.decisions.flatMap(({ candidates }) => candidates);
|
||||
expect(
|
||||
decisions?.some(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' && reason?.code === 'strict-prefix-bundle-progress',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(decisions?.some(({ status, comparison }) => status === 'chosen' && comparison)).toBe(
|
||||
true,
|
||||
);
|
||||
const final = artifacts.trace?.phases.find(({ name }) => name === 'expiry');
|
||||
expect(final?.completed).toBe(true);
|
||||
expect(
|
||||
final?.decisions.some(({ candidates }) =>
|
||||
candidates.some(({ comparison }) => comparison),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'earns an already-complete preference occurrence before comparisons (minimize every balance: %s)',
|
||||
(minimizeEveryBalance) => {
|
||||
const request: PlanningRequest & { calculationDate: string } = {
|
||||
calculationDate: '2026-11-01',
|
||||
horizon: { start: '2026-11-09', end: '2026-11-14' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2027-01-01' }],
|
||||
rules: [
|
||||
{
|
||||
id: 'mandatory',
|
||||
type: 'time_window',
|
||||
force: 'obligation',
|
||||
startDate: '2026-11-12',
|
||||
endDate: '2026-11-12',
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
},
|
||||
...(minimizeEveryBalance
|
||||
? [
|
||||
{
|
||||
id: 'all-balances',
|
||||
type: 'optimization' as const,
|
||||
configuration: {
|
||||
...OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
conditions: [],
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'producer',
|
||||
type: 'if_then',
|
||||
dayOfWeek: 3,
|
||||
placeDays: 1,
|
||||
placeType: 'cp',
|
||||
placement: 'after',
|
||||
rewardDays: 1,
|
||||
rewardType: 'rtt',
|
||||
},
|
||||
],
|
||||
};
|
||||
let artifacts: PlanningArtifacts = {};
|
||||
const outcome = calculatePlanningInternal(
|
||||
request,
|
||||
undefined,
|
||||
(value) => (artifacts = value),
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [
|
||||
{ date: '2026-11-12', type: 'cp', ruleIds: ['mandatory', 'producer'] },
|
||||
],
|
||||
preferences: [{ ruleId: 'producer', satisfied: 1 }],
|
||||
remainingBalances: [
|
||||
{
|
||||
id: 'earned:producer:2026-11-11',
|
||||
type: 'rtt',
|
||||
quantity: 1,
|
||||
availableFrom: '2026-11-13',
|
||||
expiryDate: '2027-11-13',
|
||||
sourceRuleId: 'producer',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(artifacts.finalState?.earnedActivations).toEqual([
|
||||
{
|
||||
id: 'earned:producer:2026-11-11',
|
||||
ruleId: 'producer',
|
||||
occurrence: '2026-11-11',
|
||||
dates: ['2026-11-12'],
|
||||
},
|
||||
]);
|
||||
if (artifacts.explanation?.kind !== 'PlanExplanation')
|
||||
throw new Error('Expected an explanation.');
|
||||
expect(artifacts.explanation.placements[0].contributions).toContainEqual(
|
||||
expect.objectContaining({ kind: 'rule', ruleId: 'producer' }),
|
||||
);
|
||||
expect(artifacts.explanation.ifThenContributions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
ruleId: 'producer',
|
||||
earnedEntitlement: {
|
||||
id: 'earned:producer:2026-11-11',
|
||||
type: 'rtt',
|
||||
quantity: 1,
|
||||
availableFrom: '2026-11-13',
|
||||
expiryDate: '2027-11-13',
|
||||
added: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (minimizeEveryBalance)
|
||||
expect(
|
||||
artifacts.explanation.placements[0].objectiveVector.objectives,
|
||||
).toContainEqual(
|
||||
expect.objectContaining({ id: 'optimization:all-balances', value: 1 }),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
import { vi } from 'vitest';
|
||||
import { calculatePlanningInternal } from './leave-planner.core';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
import type { PlanningRequest } from './leave-planner.service';
|
||||
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
|
||||
|
||||
const loss = {
|
||||
id: 'loss',
|
||||
name: 'Prevent losses',
|
||||
type: 'optimization' as const,
|
||||
force: 'preference' as const,
|
||||
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
};
|
||||
const personal = {
|
||||
id: 'personal',
|
||||
name: 'Personal CP day',
|
||||
type: 'time_window' as const,
|
||||
force: 'preference' as const,
|
||||
startDate: '2024-01-02',
|
||||
endDate: '2024-01-02',
|
||||
placeDays: 1,
|
||||
placeType: 'cp' as const,
|
||||
};
|
||||
const concurrent: PlanningRequest = {
|
||||
horizon: { start: '2024-01-02', end: '2024-01-03' },
|
||||
balances: [
|
||||
{ id: 'risk-rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
|
||||
{ id: 'deferred-cp', type: 'cp', quantity: 1, expiryDate: '2024-02-01' },
|
||||
],
|
||||
rules: [],
|
||||
};
|
||||
|
||||
describe('explicit active planning objectives', () => {
|
||||
beforeEach(() => {
|
||||
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
|
||||
vi.stubGlobal('window', undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('rejects a sparse selector list consistently at direct and calculation boundaries', async () => {
|
||||
const request = {
|
||||
...concurrent,
|
||||
rules: [
|
||||
{ ...loss, configuration: { ...loss.configuration, conditions: new Array(1) } },
|
||||
],
|
||||
};
|
||||
const direct = calculatePlanningInternal(request);
|
||||
const calculation = await createPlanningCalculation(request).outcome;
|
||||
for (const result of [direct, calculation])
|
||||
expect(result).toMatchObject({
|
||||
kind: 'InvalidInput',
|
||||
reasons: [{ code: 'invalid-optimization-configuration', ruleIds: ['loss'] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('adds no leave without active rules and retains declared days', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-05' },
|
||||
balances: [{ id: 'rtt', type: 'rtt', quantity: 2, expiryDate: '2024-01-04' }],
|
||||
rules: [],
|
||||
declaredDays: [{ date: '2024-01-03', type: 'other' }],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome.kind).toBe('Plan');
|
||||
if (outcome.kind !== 'Plan') return;
|
||||
expect(outcome.placements).toEqual([]);
|
||||
expect(outcome.declaredDays).toEqual([{ date: '2024-01-03', type: 'other' }]);
|
||||
expect(outcome.remainingBalances[0].quantity).toBe(2);
|
||||
expect(outcome.remainingBalances[0]).toMatchObject({
|
||||
classification: 'at-risk',
|
||||
reason: { code: 'active-priorities' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['z-early', 'a-later'],
|
||||
['a-early', 'z-later'],
|
||||
])(
|
||||
'compares equal expirations before dates without preferring balance identity %s',
|
||||
(earlyId, laterId) => {
|
||||
const result = calculatePlanningInternal({
|
||||
horizon: { start: '2024-01-09', end: '2024-01-13' },
|
||||
balances: [
|
||||
{
|
||||
id: earlyId,
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-01-09',
|
||||
expiryDate: '2024-02-01',
|
||||
},
|
||||
{
|
||||
id: laterId,
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-01-10',
|
||||
expiryDate: '2024-02-01',
|
||||
},
|
||||
],
|
||||
rules: [{ ...personal, startDate: '2024-01-09', endDate: '2024-01-12' }],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [{ date: '2024-01-09', balanceId: earlyId }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('follows the displayed order when loss prevention competes with a personal preference', async () => {
|
||||
const first = await createPlanningCalculation({ ...concurrent, rules: [loss, personal] })
|
||||
.outcome;
|
||||
const second = await createPlanningCalculation({ ...concurrent, rules: [personal, loss] })
|
||||
.outcome;
|
||||
if (first.kind !== 'Plan' || second.kind !== 'Plan')
|
||||
throw new Error('Expected valid plans');
|
||||
expect(first.placements.map((placement) => placement.balanceId)).toEqual(['risk-rtt']);
|
||||
expect(first.metrics.unplacedAtRisk).toBe(0);
|
||||
expect(first.preferences[0].satisfied).toBe(0);
|
||||
expect(second.placements.map((placement) => placement.balanceId)).toEqual(['deferred-cp']);
|
||||
expect(second.metrics.unplacedAtRisk).toBe(1);
|
||||
expect(second.preferences[0].satisfied).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps typed obligations valid when an optimization would prefer another balance', async () => {
|
||||
const outcome = await createPlanningCalculation({
|
||||
...concurrent,
|
||||
rules: [{ ...personal, force: 'obligation' }, loss],
|
||||
}).outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
|
||||
expect(outcome.placements[0]).toMatchObject({
|
||||
type: 'cp',
|
||||
balanceId: 'deferred-cp',
|
||||
ruleIds: ['personal'],
|
||||
});
|
||||
expect(outcome.metrics.unplacedAtRisk).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves scarce expiry dates before a lower personal preference', async () => {
|
||||
const outcome = await createPlanningCalculation({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-04' },
|
||||
balances: [
|
||||
{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' },
|
||||
{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
|
||||
],
|
||||
rules: [loss, personal],
|
||||
}).outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
|
||||
expect(outcome.metrics.unplacedAtRisk).toBe(0);
|
||||
expect(outcome.placements.map(({ date, type }) => ({ date, type }))).toEqual([
|
||||
{ date: '2024-01-02', type: 'rtt' },
|
||||
{ date: '2024-01-03', type: 'cp' },
|
||||
]);
|
||||
expect(outcome.preferences[0].satisfied).toBe(0);
|
||||
});
|
||||
|
||||
it('can relocate a short chain before adding a balance with a scarce date', async () => {
|
||||
const outcome = await createPlanningCalculation({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-05' },
|
||||
balances: [
|
||||
{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' },
|
||||
{
|
||||
id: 'flexible',
|
||||
type: 'rtt',
|
||||
quantity: 1,
|
||||
availableFrom: '2024-01-03',
|
||||
expiryDate: '2024-01-04',
|
||||
},
|
||||
{ id: 'scarce', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
|
||||
],
|
||||
rules: [loss, personal],
|
||||
}).outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
|
||||
expect(outcome.metrics.unplacedAtRisk).toBe(0);
|
||||
expect(outcome.placements.map(({ date, balanceId }) => ({ date, balanceId }))).toEqual([
|
||||
{ date: '2024-01-02', balanceId: 'scarce' },
|
||||
{ date: '2024-01-03', balanceId: 'cp' },
|
||||
{ date: '2024-01-04', balanceId: 'flexible' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['2024-01-23', '2024-01-24'])(
|
||||
'finishes oversubscribed balances without an eligible free endpoint through %s',
|
||||
async (end) => {
|
||||
const outcome = await createPlanningCalculation({
|
||||
deadlineMs: 500,
|
||||
horizon: { start: '2024-01-02', end },
|
||||
balances: [
|
||||
{ id: 'cp', type: 'cp', quantity: 7, expiryDate: '2024-01-22' },
|
||||
{ id: 'rtt', type: 'rtt', quantity: 8, expiryDate: '2024-01-22' },
|
||||
{ id: 'other', type: 'other', quantity: 1, expiryDate: '2024-01-22' },
|
||||
],
|
||||
rules: [loss],
|
||||
}).outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a completed valid plan');
|
||||
expect(outcome.placements).toHaveLength(15);
|
||||
expect(outcome.metrics.unplacedAtRisk).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('relocates repeated balance tokens across mandatory windows as a complete chain', async () => {
|
||||
const windows = [
|
||||
['cp-first', 'cp', '2024-01-02', '2024-01-03'],
|
||||
['rtt-middle', 'rtt', '2024-01-03', '2024-01-04'],
|
||||
['cp-last', 'cp', '2024-01-04', '2024-01-05'],
|
||||
].map(([id, placeType, startDate, endDate]) => ({
|
||||
id,
|
||||
type: 'time_window' as const,
|
||||
force: 'obligation' as const,
|
||||
placeType,
|
||||
placeDays: 1,
|
||||
startDate,
|
||||
endDate,
|
||||
}));
|
||||
const outcome = await createPlanningCalculation({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-06' },
|
||||
balances: [
|
||||
{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-01-05' },
|
||||
{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-05' },
|
||||
{ id: 'scarce', type: 'other', quantity: 1, expiryDate: '2024-01-02' },
|
||||
],
|
||||
rules: [...windows, loss],
|
||||
}).outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
|
||||
expect(outcome.metrics.unplacedAtRisk).toBe(0);
|
||||
expect(outcome.placements.map(({ date, type }) => ({ date, type }))).toEqual([
|
||||
{ date: '2024-01-02', type: 'other' },
|
||||
{ date: '2024-01-03', type: 'cp' },
|
||||
{ date: '2024-01-04', type: 'rtt' },
|
||||
{ date: '2024-01-05', type: 'cp' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('relocates same-balance tokens with different mandatory provenance', async () => {
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-05' },
|
||||
balances: [
|
||||
{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-01-04' },
|
||||
{ id: 'scarce', type: 'other', quantity: 1, expiryDate: '2024-01-02' },
|
||||
],
|
||||
rules: [
|
||||
{ ...personal, id: 'first', force: 'obligation', endDate: '2024-01-03' },
|
||||
{
|
||||
...personal,
|
||||
id: 'second',
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-03',
|
||||
endDate: '2024-01-04',
|
||||
},
|
||||
loss,
|
||||
],
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
|
||||
expect(outcome.metrics.unplacedAtRisk).toBe(0);
|
||||
const trace = await calculation.trace;
|
||||
expect(
|
||||
trace?.phases
|
||||
.flatMap(({ decisions }) => decisions)
|
||||
.flatMap(({ candidates }) => candidates)
|
||||
.some(
|
||||
({ sourceDate, dates }) =>
|
||||
sourceDate === '2024-01-02' &&
|
||||
dates?.join(',') === '2024-01-02,2024-01-03,2024-01-04',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('satisfies a lower IF_THEN pattern by reassigning its complete typed bundle', async () => {
|
||||
const outcome = await createPlanningCalculation({
|
||||
horizon: { start: '2027-12-20', end: '2027-12-28' },
|
||||
balances: [
|
||||
{ id: 'A-cp', type: 'cp', quantity: 2, expiryDate: '2027-12-27' },
|
||||
{ id: 'B-rtt', type: 'rtt', quantity: 4, expiryDate: '2027-12-27' },
|
||||
],
|
||||
rules: [
|
||||
loss,
|
||||
{
|
||||
id: 'samedi',
|
||||
name: 'Samedi Malin',
|
||||
type: 'if_then',
|
||||
dayOfWeek: 6,
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: 'other',
|
||||
},
|
||||
],
|
||||
}).outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
|
||||
expect(outcome.metrics.unplacedAtRisk).toBe(0);
|
||||
expect(outcome.preferences[0].satisfied).toBe(1);
|
||||
expect(
|
||||
outcome.placements.filter(({ type }) => type === 'cp').map(({ date }) => date),
|
||||
).toEqual(['2027-12-23', '2027-12-24']);
|
||||
});
|
||||
|
||||
it('does not classify a period extension without a calendar benefit as supporting', async () => {
|
||||
const base: PlanningRequest = {
|
||||
horizon: { start: '2026-12-28', end: '2027-01-09' },
|
||||
balances: [
|
||||
{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2027-01-08' },
|
||||
{
|
||||
id: 'cp',
|
||||
type: 'cp',
|
||||
quantity: 1,
|
||||
availableFrom: '2027-01-05',
|
||||
expiryDate: '2027-02-01',
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
...personal,
|
||||
force: 'obligation',
|
||||
startDate: '2027-01-04',
|
||||
endDate: '2027-01-04',
|
||||
placeType: 'rtt',
|
||||
},
|
||||
],
|
||||
};
|
||||
const before = await createPlanningCalculation(base).outcome;
|
||||
const after = await createPlanningCalculation({
|
||||
...base,
|
||||
rules: [
|
||||
...base.rules,
|
||||
{ ...loss, configuration: { ...loss.configuration, conditions: [] } },
|
||||
],
|
||||
}).outcome;
|
||||
if (before.kind !== 'Plan' || after.kind !== 'Plan')
|
||||
throw new Error('Expected valid plans');
|
||||
expect(after.placements.find(({ balanceId }) => balanceId === 'cp')).toMatchObject({
|
||||
date: '2027-01-05',
|
||||
supporting: false,
|
||||
});
|
||||
for (const key of [
|
||||
'holidayWeekValue',
|
||||
'nonWorkingDayGain',
|
||||
'longestWorkSequence',
|
||||
'shortSequenceDeficit',
|
||||
] as const)
|
||||
expect(after.metrics[key]).toBe(before.metrics[key]);
|
||||
expect(after.metrics.supportingPlacements).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps joint window obligations and active priority when large previews are sampled', async () => {
|
||||
const obligations = [
|
||||
['2024-01-02', '2024-01-15'],
|
||||
['2024-01-16', '2024-01-29'],
|
||||
['2024-02-01', '2024-02-14'],
|
||||
].map(([startDate, endDate], index) => ({
|
||||
id: `mandatory-${index}`,
|
||||
type: 'time_window' as const,
|
||||
force: 'obligation' as const,
|
||||
startDate,
|
||||
endDate,
|
||||
placeDays: 2,
|
||||
placeType: 'any' as const,
|
||||
}));
|
||||
const request: PlanningRequest = {
|
||||
horizon: { start: '2024-01-02', end: '2024-02-16' },
|
||||
balances: [
|
||||
{ id: 'cp', type: 'cp', quantity: 5, expiryDate: '2024-02-15' },
|
||||
{ id: 'scarce', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
|
||||
{ id: 'deferred', type: 'cp', quantity: 1, expiryDate: '2024-05-01' },
|
||||
],
|
||||
rules: [...obligations, loss, personal],
|
||||
};
|
||||
const calculation = createPlanningCalculation(request);
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
|
||||
expect(outcome.metrics.unplacedAtRisk).toBe(0);
|
||||
expect(outcome.preferences[0].satisfied).toBe(0);
|
||||
for (const rule of obligations)
|
||||
expect(
|
||||
outcome.placements.filter(({ ruleIds }) => ruleIds.includes(rule.id)),
|
||||
).toHaveLength(2);
|
||||
const trace = await calculation.trace;
|
||||
expect(
|
||||
trace?.phases
|
||||
.flatMap(({ decisions }) => decisions)
|
||||
.flatMap(({ candidates }) => candidates)
|
||||
.some(
|
||||
({ status, reason }) =>
|
||||
status === 'not-examined' &&
|
||||
reason?.code === 'window-obligation-preview-sampling',
|
||||
),
|
||||
).toBe(true);
|
||||
const reversed = await createPlanningCalculation({
|
||||
...request,
|
||||
rules: [...obligations, personal, loss],
|
||||
}).outcome;
|
||||
if (reversed.kind !== 'Plan') throw new Error('Expected a valid plan');
|
||||
expect(reversed.preferences[0].satisfied).toBe(1);
|
||||
expect(reversed.metrics.unplacedAtRisk).toBe(1);
|
||||
}, 20_000);
|
||||
|
||||
it('allows a lower typed preference to choose the balance when loss quantities tie', async () => {
|
||||
const outcome = await createPlanningCalculation({
|
||||
...concurrent,
|
||||
balances: concurrent.balances.map((balance) => ({
|
||||
...balance,
|
||||
expiryDate: '2024-01-02',
|
||||
})),
|
||||
rules: [loss, personal],
|
||||
}).outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
|
||||
expect(outcome.placements[0].type).toBe('cp');
|
||||
expect(outcome.metrics.unplacedAtRisk).toBe(1);
|
||||
expect(outcome.preferences[0].satisfied).toBe(1);
|
||||
});
|
||||
|
||||
it('makes a disabled optimization equivalent to its absence, including all-disabled requests', async () => {
|
||||
for (const otherRules of [[], [personal]]) {
|
||||
const absent = await createPlanningCalculation({ ...concurrent, rules: otherRules })
|
||||
.outcome;
|
||||
const disabled = await createPlanningCalculation({
|
||||
...concurrent,
|
||||
rules: [{ ...loss, enabled: false }, ...otherRules],
|
||||
}).outcome;
|
||||
expect(disabled).toEqual(absent);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, aggregation: 'average' },
|
||||
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: ['complete'] },
|
||||
null,
|
||||
])(
|
||||
'rejects incompatible or malformed active configurations without an obligation conflict: %s',
|
||||
async (configuration) => {
|
||||
const outcome = calculatePlanningInternal({
|
||||
...concurrent,
|
||||
rules: [{ ...loss, configuration }],
|
||||
});
|
||||
expect(outcome.kind).toBe('InvalidInput');
|
||||
if (outcome.kind !== 'InvalidInput') return;
|
||||
expect(outcome.reasons[0].ruleIds).toEqual(['loss']);
|
||||
expect(outcome.reasons[0].code).toMatch(
|
||||
/^(invalid|unsupported)-optimization-configuration$/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects obligation mode for an optimization and ignores a malformed disabled rule', async () => {
|
||||
const invalid = calculatePlanningInternal({
|
||||
...concurrent,
|
||||
rules: [{ ...loss, force: 'obligation' }],
|
||||
});
|
||||
expect(invalid).toMatchObject({
|
||||
kind: 'InvalidInput',
|
||||
reasons: [expect.objectContaining({ code: 'rule-force', ruleIds: ['loss'] })],
|
||||
});
|
||||
const disabled = calculatePlanningInternal({
|
||||
...concurrent,
|
||||
rules: [{ ...loss, enabled: false, configuration: null }],
|
||||
});
|
||||
expect(disabled).toMatchObject({ kind: 'Plan', placements: [] });
|
||||
});
|
||||
|
||||
it('keeps direct snapshots and every explanation/comparison in the submitted active order', async () => {
|
||||
const configuration: OptimizationConfiguration = structuredClone(
|
||||
OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
);
|
||||
const rule = { ...loss, configuration };
|
||||
const calculation = createPlanningCalculation({ ...concurrent, rules: [rule, personal] });
|
||||
configuration.conditions = [];
|
||||
configuration.aggregation = 'count';
|
||||
configuration.direction = 'maximize';
|
||||
rule.name = 'Edited later';
|
||||
const outcome = await calculation.outcome;
|
||||
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
|
||||
expect(outcome.placements[0].balanceId).toBe('risk-rtt');
|
||||
expect(calculation.snapshot.rules[0]).toMatchObject({
|
||||
name: 'Prevent losses',
|
||||
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
});
|
||||
expect(Object.isFrozen(calculation.snapshot.rules[0])).toBe(true);
|
||||
const explanation = await calculation.explanation;
|
||||
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
|
||||
const ids = [
|
||||
'obligation-validity',
|
||||
'optimization:loss',
|
||||
'preference:personal',
|
||||
'balance-expiry',
|
||||
'placement-date',
|
||||
];
|
||||
expect(
|
||||
explanation.placements[0].objectiveVector.objectives.map((objective) => objective.id),
|
||||
).toEqual(ids);
|
||||
expect(explanation.placements[0].objectiveVector.objectives[1].label).toBe(
|
||||
'Prevent losses',
|
||||
);
|
||||
const trace = await calculation.trace;
|
||||
expect(trace?.phases.map((phase) => phase.name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
'optimization:loss',
|
||||
'preference:personal',
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||||
if (comparison.kind !== 'Counterfactual') throw new Error('Expected comparison');
|
||||
expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual(ids);
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,12 @@ import { RuleService, RuleType } from './rule-service';
|
||||
import { UserInputService } from './user-input-service';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
import { PlanningDurationService } from './planning-duration.service';
|
||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||
|
||||
describe('Planning duration estimate', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
|
||||
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
|
||||
TestBed.inject(UserInputService).updateBalances(() => [
|
||||
{
|
||||
@@ -34,6 +35,22 @@ describe('Planning duration estimate', () => {
|
||||
expect(TestBed.inject(RuleService).rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('estimates and calibrates optimization rules without a requested leave quantity', () => {
|
||||
const rules = TestBed.inject(RuleService);
|
||||
rules.addRule({
|
||||
id: 'loss',
|
||||
name: 'Loss',
|
||||
type: RuleType.OPTIMIZATION,
|
||||
enabled: true,
|
||||
force: 'preference',
|
||||
configuration: structuredClone(OPTIMIZATION_PRESETS.unplacedAtRisk),
|
||||
});
|
||||
const duration = TestBed.inject(PlanningDurationService);
|
||||
expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true);
|
||||
duration.recordCompletion(duration.request()!, 20_000);
|
||||
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
|
||||
});
|
||||
|
||||
it('updates for dates, balances and active rules without requesting a calculation', () => {
|
||||
const duration = TestBed.inject(PlanningDurationService);
|
||||
const settings = TestBed.inject(CalendarSettingsService);
|
||||
|
||||
@@ -41,7 +41,9 @@ function initialDurationMs(request: PlanningRequest): number {
|
||||
(dateTime(rule.startDate) >= end || dateTime(rule.endDate) < start)
|
||||
)
|
||||
continue;
|
||||
ruleWeight += (rule.force === 'obligation' ? 0.5 : 0.15) * Math.max(1, rule.placeDays / 3);
|
||||
ruleWeight +=
|
||||
(rule.force === 'obligation' ? 0.5 : 0.15) *
|
||||
Math.max(1, 'placeDays' in rule ? rule.placeDays / 3 : 1);
|
||||
if ('rewardDays' in rule) placements += (Math.max(0, rule.rewardDays) * days) / 365.25;
|
||||
}
|
||||
// Rough prior from the browser references in docs/planning-duration.md, not a deadline.
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
compareObjectiveLists,
|
||||
compareObjectiveValues,
|
||||
evaluateOptimization,
|
||||
firstDifferentObjective,
|
||||
OPTIMIZATION_PRESETS,
|
||||
optimizationUnit,
|
||||
validateOptimizationConfiguration,
|
||||
type OptimizationObservations,
|
||||
} from './planning-objectives';
|
||||
|
||||
const empty: OptimizationObservations = {
|
||||
leaveBalances: [],
|
||||
publicHolidayWeeks: [],
|
||||
timeOffPeriods: [],
|
||||
workSequences: [],
|
||||
leavePlacements: [],
|
||||
};
|
||||
|
||||
describe('composable planning objectives', () => {
|
||||
it('evaluates each internal non-empty sequence with its own working-day threshold', () => {
|
||||
const observations: OptimizationObservations = {
|
||||
...empty,
|
||||
workSequences: [
|
||||
{ length: 1, internal: false },
|
||||
{ length: 0, internal: true },
|
||||
{ length: 1, internal: true },
|
||||
{ length: 4, internal: true },
|
||||
{ length: 6, internal: false },
|
||||
],
|
||||
};
|
||||
expect(evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations)).toBe(
|
||||
5,
|
||||
);
|
||||
expect(
|
||||
evaluateOptimization(
|
||||
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays: 3 },
|
||||
observations,
|
||||
),
|
||||
).toBe(2);
|
||||
expect(evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, observations)).toBe(
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
it('evaluates all six recipes from the same concrete observations', () => {
|
||||
const observations: OptimizationObservations = {
|
||||
leaveBalances: [
|
||||
{ unplacedQuantity: 3, atRisk: true },
|
||||
{ unplacedQuantity: 8, atRisk: false },
|
||||
],
|
||||
publicHolidayWeeks: [
|
||||
{ value: 3, complete: true },
|
||||
{ value: 1, complete: false },
|
||||
{ value: 4, complete: true },
|
||||
],
|
||||
timeOffPeriods: [
|
||||
{ nonWorkingDates: ['2024-01-06', '2024-01-07'] },
|
||||
{ nonWorkingDates: ['2024-05-08', '2024-05-09', '2024-05-09'] },
|
||||
],
|
||||
workSequences: [
|
||||
{ length: 1, internal: false },
|
||||
{ length: 0, internal: true },
|
||||
{ length: 1, internal: true },
|
||||
{ length: 4, internal: true },
|
||||
{ length: 6, internal: false },
|
||||
],
|
||||
leavePlacements: [{ supporting: true }, { supporting: false }, { supporting: true }],
|
||||
};
|
||||
expect(
|
||||
Object.values(OPTIMIZATION_PRESETS).map((configuration) =>
|
||||
evaluateOptimization(configuration, observations),
|
||||
),
|
||||
).toEqual([3, 7, 4, 6, 5, 2]);
|
||||
});
|
||||
|
||||
it.each(['sum', 'count', 'maximum'] as const)(
|
||||
'defines empty %s as zero for every measure',
|
||||
(aggregation) => {
|
||||
for (const configuration of Object.values(OPTIMIZATION_PRESETS)) {
|
||||
expect(evaluateOptimization({ ...configuration, aggregation }, empty)).toBe(0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('composes conditions, aggregation and direction independently of a rule name or identity', () => {
|
||||
const configuration = {
|
||||
...OPTIMIZATION_PRESETS.longestWorkSequence,
|
||||
conditions: ['internal', 'non-empty'] as const,
|
||||
aggregation: 'sum' as const,
|
||||
direction: 'maximize' as const,
|
||||
};
|
||||
const observations: OptimizationObservations = {
|
||||
...empty,
|
||||
workSequences: [
|
||||
{ length: 20, internal: false },
|
||||
{ length: 0, internal: true },
|
||||
{ length: 2, internal: true },
|
||||
{ length: 4, internal: true },
|
||||
],
|
||||
};
|
||||
const rules = [
|
||||
{ id: 'provided', name: 'Original name', configuration },
|
||||
{ id: 'personal', name: 'Renamed rule', configuration: structuredClone(configuration) },
|
||||
];
|
||||
expect(rules.map((rule) => evaluateOptimization(rule.configuration, observations))).toEqual(
|
||||
[6, 6],
|
||||
);
|
||||
expect(evaluateOptimization({ ...configuration, aggregation: 'count' }, observations)).toBe(
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
evaluateOptimization({ ...configuration, aggregation: 'maximum' }, observations),
|
||||
).toBe(4);
|
||||
expect(optimizationUnit(configuration)).toBe('working-days');
|
||||
expect(optimizationUnit({ ...configuration, aggregation: 'count' })).toBe('count');
|
||||
expect(optimizationUnit(OPTIMIZATION_PRESETS.nonWorkingDayGain)).toBe('calendar-days');
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
undefined,
|
||||
'3',
|
||||
Number.MAX_SAFE_INTEGER + 1,
|
||||
])('rejects the invalid working-day deficit threshold %s', (thresholdDays) => {
|
||||
expect(
|
||||
validateOptimizationConfiguration({
|
||||
...OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
thresholdDays,
|
||||
}),
|
||||
).toMatchObject({ valid: false, code: 'invalid-optimization-configuration' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...OPTIMIZATION_PRESETS.nonWorkingDayGain, elements: 'work-sequences' },
|
||||
{ ...OPTIMIZATION_PRESETS.nonWorkingDayGain, conditions: ['at-risk'] },
|
||||
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, aggregation: 'average' },
|
||||
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, thresholdDays: 3 },
|
||||
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdUnit: 'calendar-days' },
|
||||
])('identifies an unsupported composition', (configuration) => {
|
||||
expect(validateOptimizationConfiguration(configuration)).toMatchObject({
|
||||
valid: false,
|
||||
code: 'unsupported-optimization-configuration',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, conditions: ['internal', 'internal'] },
|
||||
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, direction: 'up' },
|
||||
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: new Array(1) },
|
||||
])('rejects malformed configurations', (configuration) => {
|
||||
expect(validateOptimizationConfiguration(configuration)).toMatchObject({
|
||||
valid: false,
|
||||
code: 'invalid-optimization-configuration',
|
||||
});
|
||||
});
|
||||
|
||||
it('never evaluates an unsupported configuration as a preset', () => {
|
||||
expect(() =>
|
||||
evaluateOptimization(
|
||||
{ ...OPTIMIZATION_PRESETS.nonWorkingDayGain, elements: 'work-sequences' },
|
||||
empty,
|
||||
),
|
||||
).toThrow('unsupported-optimization-configuration');
|
||||
});
|
||||
|
||||
it('compares ordered objectives lexicographically with the configured directions', () => {
|
||||
const before = [
|
||||
{ id: 'first', direction: 'minimize' as const, value: 3 },
|
||||
{ id: 'second', direction: 'maximize' as const, value: 100 },
|
||||
{ id: 'date', direction: 'minimize' as const, value: '2024-01-10' },
|
||||
];
|
||||
const earlierPriority = [{ ...before[0], value: 2 }, { ...before[1], value: 0 }, before[2]];
|
||||
expect(compareObjectiveLists(earlierPriority, before)).toBeLessThan(0);
|
||||
expect(firstDifferentObjective(earlierPriority, before)).toBe(0);
|
||||
const laterPriority = [before[0], { ...before[1], value: 101 }, before[2]];
|
||||
expect(compareObjectiveLists(laterPriority, before)).toBeLessThan(0);
|
||||
expect(firstDifferentObjective(laterPriority, before)).toBe(1);
|
||||
const earlierDate = [before[0], before[1], { ...before[2], value: '2024-01-09' }];
|
||||
expect(compareObjectiveLists(earlierDate, before)).toBeLessThan(0);
|
||||
expect(firstDifferentObjective(earlierDate, before)).toBe(2);
|
||||
expect(compareObjectiveLists(before, structuredClone(before))).toBe(0);
|
||||
expect(firstDifferentObjective(before, structuredClone(before))).toBe(-1);
|
||||
expect(compareObjectiveValues('maximize', 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
export type OptimizationElements =
|
||||
| 'leave-balances'
|
||||
| 'public-holiday-weeks'
|
||||
| 'time-off-periods'
|
||||
| 'work-sequences'
|
||||
| 'leave-placements';
|
||||
export type OptimizationCondition =
|
||||
| 'at-risk'
|
||||
| 'complete'
|
||||
| 'internal'
|
||||
| 'non-empty'
|
||||
| 'supporting';
|
||||
export type OptimizationMeasure =
|
||||
| 'unplaced-quantity'
|
||||
| 'public-holiday-week-value'
|
||||
| 'non-working-day-gain'
|
||||
| 'work-sequence-length'
|
||||
| 'work-sequence-deficit'
|
||||
| 'placement-count';
|
||||
export type OptimizationAggregation = 'sum' | 'count' | 'maximum';
|
||||
export type OptimizationDirection = 'minimize' | 'maximize';
|
||||
export type OptimizationUnit = 'working-days' | 'calendar-days' | 'count';
|
||||
|
||||
interface OptimizationConfigurationBase {
|
||||
elements: OptimizationElements;
|
||||
conditions: readonly OptimizationCondition[];
|
||||
aggregation: OptimizationAggregation;
|
||||
direction: OptimizationDirection;
|
||||
}
|
||||
|
||||
export type OptimizationConfiguration = OptimizationConfigurationBase &
|
||||
(
|
||||
| { measure: 'work-sequence-deficit'; thresholdDays: number }
|
||||
| { measure: Exclude<OptimizationMeasure, 'work-sequence-deficit'>; thresholdDays?: never }
|
||||
);
|
||||
|
||||
/** Each row accepts any subset of its conditions and both comparison directions. */
|
||||
export const OPTIMIZATION_CATALOG = [
|
||||
{
|
||||
elements: 'leave-balances',
|
||||
measure: 'unplaced-quantity',
|
||||
conditions: ['at-risk'],
|
||||
aggregations: ['sum', 'count', 'maximum'],
|
||||
unit: 'working-days',
|
||||
},
|
||||
{
|
||||
elements: 'public-holiday-weeks',
|
||||
measure: 'public-holiday-week-value',
|
||||
conditions: ['complete'],
|
||||
aggregations: ['sum', 'count', 'maximum'],
|
||||
unit: 'calendar-days',
|
||||
},
|
||||
{
|
||||
elements: 'time-off-periods',
|
||||
measure: 'non-working-day-gain',
|
||||
conditions: [],
|
||||
aggregations: ['sum', 'count', 'maximum'],
|
||||
unit: 'calendar-days',
|
||||
},
|
||||
{
|
||||
elements: 'work-sequences',
|
||||
measure: 'work-sequence-length',
|
||||
conditions: ['internal', 'non-empty'],
|
||||
aggregations: ['sum', 'count', 'maximum'],
|
||||
unit: 'working-days',
|
||||
},
|
||||
{
|
||||
elements: 'work-sequences',
|
||||
measure: 'work-sequence-deficit',
|
||||
conditions: ['internal', 'non-empty'],
|
||||
aggregations: ['sum', 'count', 'maximum'],
|
||||
unit: 'working-days',
|
||||
},
|
||||
{
|
||||
elements: 'leave-placements',
|
||||
measure: 'placement-count',
|
||||
conditions: ['supporting'],
|
||||
aggregations: ['sum', 'count', 'maximum'],
|
||||
unit: 'count',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type OptimizationValidation =
|
||||
| { valid: true; configuration: OptimizationConfiguration }
|
||||
| {
|
||||
valid: false;
|
||||
code: 'invalid-optimization-configuration' | 'unsupported-optimization-configuration';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function validateOptimizationConfiguration(value: unknown): OptimizationValidation {
|
||||
const invalid = (message: string): OptimizationValidation => ({
|
||||
valid: false,
|
||||
code: 'invalid-optimization-configuration',
|
||||
message,
|
||||
});
|
||||
const unsupported = (message: string): OptimizationValidation => ({
|
||||
valid: false,
|
||||
code: 'unsupported-optimization-configuration',
|
||||
message,
|
||||
});
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
return invalid('An optimization configuration must be an object.');
|
||||
const config = value as Record<string, unknown>;
|
||||
const fields = [
|
||||
'elements',
|
||||
'conditions',
|
||||
'measure',
|
||||
'aggregation',
|
||||
'direction',
|
||||
'thresholdDays',
|
||||
];
|
||||
if (Object.keys(config).some((key) => !fields.includes(key)))
|
||||
return unsupported('The optimization configuration contains an unsupported field.');
|
||||
if (config['direction'] !== 'minimize' && config['direction'] !== 'maximize')
|
||||
return invalid('The optimization direction must be minimize or maximize.');
|
||||
if (
|
||||
!Array.isArray(config['conditions']) ||
|
||||
[...config['conditions']].some((condition) => typeof condition !== 'string') ||
|
||||
new Set(config['conditions']).size !== config['conditions'].length
|
||||
)
|
||||
return invalid('Optimization conditions must be a list of distinct conditions.');
|
||||
const row = OPTIMIZATION_CATALOG.find(
|
||||
(item) => item.elements === config['elements'] && item.measure === config['measure'],
|
||||
);
|
||||
if (!row) return unsupported('The planning elements and measure are not compatible.');
|
||||
const conditions: readonly string[] = row.conditions;
|
||||
if (config['conditions'].some((condition) => !conditions.includes(condition)))
|
||||
return unsupported('A condition is not supported for these planning elements.');
|
||||
const aggregations: readonly string[] = row.aggregations;
|
||||
if (typeof config['aggregation'] !== 'string' || !aggregations.includes(config['aggregation']))
|
||||
return unsupported('The aggregation is not supported for this measure.');
|
||||
if (config['measure'] === 'work-sequence-deficit') {
|
||||
if (!Number.isSafeInteger(config['thresholdDays']) || Number(config['thresholdDays']) <= 0)
|
||||
return invalid('The deficit threshold must be a positive integer in working days.');
|
||||
} else if ('thresholdDays' in config)
|
||||
return unsupported('Only the work-sequence deficit accepts a working-day threshold.');
|
||||
return { valid: true, configuration: value as OptimizationConfiguration };
|
||||
}
|
||||
|
||||
export function optimizationUnit(configuration: OptimizationConfiguration): OptimizationUnit {
|
||||
return configuration.aggregation === 'count'
|
||||
? 'count'
|
||||
: OPTIMIZATION_CATALOG.find(
|
||||
(row) =>
|
||||
row.elements === configuration.elements &&
|
||||
row.measure === configuration.measure,
|
||||
)!.unit;
|
||||
}
|
||||
|
||||
export const OPTIMIZATION_PRESETS = {
|
||||
unplacedAtRisk: {
|
||||
elements: 'leave-balances',
|
||||
conditions: ['at-risk'],
|
||||
measure: 'unplaced-quantity',
|
||||
aggregation: 'sum',
|
||||
direction: 'minimize',
|
||||
},
|
||||
holidayWeekValue: {
|
||||
elements: 'public-holiday-weeks',
|
||||
conditions: ['complete'],
|
||||
measure: 'public-holiday-week-value',
|
||||
aggregation: 'sum',
|
||||
direction: 'maximize',
|
||||
},
|
||||
nonWorkingDayGain: {
|
||||
elements: 'time-off-periods',
|
||||
conditions: [],
|
||||
measure: 'non-working-day-gain',
|
||||
aggregation: 'sum',
|
||||
direction: 'maximize',
|
||||
},
|
||||
longestWorkSequence: {
|
||||
elements: 'work-sequences',
|
||||
conditions: [],
|
||||
measure: 'work-sequence-length',
|
||||
aggregation: 'maximum',
|
||||
direction: 'minimize',
|
||||
},
|
||||
shortSequenceDeficit: {
|
||||
elements: 'work-sequences',
|
||||
conditions: ['internal', 'non-empty'],
|
||||
measure: 'work-sequence-deficit',
|
||||
thresholdDays: 5,
|
||||
aggregation: 'sum',
|
||||
direction: 'minimize',
|
||||
},
|
||||
supportingPlacements: {
|
||||
elements: 'leave-placements',
|
||||
conditions: ['supporting'],
|
||||
measure: 'placement-count',
|
||||
aggregation: 'count',
|
||||
direction: 'minimize',
|
||||
},
|
||||
} as const satisfies Record<string, OptimizationConfiguration>;
|
||||
|
||||
/** Concrete observations from one plan; they carry no rule identity or display name. */
|
||||
export interface OptimizationObservations {
|
||||
leaveBalances: readonly { unplacedQuantity: number; atRisk: boolean }[];
|
||||
publicHolidayWeeks: readonly { value: number; complete: boolean }[];
|
||||
timeOffPeriods: readonly { nonWorkingDates: readonly string[] }[];
|
||||
workSequences: readonly { length: number; internal: boolean }[];
|
||||
leavePlacements: readonly { supporting: boolean }[];
|
||||
}
|
||||
|
||||
export function evaluateOptimization(
|
||||
configuration: OptimizationConfiguration,
|
||||
observations: OptimizationObservations,
|
||||
): number {
|
||||
const validation = validateOptimizationConfiguration(configuration);
|
||||
if (!validation.valid) throw new Error(`${validation.code}: ${validation.message}`);
|
||||
const selected = (condition: OptimizationCondition) =>
|
||||
configuration.conditions.includes(condition);
|
||||
let values: number[];
|
||||
switch (configuration.elements) {
|
||||
case 'leave-balances':
|
||||
values = observations.leaveBalances
|
||||
.filter((balance) => !selected('at-risk') || balance.atRisk)
|
||||
.map((balance) => balance.unplacedQuantity);
|
||||
break;
|
||||
case 'public-holiday-weeks':
|
||||
values = observations.publicHolidayWeeks
|
||||
.filter((week) => !selected('complete') || week.complete)
|
||||
.map((week) => week.value);
|
||||
break;
|
||||
case 'time-off-periods':
|
||||
values = observations.timeOffPeriods.map(
|
||||
(period) => new Set(period.nonWorkingDates).size,
|
||||
);
|
||||
break;
|
||||
case 'work-sequences':
|
||||
values = observations.workSequences
|
||||
.filter(
|
||||
(sequence) =>
|
||||
(!selected('internal') || sequence.internal) &&
|
||||
(!selected('non-empty') || sequence.length > 0),
|
||||
)
|
||||
.map((sequence) =>
|
||||
configuration.measure === 'work-sequence-deficit'
|
||||
? sequence.length > 0
|
||||
? Math.max(0, configuration.thresholdDays - sequence.length)
|
||||
: 0
|
||||
: sequence.length,
|
||||
);
|
||||
break;
|
||||
case 'leave-placements':
|
||||
values = observations.leavePlacements
|
||||
.filter((placement) => !selected('supporting') || placement.supporting)
|
||||
.map(() => 1);
|
||||
}
|
||||
switch (configuration.aggregation) {
|
||||
case 'count':
|
||||
return values.length;
|
||||
case 'sum':
|
||||
return values.reduce((total, value) => total + value, 0);
|
||||
case 'maximum':
|
||||
return values.reduce((maximum, value) => Math.max(maximum, value), 0);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ComparableObjective {
|
||||
id: string;
|
||||
direction: OptimizationDirection;
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
/** Negative means the left candidate is preferred. Strings retain lexical tie-break ordering. */
|
||||
export function compareObjectiveValues(
|
||||
direction: OptimizationDirection,
|
||||
left: number | string,
|
||||
right: number | string,
|
||||
): number {
|
||||
if (left === right) return 0;
|
||||
const difference =
|
||||
typeof left === 'number' && typeof right === 'number'
|
||||
? left - right
|
||||
: left < right
|
||||
? -1
|
||||
: left > right
|
||||
? 1
|
||||
: 0;
|
||||
return direction === 'minimize' ? difference : -difference;
|
||||
}
|
||||
|
||||
export function firstDifferentObjective(
|
||||
left: readonly ComparableObjective[],
|
||||
right: readonly ComparableObjective[],
|
||||
): number {
|
||||
return left.findIndex((objective, index) => objective.value !== right[index]?.value);
|
||||
}
|
||||
|
||||
export function compareObjectiveLists(
|
||||
left: readonly ComparableObjective[],
|
||||
right: readonly ComparableObjective[],
|
||||
): number {
|
||||
const index = firstDifferentObjective(left, right);
|
||||
return index < 0
|
||||
? 0
|
||||
: compareObjectiveValues(left[index].direction, left[index].value, right[index].value);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RuleService, RuleType } from './rule-service';
|
||||
import { vi } from 'vitest';
|
||||
import { CalendarSettingsService } from './calendar-settings-service';
|
||||
import { PlanningDurationService } from './planning-duration.service';
|
||||
import { UserInputService } from './user-input-service';
|
||||
import { SelectableDayType } from './calendar-service';
|
||||
import { createPlanningCalculation } from './leave-planner.service';
|
||||
|
||||
describe('saved rule migration', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
TestBed.resetTestingModule();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('restores every legacy record unchanged between loss prevention and the other five presets', () => {
|
||||
const legacy = [
|
||||
{
|
||||
id: 'personal-window',
|
||||
name: 'My fixed window',
|
||||
type: RuleType.TIME_WINDOW,
|
||||
enabled: true,
|
||||
force: 'obligation',
|
||||
startDate: '2024-01-10',
|
||||
endDate: '2024-01-12',
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
},
|
||||
{
|
||||
id: 'personal-pattern',
|
||||
name: 'My disabled pattern',
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: false,
|
||||
dayOfWeek: 6,
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
placement: 'around',
|
||||
rewardDays: 1,
|
||||
rewardType: 'other',
|
||||
},
|
||||
];
|
||||
localStorage.setItem('vacationRules', JSON.stringify(legacy));
|
||||
const service = TestBed.inject(RuleService);
|
||||
expect(service.rules).toHaveLength(8);
|
||||
expect(service.rules.slice(1, 3).map(({ id }) => id)).toEqual([
|
||||
'personal-window',
|
||||
'personal-pattern',
|
||||
]);
|
||||
const saved = JSON.parse(localStorage.getItem('vacationRules')!);
|
||||
expect(saved.version).toBe(1);
|
||||
expect(saved.rules.slice(1, 3)).toEqual(legacy);
|
||||
expect(service.rules[2].force).toBe('preference');
|
||||
expect(saved.rules[2]).not.toHaveProperty('force');
|
||||
});
|
||||
|
||||
it('gives fresh users the six active presets around the two existing initial rules', () => {
|
||||
const service = TestBed.inject(RuleService);
|
||||
expect(service.rules.map(({ id }) => id)).toEqual([
|
||||
'default-unplaced-at-risk',
|
||||
'default-samedi-malin',
|
||||
'default-christmas',
|
||||
'default-holiday-week-value',
|
||||
'default-non-working-day-gain',
|
||||
'default-longest-work-sequence',
|
||||
'default-short-sequence-deficit',
|
||||
'default-supporting-placements',
|
||||
]);
|
||||
const presets = service.rules.filter(({ type }) => type === RuleType.OPTIMIZATION);
|
||||
expect(presets).toHaveLength(6);
|
||||
expect(presets.every(({ enabled, force }) => enabled && force === 'preference')).toBe(true);
|
||||
});
|
||||
|
||||
it('migrates an old empty list once and retains editing, order, disabled and deleted choices immediately', () => {
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
let service = TestBed.inject(RuleService);
|
||||
expect(service.rules).toHaveLength(6);
|
||||
const deficit = service.rules[4];
|
||||
if (
|
||||
deficit.type !== RuleType.OPTIMIZATION ||
|
||||
deficit.configuration.measure !== 'work-sequence-deficit'
|
||||
)
|
||||
throw new Error('Expected the deficit preset');
|
||||
service.updateRule({
|
||||
...deficit,
|
||||
name: 'My three-day deficit',
|
||||
configuration: { ...deficit.configuration, thresholdDays: 3 },
|
||||
});
|
||||
service.reorderRules(4, 0);
|
||||
service.toggleRule(deficit.id);
|
||||
service.deleteRule('default-holiday-week-value');
|
||||
const expected = service.rules;
|
||||
TestBed.resetTestingModule();
|
||||
service = TestBed.inject(RuleService);
|
||||
expect(service.rules).toEqual(expected);
|
||||
expect(service.rules[0]).toMatchObject({
|
||||
name: 'My three-day deficit',
|
||||
enabled: false,
|
||||
configuration: { thresholdDays: 3 },
|
||||
});
|
||||
for (const rule of service.rules) service.deleteRule(rule.id);
|
||||
TestBed.resetTestingModule();
|
||||
expect(TestBed.inject(RuleService).rules).toEqual([]);
|
||||
expect(JSON.parse(localStorage.getItem('vacationRules')!)).toEqual({
|
||||
version: 1,
|
||||
rules: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('avoids colliding with existing custom identities without replacing their records', () => {
|
||||
const custom = ['default-unplaced-at-risk', 'default-unplaced-at-risk-2'].map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
type: RuleType.IF_THEN,
|
||||
enabled: false,
|
||||
dayOfWeek: 6,
|
||||
placeDays: 2,
|
||||
placeType: 'cp',
|
||||
placement: 'auto',
|
||||
rewardDays: 1,
|
||||
rewardType: 'other',
|
||||
}));
|
||||
localStorage.setItem('vacationRules', JSON.stringify(custom));
|
||||
const rules = TestBed.inject(RuleService).rules;
|
||||
expect(rules[0].id).toBe('default-unplaced-at-risk-3');
|
||||
expect(new Set(rules.map(({ id }) => id)).size).toBe(8);
|
||||
expect(JSON.parse(localStorage.getItem('vacationRules')!).rules.slice(1, 3)).toEqual(
|
||||
custom,
|
||||
);
|
||||
});
|
||||
|
||||
it('saves the migrated list and version in one atomic write and retries after a failed save', () => {
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota');
|
||||
});
|
||||
let service = TestBed.inject(RuleService);
|
||||
expect(service.rules).toHaveLength(6);
|
||||
expect(save).toHaveBeenCalledOnce();
|
||||
expect(localStorage.getItem('vacationRules')).toBe('[]');
|
||||
service.toggleRule(service.rules[0].id);
|
||||
expect(localStorage.getItem('vacationRules')).toBe('[]');
|
||||
save.mockRestore();
|
||||
TestBed.resetTestingModule();
|
||||
service = TestBed.inject(RuleService);
|
||||
expect(service.rules).toHaveLength(6);
|
||||
expect(service.rules[0].enabled).toBe(true);
|
||||
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps edits usable and saves the entire current list once storage recovers', () => {
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
const service = TestBed.inject(RuleService);
|
||||
const prior = localStorage.getItem('vacationRules');
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota');
|
||||
});
|
||||
service.toggleRule(service.rules[0].id);
|
||||
expect(service.rules[0].enabled).toBe(false);
|
||||
expect(localStorage.getItem('vacationRules')).toBe(prior);
|
||||
save.mockRestore();
|
||||
service.deleteRule(service.rules[1].id);
|
||||
const expected = service.rules;
|
||||
TestBed.resetTestingModule();
|
||||
expect(TestBed.inject(RuleService).rules).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each(['{"version":2,"rules":[]}', '{broken', 'null', '[null]'])(
|
||||
'preserves unsupported saved data through subsequent edits: %s',
|
||||
(saved) => {
|
||||
localStorage.setItem('vacationRules', saved);
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const save = vi.spyOn(Storage.prototype, 'setItem');
|
||||
const service = TestBed.inject(RuleService);
|
||||
service.toggleRule(service.rules[0].id);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('vacationRules')).toBe(saved);
|
||||
},
|
||||
);
|
||||
|
||||
it('prevents edits from overwriting unknown valid saved data after a read failure', () => {
|
||||
const saved = '{"version":1,"rules":[]}';
|
||||
localStorage.setItem('vacationRules', saved);
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const read = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('denied');
|
||||
});
|
||||
const service = TestBed.inject(RuleService);
|
||||
read.mockRestore();
|
||||
const write = vi.spyOn(Storage.prototype, 'setItem');
|
||||
service.toggleRule(service.rules[0].id);
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('vacationRules')).toBe(saved);
|
||||
});
|
||||
|
||||
it('works in memory when browser storage is unavailable', () => {
|
||||
vi.stubGlobal('localStorage', undefined);
|
||||
const service = TestBed.inject(RuleService);
|
||||
expect(service.rules).toHaveLength(8);
|
||||
service.toggleRule(service.rules[0].id);
|
||||
expect(service.rules[0].enabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['disabled', 'deleted'])(
|
||||
'keeps a migrated %s list inactive after reload while preserving declarations and at-risk quantities',
|
||||
async (choice) => {
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
const service = TestBed.inject(RuleService);
|
||||
for (const rule of service.rules) {
|
||||
if (choice === 'disabled') service.toggleRule(rule.id);
|
||||
else service.deleteRule(rule.id);
|
||||
}
|
||||
const saved = localStorage.getItem('vacationRules');
|
||||
TestBed.resetTestingModule();
|
||||
const restored = TestBed.inject(RuleService).rules;
|
||||
expect(restored).toHaveLength(choice === 'disabled' ? 6 : 0);
|
||||
expect(restored.every(({ enabled }) => !enabled)).toBe(true);
|
||||
expect(localStorage.getItem('vacationRules')).toBe(saved);
|
||||
vi.stubGlobal('window', undefined);
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-02', end: '2024-01-05' },
|
||||
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-01-04' }],
|
||||
declaredDays: [{ date: '2024-01-03', type: 'other' }],
|
||||
rules: restored,
|
||||
});
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
placements: [],
|
||||
declaredDays: [{ date: '2024-01-03', type: 'other' }],
|
||||
remainingBalances: [
|
||||
{
|
||||
id: 'cp',
|
||||
quantity: 2,
|
||||
classification: 'at-risk',
|
||||
reason: { code: 'active-priorities' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect((await calculation.trace)?.phases.map(({ name }) => name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
expect(await calculation.explanation).toMatchObject({
|
||||
kind: 'PlanExplanation',
|
||||
placements: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('migrates before the first duration request and actual calculation', async () => {
|
||||
localStorage.setItem('vacationRules', '[]');
|
||||
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-02', '2024-01-05');
|
||||
TestBed.inject(UserInputService).updateBalances(() => [
|
||||
{
|
||||
id: 'cp',
|
||||
type: SelectableDayType.CP,
|
||||
numberOfDays: 1,
|
||||
availableDate: new Date('2024-01-02'),
|
||||
expiryDate: new Date('2024-01-05'),
|
||||
},
|
||||
]);
|
||||
const request = TestBed.inject(PlanningDurationService).request()!;
|
||||
expect(request.rules).toHaveLength(6);
|
||||
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
|
||||
vi.stubGlobal('window', undefined);
|
||||
const calculation = createPlanningCalculation(request);
|
||||
const outcome = await calculation.outcome;
|
||||
expect(outcome).toMatchObject({
|
||||
kind: 'Plan',
|
||||
metrics: { unplacedAtRisk: 0 },
|
||||
placements: [{ balanceId: 'cp' }],
|
||||
});
|
||||
expect((await calculation.trace)?.phases.map(({ name }) => name)).toEqual([
|
||||
'validation',
|
||||
'obligations',
|
||||
...request.rules.map(({ id }) => `optimization:${id}`),
|
||||
'expiry',
|
||||
'dates',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,14 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RuleService, RuleType, IfThenRule, TimeWindowRule } from './rule-service';
|
||||
import {
|
||||
RuleService,
|
||||
RuleType,
|
||||
IfThenRule,
|
||||
TimeWindowRule,
|
||||
OptimizationRule,
|
||||
} from './rule-service';
|
||||
import { SelectableDayType } from './calendar-service';
|
||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('RuleService', () => {
|
||||
let service: RuleService;
|
||||
@@ -18,9 +26,10 @@ describe('RuleService', () => {
|
||||
|
||||
it('should initialize with default rules', () => {
|
||||
const rules = service.rules;
|
||||
expect(rules.length).toBe(2);
|
||||
expect(rules[0].id).toBe('default-samedi-malin');
|
||||
expect(rules[1].id).toBe('default-christmas');
|
||||
expect(rules.length).toBe(8);
|
||||
expect(rules[0].id).toBe('default-unplaced-at-risk');
|
||||
expect(rules[1].id).toBe('default-samedi-malin');
|
||||
expect(rules[2].id).toBe('default-christmas');
|
||||
});
|
||||
|
||||
it('should parse dates correctly for TimeWindowRules', () => {
|
||||
@@ -62,9 +71,12 @@ describe('RuleService', () => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(RuleService);
|
||||
|
||||
expect(service.rules.map((rule) => rule.id)).toEqual(['legacy-first', 'legacy-second']);
|
||||
expect(service.rules[0].force).toBe('preference');
|
||||
const first = service.rules[0] as TimeWindowRule;
|
||||
expect(service.rules.slice(1, 3).map((rule) => rule.id)).toEqual([
|
||||
'legacy-first',
|
||||
'legacy-second',
|
||||
]);
|
||||
expect(service.rules[1].force).toBe('preference');
|
||||
const first = service.rules[1] as TimeWindowRule;
|
||||
expect(first.startDate.toISOString().slice(0, 10)).toBe('2024-02-01');
|
||||
});
|
||||
|
||||
@@ -84,13 +96,13 @@ describe('RuleService', () => {
|
||||
|
||||
service.addRule(newRule);
|
||||
const rules = service.rules;
|
||||
expect(rules.length).toBe(3);
|
||||
expect(rules.length).toBe(9);
|
||||
expect(rules.find((r) => r.id === 'test-rule')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update an existing rule', () => {
|
||||
const rules = service.rules;
|
||||
const targetRule = { ...rules[0] } as IfThenRule;
|
||||
const targetRule = { ...rules.find((rule) => rule.type === RuleType.IF_THEN)! };
|
||||
targetRule.name = 'Updated Name';
|
||||
|
||||
service.updateRule(targetRule);
|
||||
@@ -107,7 +119,7 @@ describe('RuleService', () => {
|
||||
service.deleteRule(idToDelete);
|
||||
|
||||
const updatedRules = service.rules;
|
||||
expect(updatedRules.length).toBe(1);
|
||||
expect(updatedRules.length).toBe(7);
|
||||
expect(updatedRules.find((r) => r.id === idToDelete)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -132,4 +144,42 @@ describe('RuleService', () => {
|
||||
expect(rulesAfter[0].id).toBe(secondRuleId);
|
||||
expect(rulesAfter[1].id).toBe(firstRuleId);
|
||||
});
|
||||
|
||||
it('restores the full optimization identity, parameters, enabled state and position', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
TestBed.tick();
|
||||
const optimization: OptimizationRule = {
|
||||
id: 'personal-loss',
|
||||
name: 'My loss priority',
|
||||
type: RuleType.OPTIMIZATION,
|
||||
enabled: true,
|
||||
force: 'preference',
|
||||
configuration: structuredClone(OPTIMIZATION_PRESETS.unplacedAtRisk),
|
||||
};
|
||||
service.addRule(optimization);
|
||||
optimization.configuration.conditions = [];
|
||||
service.reorderRules(
|
||||
service.rules.findIndex(({ id }) => id === optimization.id),
|
||||
0,
|
||||
);
|
||||
service.toggleRule('personal-loss');
|
||||
const saved = service.rules;
|
||||
TestBed.tick();
|
||||
vi.advanceTimersByTime(350);
|
||||
TestBed.resetTestingModule();
|
||||
service = TestBed.inject(RuleService);
|
||||
expect(service.rules).toEqual(saved);
|
||||
expect(service.rules[0]).toMatchObject({
|
||||
id: 'personal-loss',
|
||||
name: 'My loss priority',
|
||||
enabled: false,
|
||||
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
|
||||
});
|
||||
service.deleteRule('personal-loss');
|
||||
expect(service.rules.some((rule) => rule.id === 'personal-loss')).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Injectable, WritableSignal, Injector, inject } from '@angular/core';
|
||||
import { Injectable, WritableSignal, signal } from '@angular/core';
|
||||
import { SelectableDayType } from './calendar-service';
|
||||
import { storedSignal } from '../shared/utils/stored-signal';
|
||||
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
|
||||
|
||||
export enum RuleType {
|
||||
IF_THEN = 'if_then',
|
||||
TIME_WINDOW = 'time_window',
|
||||
OPTIMIZATION = 'optimization',
|
||||
}
|
||||
|
||||
export type RuleForce = 'obligation' | 'preference';
|
||||
@@ -14,7 +15,7 @@ export interface BaseRule {
|
||||
name: string;
|
||||
type: RuleType;
|
||||
enabled: boolean;
|
||||
/** Missing force is a legacy preference and is migrated on read. */
|
||||
/** Missing force is read as a legacy preference without rewriting the saved entry. */
|
||||
force?: RuleForce;
|
||||
}
|
||||
|
||||
@@ -38,29 +39,88 @@ export interface TimeWindowRule extends BaseRule {
|
||||
placeType: SelectableDayType;
|
||||
}
|
||||
|
||||
export type VacationRule = IfThenRule | TimeWindowRule;
|
||||
export interface OptimizationRule extends BaseRule {
|
||||
type: RuleType.OPTIMIZATION;
|
||||
force: 'preference';
|
||||
configuration: OptimizationConfiguration;
|
||||
}
|
||||
|
||||
interface SerializedRule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: RuleType;
|
||||
enabled: boolean;
|
||||
force?: RuleForce;
|
||||
dayOfWeek?: number;
|
||||
placeDays: number;
|
||||
placeType: SelectableDayType;
|
||||
placement?: PlacementStrategy;
|
||||
rewardDays?: number;
|
||||
rewardType?: SelectableDayType;
|
||||
startDate?: string | Date;
|
||||
endDate?: string | Date;
|
||||
export type VacationRule = IfThenRule | TimeWindowRule | OptimizationRule;
|
||||
|
||||
type SerializedRule =
|
||||
| IfThenRule
|
||||
| (Omit<TimeWindowRule, 'startDate' | 'endDate'> & {
|
||||
startDate: string | Date;
|
||||
endDate: string | Date;
|
||||
})
|
||||
| (Omit<OptimizationRule, 'force'> & { force?: 'preference' });
|
||||
|
||||
export function createOptimizationPresetRules(
|
||||
existingIds: readonly string[] = [],
|
||||
): OptimizationRule[] {
|
||||
const ids = new Set(existingIds);
|
||||
return (
|
||||
[
|
||||
['unplaced-at-risk', 'Prévention des pertes', OPTIMIZATION_PRESETS.unplacedAtRisk],
|
||||
['holiday-week-value', 'Semaines fériées', OPTIMIZATION_PRESETS.holidayWeekValue],
|
||||
[
|
||||
'non-working-day-gain',
|
||||
'Gain de jours non ouvrés',
|
||||
OPTIMIZATION_PRESETS.nonWorkingDayGain,
|
||||
],
|
||||
[
|
||||
'longest-work-sequence',
|
||||
'Séquence de travail maximale',
|
||||
OPTIMIZATION_PRESETS.longestWorkSequence,
|
||||
],
|
||||
[
|
||||
'short-sequence-deficit',
|
||||
'Déficit des reprises',
|
||||
OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||||
],
|
||||
[
|
||||
'supporting-placements',
|
||||
'Économie des jours d’appoint',
|
||||
OPTIMIZATION_PRESETS.supportingPlacements,
|
||||
],
|
||||
] as const
|
||||
).map<OptimizationRule>(([key, name, configuration]) => {
|
||||
const base = `default-${key}`;
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (ids.has(id)) id = `${base}-${suffix++}`;
|
||||
ids.add(id);
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: RuleType.OPTIMIZATION,
|
||||
enabled: true,
|
||||
force: 'preference',
|
||||
configuration: structuredClone(configuration),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isRuleList(value: unknown): value is SerializedRule[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(rule) =>
|
||||
rule &&
|
||||
typeof rule === 'object' &&
|
||||
typeof rule.id === 'string' &&
|
||||
typeof rule.name === 'string' &&
|
||||
typeof rule.enabled === 'boolean' &&
|
||||
Object.values(RuleType).includes(rule.type),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RuleService {
|
||||
private readonly injector = inject(Injector);
|
||||
private canPersist = true;
|
||||
|
||||
private readonly defaultRules: VacationRule[] = [
|
||||
{
|
||||
@@ -92,11 +152,56 @@ export class RuleService {
|
||||
private rawRulesSignal: WritableSignal<SerializedRule[]>;
|
||||
|
||||
constructor() {
|
||||
this.rawRulesSignal = storedSignal<SerializedRule[]>(
|
||||
'vacationRules',
|
||||
this.defaultRules as unknown as SerializedRule[],
|
||||
this.injector,
|
||||
);
|
||||
let rules = this.initialRules(this.defaultRules);
|
||||
let migrate = true;
|
||||
try {
|
||||
const stored =
|
||||
typeof localStorage === 'undefined' ? null : localStorage.getItem('vacationRules');
|
||||
if (stored !== null) {
|
||||
const parsed: unknown = JSON.parse(stored);
|
||||
if (isRuleList(parsed)) rules = this.initialRules(parsed);
|
||||
else if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
'version' in parsed &&
|
||||
parsed.version === 1 &&
|
||||
'rules' in parsed &&
|
||||
isRuleList(parsed.rules)
|
||||
) {
|
||||
rules = parsed.rules;
|
||||
migrate = false;
|
||||
} else throw new Error('Unsupported saved rule format.');
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed read must not allow a later edit to replace unknown saved rules.
|
||||
this.canPersist = false;
|
||||
console.warn('Failed to restore planning rules from localStorage.', error);
|
||||
}
|
||||
this.rawRulesSignal = signal(rules);
|
||||
if (migrate) this.saveRules();
|
||||
}
|
||||
|
||||
private initialRules(existing: SerializedRule[]): SerializedRule[] {
|
||||
const presets = createOptimizationPresetRules(existing.map(({ id }) => id));
|
||||
return [presets[0], ...existing, ...presets.slice(1)];
|
||||
}
|
||||
|
||||
private saveRules(): void {
|
||||
if (!this.canPersist) return;
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined')
|
||||
localStorage.setItem(
|
||||
'vacationRules',
|
||||
JSON.stringify({ version: 1, rules: this.rawRulesSignal() }),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save planning rules to localStorage.', error);
|
||||
}
|
||||
}
|
||||
|
||||
private updateRules(update: (rules: SerializedRule[]) => SerializedRule[]): void {
|
||||
this.rawRulesSignal.update(update);
|
||||
this.saveRules();
|
||||
}
|
||||
|
||||
public get rules(): VacationRule[] {
|
||||
@@ -106,46 +211,42 @@ export class RuleService {
|
||||
return {
|
||||
...rule,
|
||||
force: rule.force ?? 'preference',
|
||||
startDate: new Date(rule.startDate as string),
|
||||
endDate: new Date(rule.endDate as string),
|
||||
} as TimeWindowRule;
|
||||
startDate: new Date(rule.startDate),
|
||||
endDate: new Date(rule.endDate),
|
||||
};
|
||||
}
|
||||
return { ...rule, force: rule.force ?? 'preference' } as IfThenRule;
|
||||
if (rule.type === RuleType.OPTIMIZATION)
|
||||
return structuredClone({ ...rule, force: rule.force ?? ('preference' as const) });
|
||||
return structuredClone({ ...rule, force: rule.force ?? 'preference' });
|
||||
});
|
||||
}
|
||||
|
||||
public addRule(rule: VacationRule): void {
|
||||
this.rawRulesSignal.update((rules) => [
|
||||
...rules,
|
||||
{ ...rule, force: rule.force ?? 'preference' } as unknown as SerializedRule,
|
||||
]);
|
||||
const saved = structuredClone(rule);
|
||||
saved.force ??= 'preference';
|
||||
this.updateRules((rules) => [...rules, saved]);
|
||||
}
|
||||
|
||||
public updateRule(updatedRule: VacationRule): void {
|
||||
this.rawRulesSignal.update((rules) =>
|
||||
rules.map((rule) =>
|
||||
rule.id === updatedRule.id
|
||||
? ({
|
||||
...updatedRule,
|
||||
force: updatedRule.force ?? 'preference',
|
||||
} as unknown as SerializedRule)
|
||||
: rule,
|
||||
),
|
||||
const saved = structuredClone(updatedRule);
|
||||
saved.force ??= 'preference';
|
||||
this.updateRules((rules) =>
|
||||
rules.map((rule) => (rule.id === updatedRule.id ? saved : rule)),
|
||||
);
|
||||
}
|
||||
|
||||
public deleteRule(id: string): void {
|
||||
this.rawRulesSignal.update((rules) => rules.filter((rule) => rule.id !== id));
|
||||
this.updateRules((rules) => rules.filter((rule) => rule.id !== id));
|
||||
}
|
||||
|
||||
public toggleRule(id: string): void {
|
||||
this.rawRulesSignal.update((rules) =>
|
||||
this.updateRules((rules) =>
|
||||
rules.map((rule) => (rule.id === id ? { ...rule, enabled: !rule.enabled } : rule)),
|
||||
);
|
||||
}
|
||||
|
||||
public reorderRules(previousIndex: number, currentIndex: number): void {
|
||||
this.rawRulesSignal.update((rules) => {
|
||||
this.updateRules((rules) => {
|
||||
const updatedRules = [...rules];
|
||||
const [movedRule] = updatedRules.splice(previousIndex, 1);
|
||||
updatedRules.splice(currentIndex, 0, movedRule);
|
||||
|
||||
@@ -3,6 +3,16 @@ import { Injectable, inject, Injector, computed } from '@angular/core';
|
||||
import { storedSignal } from '../shared/utils/stored-signal';
|
||||
import type { PlanOutcome, RemainingBalance } from './leave-planner.service';
|
||||
import { addPlanningDays } from './planning-date';
|
||||
import {
|
||||
optimizationUnit,
|
||||
validateOptimizationConfiguration,
|
||||
type OptimizationConfiguration,
|
||||
type OptimizationElements,
|
||||
type OptimizationCondition,
|
||||
type OptimizationMeasure,
|
||||
type OptimizationAggregation,
|
||||
type OptimizationDirection,
|
||||
} from './planning-objectives';
|
||||
|
||||
export interface AppTranslations {
|
||||
locale: string;
|
||||
@@ -77,6 +87,25 @@ export interface AppTranslations {
|
||||
invalidHorizon: string;
|
||||
};
|
||||
rules: {
|
||||
optimization: {
|
||||
newRule: string;
|
||||
defaultName: string;
|
||||
elementsLabel: string;
|
||||
conditionsLabel: string;
|
||||
measureLabel: string;
|
||||
aggregationLabel: string;
|
||||
directionLabel: string;
|
||||
thresholdLabel: string;
|
||||
thresholdSummary: string;
|
||||
summary: string;
|
||||
invalidConfiguration: string;
|
||||
nameRequired: string;
|
||||
elements: Record<OptimizationElements, string>;
|
||||
conditions: Record<OptimizationCondition, string>;
|
||||
measures: Record<OptimizationMeasure, string>;
|
||||
aggregations: Record<OptimizationAggregation, string>;
|
||||
directions: Record<OptimizationDirection, string>;
|
||||
};
|
||||
title: string;
|
||||
subtitle: string;
|
||||
active: string;
|
||||
@@ -390,6 +419,29 @@ export class TranslationService {
|
||||
}
|
||||
}
|
||||
|
||||
public optimizationSummary(configuration: OptimizationConfiguration): string {
|
||||
const tr = this.tr().rules.optimization;
|
||||
if (!validateOptimizationConfiguration(configuration).valid) return tr.invalidConfiguration;
|
||||
const unit = optimizationUnit(configuration);
|
||||
const unitLabel =
|
||||
unit === 'count'
|
||||
? tr.aggregations.count
|
||||
: this.tr().planning.statistics[
|
||||
unit === 'working-days' ? 'workingDays' : 'calendarDays'
|
||||
];
|
||||
const conditions = configuration.conditions.map((condition) => tr.conditions[condition]);
|
||||
if (configuration.measure === 'work-sequence-deficit')
|
||||
conditions.push(
|
||||
tr.thresholdSummary.replace('{count}', String(configuration.thresholdDays)),
|
||||
);
|
||||
return tr.summary
|
||||
.replace('{direction}', tr.directions[configuration.direction])
|
||||
.replace('{aggregation}', tr.aggregations[configuration.aggregation])
|
||||
.replace('{measure}', `${tr.measures[configuration.measure]} (${unitLabel})`)
|
||||
.replace('{elements}', tr.elements[configuration.elements])
|
||||
.replace('{conditions}', conditions.length ? ` (${conditions.join(', ')})` : '');
|
||||
}
|
||||
|
||||
public getLanguageName(langCode: string): string {
|
||||
try {
|
||||
// Using native Intl.DisplayNames to get name of language in its own language
|
||||
@@ -421,7 +473,8 @@ export class TranslationService {
|
||||
const reasons = this.tr().planning.details.remainingReasons;
|
||||
if (!plan) return reasons['not-established'];
|
||||
const code =
|
||||
incomplete && ['preserved', 'not-established'].includes(balance.reason.code)
|
||||
incomplete &&
|
||||
['preserved', 'active-priorities', 'not-established'].includes(balance.reason.code)
|
||||
? 'calculation-incomplete'
|
||||
: balance.reason.code;
|
||||
return (reasons[code] ?? reasons['not-established'])
|
||||
|
||||
+54
-2
@@ -133,6 +133,51 @@
|
||||
"after": "danach",
|
||||
"around": "darum herum",
|
||||
"auto": "automatisch"
|
||||
},
|
||||
"optimization": {
|
||||
"newRule": "Neue Optimierungsregel",
|
||||
"defaultName": "Verluste vermeiden",
|
||||
"elementsLabel": "Planungselemente",
|
||||
"conditionsLabel": "Bedingungen (alle müssen erfüllt sein)",
|
||||
"measureLabel": "Messgröße",
|
||||
"aggregationLabel": "Aggregation",
|
||||
"directionLabel": "Richtung",
|
||||
"thresholdLabel": "Präferenzschwelle in Arbeitstagen",
|
||||
"thresholdSummary": "Präferenzschwelle: {count} Arbeitstage",
|
||||
"summary": "{direction} {aggregation}: {measure} — {elements}{conditions}.",
|
||||
"invalidConfiguration": "Wählen Sie passende Elemente, Bedingungen, Messgrößen und Aggregationen sowie bei Bedarf eine positive ganzzahlige Tagesschwelle.",
|
||||
"nameRequired": "Geben Sie einen Regelnamen ein.",
|
||||
"elements": {
|
||||
"leave-balances": "Urlaubsguthaben",
|
||||
"public-holiday-weeks": "Feiertagswochen",
|
||||
"time-off-periods": "Arbeitsfreie Zeiträume",
|
||||
"work-sequences": "Arbeitsfolgen",
|
||||
"leave-placements": "Geplante Urlaubstage"
|
||||
},
|
||||
"conditions": {
|
||||
"at-risk": "Vom Verfall bedroht",
|
||||
"complete": "Vollständig",
|
||||
"internal": "Intern",
|
||||
"non-empty": "Nicht leer",
|
||||
"supporting": "Unterstützend"
|
||||
},
|
||||
"measures": {
|
||||
"unplaced-quantity": "Nicht eingeplante Menge",
|
||||
"public-holiday-week-value": "Wert der Feiertagswochen",
|
||||
"non-working-day-gain": "Gewinn arbeitsfreier Tage",
|
||||
"work-sequence-length": "Länge der Arbeitsfolgen",
|
||||
"work-sequence-deficit": "Defizit der Arbeitsfolgen",
|
||||
"placement-count": "Anzahl der Platzierungen"
|
||||
},
|
||||
"aggregations": {
|
||||
"sum": "Summe",
|
||||
"count": "Anzahl",
|
||||
"maximum": "Maximum"
|
||||
},
|
||||
"directions": {
|
||||
"minimize": "Minimieren",
|
||||
"maximize": "Maximieren"
|
||||
}
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
@@ -276,7 +321,7 @@
|
||||
"withoutDay": "Ohne diesen Tag",
|
||||
"withDay": "Mit diesem Tag",
|
||||
"noChange": "Diese Werte sind gleich. Regeln und die letzten Entscheidungskriterien (Verfall des Guthabens, dann Datum) können die Auswahl weiterhin beeinflussen.",
|
||||
"priorityHelp": "Die Kriterien stehen in Prioritätsreihenfolge. Der erste Unterschied hat Vorrang vor den folgenden.",
|
||||
"priorityHelp": "Verpflichtungen werden gemeinsam erfüllt. Aktive Präferenzen werden in Ihrer Regelreihenfolge verglichen. Nur diese Regeln führen zu neuen Urlaubstagen; früherer Verfall und dann frühere Daten entscheiden bei Gleichstand.",
|
||||
"tryDate": "Anderes Datum testen",
|
||||
"plannedDate": "Derzeit geplant für:",
|
||||
"currentPlan": "Aktueller Plan",
|
||||
@@ -327,6 +372,10 @@
|
||||
"placement-date": {
|
||||
"label": "Frühere Daten bevorzugen",
|
||||
"help": "Letztes Kriterium, wenn alle anderen Kriterien gleich sind."
|
||||
},
|
||||
"optimization": {
|
||||
"label": "{rule}: {summary}",
|
||||
"help": "Diese Optimierungspräferenz bewertet ihre gespeicherte Konfiguration. Aktive Präferenzen werden in der angezeigten Reihenfolge verglichen; frühere haben Vorrang."
|
||||
}
|
||||
},
|
||||
"stages": {
|
||||
@@ -342,7 +391,9 @@
|
||||
"unavailable": "Dieses Urlaubsguthaben kann an diesem Datum nicht verwendet werden.",
|
||||
"expired": "Dieses Datum liegt nach dem Verfall des Urlaubsguthabens.",
|
||||
"type-incompatible": "Diese Urlaubsart kann an diesem Datum nicht verwendet werden.",
|
||||
"rule-invalid": "Dieses Datum würde eine Planungsregel verletzen."
|
||||
"rule-invalid": "Dieses Datum würde eine Planungsregel verletzen.",
|
||||
"invalid-optimization-configuration": "Wählen Sie passende Elemente, Bedingungen, Messgrößen und Aggregationen sowie bei Bedarf eine positive ganzzahlige Tagesschwelle.",
|
||||
"unsupported-optimization-configuration": "Diese Regel kombiniert unvereinbare Planungselemente und Messgrößen."
|
||||
},
|
||||
"preferenceHelp": {
|
||||
"time_window": "Urlaubstage im bevorzugten Zeitraum. Mehr ist besser.",
|
||||
@@ -373,6 +424,7 @@
|
||||
"no-working-date": "Kein Arbeitstag liegt sowohl im Planungszeitraum als auch zwischen Verfügbarkeits- und Ablaufdatum.",
|
||||
"all-dates-occupied": "Alle Arbeitstage, an denen dieses Urlaubskonto nutzbar ist, sind bereits durch geplanten Urlaub oder gebuchte Tage belegt.",
|
||||
"preserved": "Für später aufbewahrt: Das Urlaubskonto läuft nach dem Planungszeitraum ab und die Berechnung hat gemäß Ihren Prioritäten keine zusätzliche Platzierung ausgewählt.",
|
||||
"active-priorities": "Die Berechnung hat dieses Urlaubskonto gemäß Ihren aktiven Regeln ungenutzt gelassen. Ändern Sie die Regeln oder ihre Reihenfolge, um einen anderen Plan anzufordern.",
|
||||
"calculation-incomplete": "Die Berechnung wurde vor Abschluss der Planung unterbrochen. Die Nichtnutzung ist keine endgültige Entscheidung.",
|
||||
"not-established": "Diese Berechnung hat keinen genauen Grund für die Nichtnutzung ermittelt."
|
||||
}
|
||||
|
||||
+54
-2
@@ -133,6 +133,51 @@
|
||||
"after": "After",
|
||||
"around": "Around",
|
||||
"auto": "Auto (Best choice)"
|
||||
},
|
||||
"optimization": {
|
||||
"newRule": "New Optimization Rule",
|
||||
"defaultName": "Loss prevention",
|
||||
"elementsLabel": "Planning elements",
|
||||
"conditionsLabel": "Conditions (all must match)",
|
||||
"measureLabel": "Measure",
|
||||
"aggregationLabel": "Aggregation",
|
||||
"directionLabel": "Direction",
|
||||
"thresholdLabel": "Preference threshold in working days",
|
||||
"thresholdSummary": "preference threshold: {count} working days",
|
||||
"summary": "{direction} {aggregation}: {measure} — {elements}{conditions}.",
|
||||
"invalidConfiguration": "Choose compatible planning elements, conditions, measure and aggregation, with a positive whole-day threshold when needed.",
|
||||
"nameRequired": "Enter a rule name.",
|
||||
"elements": {
|
||||
"leave-balances": "Leave balances",
|
||||
"public-holiday-weeks": "Public-holiday weeks",
|
||||
"time-off-periods": "Time-off periods",
|
||||
"work-sequences": "Work sequences",
|
||||
"leave-placements": "Leave placements"
|
||||
},
|
||||
"conditions": {
|
||||
"at-risk": "At risk of expiry",
|
||||
"complete": "Complete",
|
||||
"internal": "Internal",
|
||||
"non-empty": "Non-empty",
|
||||
"supporting": "Supporting"
|
||||
},
|
||||
"measures": {
|
||||
"unplaced-quantity": "Unplaced quantity",
|
||||
"public-holiday-week-value": "Public-holiday week value",
|
||||
"non-working-day-gain": "Non-working-day gain",
|
||||
"work-sequence-length": "Work-sequence length",
|
||||
"work-sequence-deficit": "Work-sequence deficit",
|
||||
"placement-count": "Placement count"
|
||||
},
|
||||
"aggregations": {
|
||||
"sum": "Sum",
|
||||
"count": "Count",
|
||||
"maximum": "Maximum"
|
||||
},
|
||||
"directions": {
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
@@ -276,7 +321,7 @@
|
||||
"withoutDay": "Without this day",
|
||||
"withDay": "With this day",
|
||||
"noChange": "These totals are unchanged. Rules and the final tie-breakers (balance expiry, then date) can still affect the choice.",
|
||||
"priorityHelp": "Criteria are listed in priority order. The first difference takes precedence over later ones.",
|
||||
"priorityHelp": "Obligations are satisfied together. Active preferences are compared in your rule order. Only those rules drive new placements; earlier expiry and then dates break equal results.",
|
||||
"tryDate": "Try another date",
|
||||
"plannedDate": "Currently planned for:",
|
||||
"currentPlan": "Current plan",
|
||||
@@ -327,6 +372,10 @@
|
||||
"placement-date": {
|
||||
"label": "Earlier dates first",
|
||||
"help": "Final tie-breaker when all other criteria are equal."
|
||||
},
|
||||
"optimization": {
|
||||
"label": "{rule}: {summary}",
|
||||
"help": "This optimization preference evaluates its saved configuration. Active preferences follow the displayed order; earlier preferences win before later ones."
|
||||
}
|
||||
},
|
||||
"stages": {
|
||||
@@ -342,7 +391,9 @@
|
||||
"unavailable": "This leave balance cannot be used on this date.",
|
||||
"expired": "This date is after the leave balance expires.",
|
||||
"type-incompatible": "This leave type cannot be used on this date.",
|
||||
"rule-invalid": "Using this date would break a planning rule."
|
||||
"rule-invalid": "Using this date would break a planning rule.",
|
||||
"invalid-optimization-configuration": "Choose compatible planning elements, conditions, measure and aggregation, with a positive whole-day threshold when needed.",
|
||||
"unsupported-optimization-configuration": "This rule uses an unsupported combination of planning elements and measures."
|
||||
},
|
||||
"preferenceHelp": {
|
||||
"time_window": "Leave days in this preferred period. More is better.",
|
||||
@@ -373,6 +424,7 @@
|
||||
"no-working-date": "No working day falls within both the planning period and its availability and expiry dates.",
|
||||
"all-dates-occupied": "All working days on which this balance can be used are already occupied by planned leave or already booked days.",
|
||||
"preserved": "Kept for later: it expires after the planning period and the calculation retained no additional placement under your priorities.",
|
||||
"active-priorities": "The calculation kept this balance unused under your active planning rules. Change the rules or their order to request a different plan.",
|
||||
"calculation-incomplete": "The calculation was interrupted before planning finished. Leaving it unused is not a final decision.",
|
||||
"not-established": "This calculation did not establish a precise reason for leaving it unused."
|
||||
}
|
||||
|
||||
+54
-2
@@ -133,6 +133,51 @@
|
||||
"after": "Después",
|
||||
"around": "Alrededor",
|
||||
"auto": "Auto (Mejor opción)"
|
||||
},
|
||||
"optimization": {
|
||||
"newRule": "Nueva regla de Optimización",
|
||||
"defaultName": "Prevención de pérdidas",
|
||||
"elementsLabel": "Elementos del plan",
|
||||
"conditionsLabel": "Condiciones (deben cumplirse todas)",
|
||||
"measureLabel": "Medida",
|
||||
"aggregationLabel": "Agregación",
|
||||
"directionLabel": "Dirección",
|
||||
"thresholdLabel": "Umbral de preferencia en días laborables",
|
||||
"thresholdSummary": "umbral de preferencia: {count} días laborables",
|
||||
"summary": "{direction} {aggregation}: {measure} — {elements}{conditions}.",
|
||||
"invalidConfiguration": "Elija elementos, condiciones, medidas y agregaciones compatibles, con un umbral entero positivo cuando sea necesario.",
|
||||
"nameRequired": "Introduzca un nombre para la regla.",
|
||||
"elements": {
|
||||
"leave-balances": "Saldos de vacaciones",
|
||||
"public-holiday-weeks": "Semanas con festivos",
|
||||
"time-off-periods": "Períodos sin trabajo",
|
||||
"work-sequences": "Secuencias de trabajo",
|
||||
"leave-placements": "Vacaciones colocadas"
|
||||
},
|
||||
"conditions": {
|
||||
"at-risk": "En riesgo de caducar",
|
||||
"complete": "Completas",
|
||||
"internal": "Internas",
|
||||
"non-empty": "No vacías",
|
||||
"supporting": "De apoyo"
|
||||
},
|
||||
"measures": {
|
||||
"unplaced-quantity": "Cantidad no colocada",
|
||||
"public-holiday-week-value": "Valor de las semanas festivas",
|
||||
"non-working-day-gain": "Ganancia de días sin trabajo",
|
||||
"work-sequence-length": "Longitud de las secuencias de trabajo",
|
||||
"work-sequence-deficit": "Déficit de las secuencias de trabajo",
|
||||
"placement-count": "Número de colocaciones"
|
||||
},
|
||||
"aggregations": {
|
||||
"sum": "Suma",
|
||||
"count": "Cantidad",
|
||||
"maximum": "Máximo"
|
||||
},
|
||||
"directions": {
|
||||
"minimize": "Minimizar",
|
||||
"maximize": "Maximizar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
@@ -276,7 +321,7 @@
|
||||
"withoutDay": "Sin este día",
|
||||
"withDay": "Con este día",
|
||||
"noChange": "Estos totales son iguales. Las reglas y los últimos criterios de desempate (vencimiento del saldo y después fecha) aún pueden influir en la elección.",
|
||||
"priorityHelp": "Los criterios se muestran por prioridad. La primera diferencia prevalece sobre las siguientes.",
|
||||
"priorityHelp": "Las obligaciones se resuelven conjuntamente. Las preferencias activas se comparan en el orden de sus reglas. Solo esas reglas añaden vacaciones; la caducidad y luego las fechas resuelven los empates.",
|
||||
"tryDate": "Probar otra fecha",
|
||||
"plannedDate": "Fecha prevista actualmente:",
|
||||
"currentPlan": "Plan actual",
|
||||
@@ -327,6 +372,10 @@
|
||||
"placement-date": {
|
||||
"label": "Primero las fechas más tempranas",
|
||||
"help": "Último desempate cuando los demás criterios son iguales."
|
||||
},
|
||||
"optimization": {
|
||||
"label": "{rule}: {summary}",
|
||||
"help": "Esta preferencia de optimización evalúa su configuración guardada. Las preferencias activas siguen el orden mostrado; las anteriores tienen prioridad."
|
||||
}
|
||||
},
|
||||
"stages": {
|
||||
@@ -342,7 +391,9 @@
|
||||
"unavailable": "Este saldo de permisos no se puede usar en esta fecha.",
|
||||
"expired": "Esta fecha es posterior al vencimiento del saldo.",
|
||||
"type-incompatible": "Este tipo de permiso no se puede usar en esta fecha.",
|
||||
"rule-invalid": "Usar esta fecha incumpliría una regla del plan."
|
||||
"rule-invalid": "Usar esta fecha incumpliría una regla del plan.",
|
||||
"invalid-optimization-configuration": "Elija elementos, condiciones, medidas y agregaciones compatibles, con un umbral entero positivo cuando sea necesario.",
|
||||
"unsupported-optimization-configuration": "Esta regla combina elementos del plan y medidas incompatibles."
|
||||
},
|
||||
"preferenceHelp": {
|
||||
"time_window": "Días de permiso dentro del período preferido. Más es mejor.",
|
||||
@@ -373,6 +424,7 @@
|
||||
"no-working-date": "Ningún día laborable está dentro del período planificado y entre sus fechas de disponibilidad y caducidad.",
|
||||
"all-dates-occupied": "Todos los días laborables en los que puede utilizarse este saldo ya están ocupados por permisos planificados o días ya reservados.",
|
||||
"preserved": "Guardado para más adelante: caduca después del período planificado y el cálculo no seleccionó ninguna colocación adicional según tus prioridades.",
|
||||
"active-priorities": "El cálculo conservó este saldo sin utilizar según tus reglas activas. Cambia las reglas o su orden para solicitar otro plan.",
|
||||
"calculation-incomplete": "El cálculo se interrumpió antes de terminar la planificación. Su falta de uso no es una decisión definitiva.",
|
||||
"not-established": "Este cálculo no determinó un motivo preciso para dejarlo sin utilizar."
|
||||
}
|
||||
|
||||
+54
-2
@@ -133,6 +133,51 @@
|
||||
"after": "Après",
|
||||
"around": "Autour",
|
||||
"auto": "Auto (Meilleur choix)"
|
||||
},
|
||||
"optimization": {
|
||||
"newRule": "Nouvelle règle d’Optimisation",
|
||||
"defaultName": "Prévention des pertes",
|
||||
"elementsLabel": "Éléments du planning",
|
||||
"conditionsLabel": "Conditions (toutes doivent être vérifiées)",
|
||||
"measureLabel": "Mesure",
|
||||
"aggregationLabel": "Agrégation",
|
||||
"directionLabel": "Direction",
|
||||
"thresholdLabel": "Seuil de préférence en jours ouvrés",
|
||||
"thresholdSummary": "seuil de préférence : {count} jours ouvrés",
|
||||
"summary": "{direction} {aggregation} : {measure} — {elements}{conditions}.",
|
||||
"invalidConfiguration": "Choisissez des éléments, conditions, une mesure et une agrégation compatibles, avec un seuil entier positif si nécessaire.",
|
||||
"nameRequired": "Saisissez un nom de règle.",
|
||||
"elements": {
|
||||
"leave-balances": "Soldes de congés",
|
||||
"public-holiday-weeks": "Semaines avec jour férié",
|
||||
"time-off-periods": "Périodes non travaillées",
|
||||
"work-sequences": "Séquences de travail",
|
||||
"leave-placements": "Congés placés"
|
||||
},
|
||||
"conditions": {
|
||||
"at-risk": "À risque d’expiration",
|
||||
"complete": "Complètes",
|
||||
"internal": "Internes",
|
||||
"non-empty": "Non vides",
|
||||
"supporting": "D’appui"
|
||||
},
|
||||
"measures": {
|
||||
"unplaced-quantity": "Quantité non placée",
|
||||
"public-holiday-week-value": "Valeur des semaines fériées",
|
||||
"non-working-day-gain": "Gain de jours non travaillés",
|
||||
"work-sequence-length": "Longueur des séquences de travail",
|
||||
"work-sequence-deficit": "Déficit des séquences de travail",
|
||||
"placement-count": "Nombre de placements"
|
||||
},
|
||||
"aggregations": {
|
||||
"sum": "Somme",
|
||||
"count": "Nombre",
|
||||
"maximum": "Maximum"
|
||||
},
|
||||
"directions": {
|
||||
"minimize": "Minimiser",
|
||||
"maximize": "Maximiser"
|
||||
}
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
@@ -276,7 +321,7 @@
|
||||
"withoutDay": "Sans ce jour",
|
||||
"withDay": "Avec ce jour",
|
||||
"noChange": "Ces totaux sont identiques. Les règles et les derniers critères de départage (expiration du solde, puis date) peuvent encore influencer le choix.",
|
||||
"priorityHelp": "Les critères sont classés par priorité. La première différence prime sur les suivantes.",
|
||||
"priorityHelp": "Les obligations sont résolues conjointement. Les préférences actives sont comparées dans l’ordre de vos règles. Seules ces règles déclenchent des placements ; l’expiration puis les dates départagent les résultats égaux.",
|
||||
"tryDate": "Essayer une autre date",
|
||||
"plannedDate": "Date actuellement prévue :",
|
||||
"currentPlan": "Planning actuel",
|
||||
@@ -327,6 +372,10 @@
|
||||
"placement-date": {
|
||||
"label": "Dates les plus proches d’abord",
|
||||
"help": "Dernier départage lorsque tous les autres critères sont égaux."
|
||||
},
|
||||
"optimization": {
|
||||
"label": "{rule}: {summary}",
|
||||
"help": "Cette préférence d’optimisation évalue sa configuration enregistrée. Les préférences actives suivent l’ordre affiché ; les premières passent avant les suivantes."
|
||||
}
|
||||
},
|
||||
"stages": {
|
||||
@@ -342,7 +391,9 @@
|
||||
"unavailable": "Ce solde de congés ne peut pas être utilisé à cette date.",
|
||||
"expired": "Cette date dépasse la date d’expiration du solde.",
|
||||
"type-incompatible": "Ce type de congé ne peut pas être utilisé à cette date.",
|
||||
"rule-invalid": "Utiliser cette date ne respecterait pas une règle du planning."
|
||||
"rule-invalid": "Utiliser cette date ne respecterait pas une règle du planning.",
|
||||
"invalid-optimization-configuration": "Choisissez des éléments, conditions, une mesure et une agrégation compatibles, avec un seuil entier positif si nécessaire.",
|
||||
"unsupported-optimization-configuration": "Cette règle combine des éléments du planning et des mesures incompatibles."
|
||||
},
|
||||
"preferenceHelp": {
|
||||
"time_window": "Jours de congé dans cette période souhaitée. Plus, c’est mieux.",
|
||||
@@ -373,6 +424,7 @@
|
||||
"no-working-date": "Aucun jour ouvré ne se trouve à la fois dans la période planifiée et entre ses dates de disponibilité et d’expiration.",
|
||||
"all-dates-occupied": "Tous les jours ouvrés où ce solde est utilisable sont déjà occupés par des congés calculés ou des jours déjà posés.",
|
||||
"preserved": "Conservé pour plus tard : il expire après la période planifiée et le calcul n’a pas retenu de pose supplémentaire selon vos priorités.",
|
||||
"active-priorities": "Le calcul a conservé ce solde inutilisé selon vos règles actives. Modifiez les règles ou leur ordre pour demander un autre planning.",
|
||||
"calculation-incomplete": "Le calcul a été interrompu avant de terminer la planification. Sa non-utilisation n’est pas une décision définitive.",
|
||||
"not-established": "La raison précise de sa non-utilisation n’a pas été établie par ce calcul."
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user