test(planning): verify integrated planning after trace removal
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m14s
CI / test (push) Successful in 3m21s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m21s
CI / security (push) Successful in 1m45s
CI / images (push) Successful in 1m53s
CI / required (push) Successful in 4s

close #77

See pull request #273
This commit was merged in pull request #273.
This commit is contained in:
nessar committed 2026-10-16 13:57:53 +02:00
commit 0852cd4444
5 files changed
+220 -2

No files matched your search

+1 -1
View File
@@ -136,7 +136,7 @@ jobs:
run: /tmp/semgrep/bin/semgrep scan --config p/javascript --config p/typescript --metrics off --sarif --output semgrep-results.sarif src scripts
- name: Dependency and secret scan
if: ${{ !cancelled() }}
run: trivy fs --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
run: trivy fs --db-repository public.ecr.aws/aquasecurity/trivy-db:2 --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
- name: Security reports
if: ${{ always() }}
uses: https://gitea.com/actions/upload-artifact@v3
+3
View File
@@ -7,6 +7,9 @@ the benchmark fixtures. Before trace removal, the full functional coverage run p
across 34 files under the 2 GB Node heap limit, excluding the separate benchmark.
That run does not establish a passing performance protocol.
The post-removal functional integration checks, browser QA and unchanged-input
handoff for #78 are recorded in [the #77 verification report](../trace-free-planning-verification.md).
On 2026-10-15, the #76 worktree based on `ea42561` passed 630 functional tests
across 33 files with coverage under the 2 GB Node heap limit. Complete outcome
comparisons against that baseline matched for all seven benchmark scenarios.
+2
View File
@@ -36,6 +36,8 @@ branch slug; stable promotions share one group and are not cancelled in progress
Validation uses Node 24.21.0 and Bun 1.4.2 with the frozen lockfile. The image
job uses Docker 29.8.2 and its matching Docker-in-Docker service. Scanner
versions are Semgrep 1.180.0 and Trivy 0.75.0; Trivy downloads are checksum-checked.
Trivy downloads its vulnerability database from Aqua's public ECR mirror because
the default registries returned a missing database blob and a download timeout.
Jobs select the `ubuntu-latest` label and require Linux container-job support.
Allow at least the preserved 2 GiB Node heap plus runner and container overhead.
+151
View File
@@ -0,0 +1,151 @@
# Integrated planning verification after trace removal
Functional verification for [#77](https://gitea.nessar.fr/nessar/vac-optimizer/issues/77),
run on 2026-10-15 against `2f4af14869c47836d2c1008157136f75c9d3e98b`
(`develop`, including the merged #76 implementation). The original checkout was
clean at `d9d9befa1256cd2200aa4778d28b2bc23ba141e6`; both commits have the same
tree. This verification adds two real-worker lifecycle tests and this report.
It does not change production behavior, saved data, placement expectations,
benchmark inputs, CI settings or performance limits.
## Contract audit
| Path | Verified retained contract |
| --- | --- |
| Synchronous and cooperative core | Outcomes, valid checkpoints, final-state snapshots, stable explanations and explicit cancellation; no recorder, candidate history or historical replay API. |
| Server calculation context | Immutable submitted configuration, independently delivered outcome and explanation, and displayed-placement date analysis. |
| Browser without Worker | Validation and explicit `worker-unavailable` handling; no synchronous planning or trace compatibility result. |
| Native worker and coordinator | Heartbeat, progress, checkpoint, result, explanation and final artifacts. The coordinator terminates the worker after required delivery with no acknowledgement message or queue. |
| Calendar and remaining balances | Final and valid partial calendars, placement provenance, objective comparisons, earned sources, validity dates and concrete non-placement reasons. |
| Alternative dates | Captured horizon, entitlement, type and rule provenance; unrelated placements stay fixed. Hypothetical comparisons never update the displayed plan. |
| Benchmark callers | Supported calculation outcome, explanation and timings; no trace promises, waits, counts or replacement trace metrics. |
Production-source searches found no decision-trace, historical analysis,
acknowledgement or replay implementation. Angular hydration's `withEventReplay`
is unrelated to planning history. `PlanningArtifacts` carries actual explanation,
final-state and planning-work timing data, rather than a compatibility shell.
## Existing deterministic fixtures
The full functional suite passes without changing any existing expected placement,
balance, metric, objective, chronology or completed-stage assertion.
| Requirement | Existing regression coverage |
| --- | --- |
| Typed joint obligations and shared provenance | `leave-planner.service.spec.ts`, `leave-planner-explanation.spec.ts` and real-worker tests. |
| Earned-entitlement chains and availability | Integration, explanation, supporting-placement and real-worker tests, including declarations and cancellation. |
| Custom formulas and reordered priorities | Optimization, calendar-objective, work-sequence and supporting-placement tests, including real-worker comparisons. |
| Two- and three-year horizons | Service, calendar and worker tests retain the submitted dates and deterministic completed plans. |
| No active optimization rules | Optimization and migration tests retain declarations and remaining balances without adding placements. |
| Stable explanations and remaining reasons | Entitlements, every contributing rule, ordered objective values, first decisive objective, periods and dated non-placement reasons. |
| Cancellation and failures | Before and after a jointly valid incumbent, stale calculations and date analyses, worker liveness, technical budgets, and local explanation errors. |
| Saved settings | Existing migration and persistence tests preserve user rule choices, balances, declarations and explicitly selected horizons. |
An additional direct comparison uses the pre-removal core from `ea42561`, with
only its relative imports redirected to the unchanged calendar/date/objective
modules. Both versions receive the same cloned requests and calculation date
`2024-01-01`. Full outcomes and stable explanations are compared with deep
equality; no placement expectations or retained explanation fields are filtered.
All ten completed comparisons match for both the full outcome and stable
explanation:
| Fixture | Outcome | Placements | Last completed stage |
| --- | --- | --- | --- |
| Representative year | `Plan` | 30 | `dates` |
| Typed joint obligations | `Plan` | 10 | `dates` |
| Chained earned entitlements | `Plan` | 3 | `dates` |
| Deliberate conflict | `PlanningConflict` | 0 | — |
| Two years | `Plan` | 60 | `dates` |
| Three years | `Plan` | 90 | `dates` |
| Holiday value before non-working-day gain | `Plan` | 3 | `dates` |
| Non-working-day gain before holiday value | `Plan` | 3 | `dates` |
| Custom maximum holiday-week aggregation | `Plan` | 3 | `dates` |
| No active optimization, with a declared day | `Plan` | 0 | `dates` |
The reordered priorities retain their different placement dates: 6, 7 and 10 May
for holiday value first; 29 April, 10 May and 17 May for gain first. The zero-rule
case retains its declaration. These are correctness comparisons, not timed
benchmark repetitions. An exploratory pre-removal loaded-year replay was stopped
and is not used as passing evidence; its full protocol remains with #78.
## Correctness and static checks
Environment: Node `24.18.0`, Bun `1.3.14`, Linux under WSL2.
```sh
NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
bunx tsc -p tsconfig.app.json --noEmit
bunx tsc -p tsconfig.spec.json --noEmit
bun run lint
bun run format:check
bunx prettier --check 'scripts/ci-*.mjs' '.gitea/workflows/*.yml'
node scripts/ci-policy.test.mjs
node scripts/ci-image.test.mjs
bun run build
git diff --check
```
All checks pass. Functional coverage: **632 tests across 33 files**, 93.56%
statements, 85.90% branches, 95.69% functions and 95.41% lines. The full coverage
run took 95.67 seconds with the existing 2 GB Node heap setting. The focused
real-worker file passes all 15 tests, including the two added lifecycle checks.
Coverage configuration and thresholds are unchanged.
The production build retains its existing warning budgets: initial bundle
2.57 MB against the 2.50 MB warning and calendar-year styles 12.98 kB against
the 8 kB warning. Both remain below their unchanged error limits.
## Browser QA
The production build was served locally with
`NG_ALLOWED_HOSTS=localhost,127.0.0.1 PORT=4177` and exercised in headless
Chromium `149.0.7827.55`, using native module Workers, reduced motion and
375×915, 768×915 and 1440-pixel-wide viewports. The calculation date was fixed
to `2024-01-01`. This is desktop functional evidence, not a phone performance run.
| Flow | Result |
| --- | --- |
| Completed calendar and explanation | Three CP placements on 6, 7 and 10 May 2024, six gained non-working dates; keyboard disclosure, placement navigation and calendar selection work. |
| Remaining balances | Future RTT availability and unavoidable expiry show their actual dates, reasons and planning horizon without attempt/history controls. Earned-source rendering is also covered by component tests. |
| Date comparison | An alternative on 13 May is hypothetical; placements and selected calendar dates remain unchanged. Escape closes the dialog and restores focus to Compare dates. |
| Immutable settings | Persisting a current CP balance edit from 3 to 99 leaves the displayed three placements unchanged. Select another placement and verify that a new comparison request is sent after the edit; that request still contains the captured quantity 3 and original horizon. |
| Cancellation before a valid plan | At 375 px, hold the native worker script download, activate Cancel with Enter, then release startup. Result is `PlanningIncomplete` / `user-cancelled`, without an incumbent or a false conflict. This controlled startup is not latency evidence. |
| Cancellation after a valid plan | At 375 px, use the unchanged loaded-year request and cancel with Enter after a real checkpoint. All three typed obligation windows remain satisfied, placements plus remaining balances total 72, and the partial explanation is available. Inputs remain editable during planning; Cancel is at least 44 px high. |
| Delayed and stale delivery | At 375 px, hold explanation and artifact messages after a real outcome. The three-placement calendar is usable immediately. Recalculate, then release the old messages; the current result and explanation remain intact. |
| Explanation failure | At 375 px, inject explanation transport and checkpoint-explanation recovery failures. An inline error appears while the completed calendar and all three placements remain available. |
| Responsive and keyboard interaction | No page-level horizontal overflow at 375, 768 or 1440 px. Comparison dialogs fit, retain keyboard focus and restore it on dismissal. No unexpected browser page errors. |
| Removed UI | No advanced trace explorer, historical overlays, replay controls, candidate-status UI or remaining-balance attempt controls. |
Controlled-clock transport tests additionally verify that heartbeats renew the
30-second inactivity watchdog across repeated windows and that monitoring ends
at outcome delivery. Native-worker tests verify actual heartbeat delivery,
incumbent validity and retained explanations. No automatic engine cutoff is added.
## Handoff to #78
The #70 → #71 → #72 → #73 → #74 → #75 dependency order is preserved. The tracker
already reports those six tickets and #76 closed. This work verifies their
functional integration and does not close or modify any other ticket.
The benchmark source and its original correctness assertions remain unchanged
from the integrated baseline:
| File | SHA-256 |
| --- | --- |
| `src/app/benchmark/leave-planner-benchmark.ts` | `16e057fa042c898d203f71e018f183c9fbecb5cd0edbf0ce0f3f25277cb00d3a` |
| `src/app/benchmark/leave-planner-benchmark.spec.ts` | `c7b4c37b6c169b42c4d18dfc3e650b25a2ba47415c97268dafefafbfb0ee3023` |
All seven scenarios, typed and earned-chain fixtures, two-/three-year horizons,
60-/90-placement expectations, determinism comparisons and final `dates` stage
assertions remain in place. Warm-ups and measured repetitions remain unchanged.
The full 2 GB / 300-second benchmark protocol and any required optimization stay
open in [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78); they are not
a completion gate for #75 or #77. This run makes no performance acceptance or
trace-removal speedup claim. The separate advisory benchmark status must be
resolved by #78 under the existing limits.
Local run logs, the original spec and checkout record, comparison scripts and
browser screenshots are in `/tmp/vac-issue-77`. The source fixtures and commands
above identify the reproducible correctness checks; temporary QA controls are
not part of the application.
+63 -1
View File
@@ -33,6 +33,9 @@ describe('real planning worker cancellation', () => {
let simulateLongCalculation = false;
let simulatedElapsed = 0;
const threads = new Set<NodeWorker>();
const sentTypes: string[] = [];
const receivedTypes: string[] = [];
let terminationCount = 0;
beforeAll(async () => {
directory = mkdtempSync(join(tmpdir(), 'vac-optimizer-worker-'));
@@ -65,6 +68,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
heartbeats = 0;
simulateLongCalculation = false;
simulatedElapsed = 0;
sentTypes.length = 0;
receivedTypes.length = 0;
terminationCount = 0;
class RealWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
@@ -76,6 +82,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
simulatedElapsed = data.elapsed;
return;
}
receivedTypes.push(data.type);
if (holdArtifacts && ['explanation', 'artifacts'].includes(data.type)) {
heldArtifactMessages.push(() => this.onmessage?.({ data } as MessageEvent));
return;
@@ -86,10 +93,12 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
});
this.thread.on('error', () => this.onerror?.());
}
postMessage(message: unknown) {
postMessage(message: { type: string }) {
sentTypes.push(message.type);
this.thread.postMessage(message);
}
terminate() {
terminationCount++;
void this.thread.terminate();
}
}
@@ -104,6 +113,59 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it('delivers the outcome and explanation and releases the worker without acknowledgements', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-04' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }],
rules: [presets[0]],
});
const outcome = await calculation.outcome;
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [{ date: '2024-01-02', balanceId: 'cp', type: 'cp' }],
lastCompletedStage: 'dates',
});
expect(await calculation.explanation).toMatchObject({
kind: 'PlanExplanation',
placements: [{ placement: { date: '2024-01-02', balanceId: 'cp' } }],
});
expect(await calculation.explanationError).toBeUndefined();
expect(await calculation.timings).toEqual({ planningWorkMs: expect.any(Number) });
expect(sentTypes).toEqual(['plan']);
expect(receivedTypes.slice(-3)).toEqual(['result', 'explanation', 'artifacts']);
expect(new Set(receivedTypes)).toEqual(
new Set(['heartbeat', 'progress', 'checkpoint', 'result', 'explanation', 'artifacts']),
);
expect(terminationCount).toBe(1);
});
it('ignores a replaced real worker’s late explanation without losing either outcome', async () => {
const request = {
horizon: { start: '2024-01-02', end: '2024-01-04' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }],
rules: [presets[0]],
};
holdArtifacts = true;
const first = createPlanningCalculation(request);
const original = await first.outcome;
expect(original.kind).toBe('Plan');
await vi.waitFor(() => expect(heldArtifactMessages).toHaveLength(2));
holdArtifacts = false;
const latest = createPlanningCalculation({ ...request, rules: [] });
const outcome = await latest.outcome;
expect(outcome).toMatchObject({ kind: 'Plan', placements: [] });
const explanation = await latest.explanation;
expect(explanation).toMatchObject({ kind: 'PlanExplanation', placements: [] });
await latest.timings;
for (const deliver of heldArtifactMessages) deliver();
expect(await first.outcome).toEqual(original);
expect(await first.explanation).toBeUndefined();
expect(await latest.outcome).toEqual(outcome);
expect(await latest.explanation).toEqual(explanation);
expect(await latest.explanationError).toBeUndefined();
expect(terminationCount).toBe(2);
});
it('preserves a complete optimization configuration and active order in the real worker snapshot', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.unplacedAtRisk,