From a9107bda3702cb489b496606798fef998011cc17 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sat, 11 Jul 2026 16:30:09 +0200 Subject: [PATCH 01/12] docs(backlog): open REINTEGRATE-ORPHAN-TESTS lot (PRD + 7 tickets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage audit found 62 features tested ONLY by the dead FullGas relics under tests/dcs/noPlayer/. 46 are genuine gaps (behaviour alive in src/, tested nowhere active), 10 are false orphans already covered elsewhere, 6 need FullGas (deep refactors / visual check β€” logged in dev/fullgas-report.md). This lot re-integrates the 46 gaps, busted-first (mockable -> tests/ci/ in CI; only real-engine behaviour stays as live-DCS tagged scenarios). It precedes and unblocks the planned CLEANUP-LEGACY-DCS-TESTS purge. Co-Authored-By: Claude Opus 4.8 --- .backlog/README.md | 2 +- .backlog/REINTEGRATE-ORPHAN-TESTS/PRD.md | 72 +++++++++++++++++++ .../tickets/01-crate-lifecycle.md | 40 +++++++++++ .../tickets/02-legacy-api-wrappers.md | 33 +++++++++ .../tickets/03-scenes-minefields.md | 35 +++++++++ .../tickets/04-deploy-managers.md | 36 ++++++++++ .../tickets/05-menu-gating-player-wiring.md | 45 ++++++++++++ .../tickets/06-jtac-recon.md | 31 ++++++++ .../tickets/07-live-dcs-coverage.md | 38 ++++++++++ 9 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 .backlog/REINTEGRATE-ORPHAN-TESTS/PRD.md create mode 100644 .backlog/REINTEGRATE-ORPHAN-TESTS/tickets/01-crate-lifecycle.md create mode 100644 .backlog/REINTEGRATE-ORPHAN-TESTS/tickets/02-legacy-api-wrappers.md create mode 100644 .backlog/REINTEGRATE-ORPHAN-TESTS/tickets/03-scenes-minefields.md create mode 100644 .backlog/REINTEGRATE-ORPHAN-TESTS/tickets/04-deploy-managers.md create mode 100644 .backlog/REINTEGRATE-ORPHAN-TESTS/tickets/05-menu-gating-player-wiring.md create mode 100644 .backlog/REINTEGRATE-ORPHAN-TESTS/tickets/06-jtac-recon.md create mode 100644 .backlog/REINTEGRATE-ORPHAN-TESTS/tickets/07-live-dcs-coverage.md diff --git a/.backlog/README.md b/.backlog/README.md index 9657c82..d08dcd0 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -14,7 +14,7 @@ authored **per lot, when the lot is started** (not in batch). | Lot | Status | Description | Branch | |-----|--------|-------------|--------| -| _(none)_ | | | | +| `REINTEGRATE-ORPHAN-TESTS` | 🚧 | Rebuild coverage for the 46 features tested only by dead FullGas relics (busted-first: 6 busted tickets + 1 live-DCS). Precedes & unblocks `CLEANUP-LEGACY-DCS-TESTS`. 10 relics are false orphans (already covered); 6 are deferred to FullGas (`dev/fullgas-report.md`). | feature/reintegrate-orphan-tests | ### Planned lots diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/PRD.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/PRD.md new file mode 100644 index 0000000..5dc0e76 --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/PRD.md @@ -0,0 +1,72 @@ +# Lot REINTEGRATE-ORPHAN-TESTS β€” rebuild coverage lost with the dead FullGas relics + +Status: 🚧 in progress +Branch: feature/reintegrate-orphan-tests β†’ PR (pending) β†’ develop +Program: re-tooling CTLD on the VMCT model (see `.backlog/README.md`). Precedes and unblocks the +planned `CLEANUP-LEGACY-DCS-TESTS` purge. + +## Problem Statement + +`tests/dcs/noPlayer/` still holds ~194 dead FullGas relics (no `-- @tier:` header, `dofile` of +`C:/Users/Moi/.../DCS-CTLD_FG/recette/setup.lua`, absent `ctld_test` framework). The planned +`CLEANUP-LEGACY-DCS-TESTS` lot would purge them. But a coverage audit (cross-referencing the +tagged `@tier` scenarios and the busted `tests/ci/` suite by *tested symbol/assertion*, not by +file name) found that **62 features are covered ONLY by these relics** β€” and of those, **46 are +genuine gaps**: the behaviour is alive in `src/` but tested nowhere active. Purging without +re-integrating would silently lock in that coverage loss (it is already lost *de facto* β€” the +relics execute nothing β€” but the purge would make it permanent). + +Crucially these are mostly *live-DCS-flavoured* behaviours (events, manager contracts, scene +structures, legacy wrappers) β€” not covered by the entity-level busted specs that exist today. + +## Audit result (62 orphan features) + +- **GAP (46)** β€” alive in `src/`, uncovered β†’ re-integrate here. +- **COVERED (10)** β€” false orphans (covered under another name), relic is safe to purge, no test + action: F-024, F-026, F-042, F-072, F-073, F-074, F-076, F-081, F-082, F-111. +- **FULLGAS (6)** β€” deep refactor / human visual check, original-author intent required β†’ hand to + FullGas (see `dev/fullgas-report.md`), NOT guessed here: + - F-010 `hideScan`β†’`stopScan` renamed, mark-removal contract to settle + - F-020 vehicle parachute refactored to `parachuteVehicle()` (already covered by `parachute_spec`) + - F-044 / F-090 `fobScene` redesigned 4β†’21 steps, structure assertions stale + - F-093 FOB unpack: `_onFOBBuilt` gone + beacon now offset βˆ’5 m from the centroid + - F-100 `spawnCrate`: two crates physically visible on the F10 map β†’ human visual check (tier `ia`) + +## Solution + +**Busted-first.** Everything mockable against the DCS stubs β†’ busted (`tests/ci/`, runs in CI, +fast, deterministic). Only behaviour that needs the real DCS engine (real object spawn, world +events, `dcs_native` unit handling) stays as a live-DCS tagged scenario. + +Busted cannot be run locally (the Lua-for-Windows luarocks is too old to parse busted's modern +dependency constraints). We rely on CI as the runner (same posture as luacheck), with local +syntax validation via `luac5.1 -p`. The pilot ticket (01) is pushed first to validate the busted +pattern in CI before the remaining lots are written. + +## Tickets (one per coherent test lot) + +| # | Scope | Relics | Cible | +|---|-------|--------|-------| +| 01 | Crate lifecycle + events (PILOT) | F-027, F-028, F-029, F-030, F-031, F-032, F-041 | busted | +| 02 | Legacy API wrappers (routing + deprecation) | F-094, F-095, F-096, F-097, F-098 | busted | +| 03 | Scene structures + minefields | F-043, F-091, F-083, F-084, F-085, F-087 | busted | +| 04 | Deploy managers: AA assembly, FOB events, pack vehicle | F-021, F-022, F-023, F-012, F-013, F-099 | busted | +| 05 | Menu gating by config + player event wiring | F-048/049/050/052/053/054/055/056/075/077/088/089, F-025 | busted | +| 06 | JTAC config/deregister + recon auto-refresh | F-110, F-112, F-011 | busted | +| 07 | Live-DCS coverage (real engine) | F-006, F-007, F-092, F-009, F-018, F-019 | scΓ©nario `@tier` | + +## User Stories + +1. As a maintainer, I want the behaviours currently tested only by dead relics to have live, + running coverage, so that `CLEANUP-LEGACY-DCS-TESTS` can purge the relics without losing + anything. +2. As a developer, I want that coverage in busted/CI wherever possible, so it runs on every push + without a live DCS session. + +## Non-goals + +- Purging the relics themselves β€” that's `CLEANUP-LEGACY-DCS-TESTS`, after this lot. +- The 6 FULLGAS items β€” deferred to FullGas (documented in `dev/fullgas-report.md`). +- Re-testing the 10 COVERED features β€” already covered elsewhere. +- Raising the coverage ratchet as a goal in itself (it will rise as a side effect; the gate must + still pass). diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/01-crate-lifecycle.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/01-crate-lifecycle.md new file mode 100644 index 0000000..90e92cb --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/01-crate-lifecycle.md @@ -0,0 +1,40 @@ +# 01 β€” Crate lifecycle manager methods + events (PILOT) + +Status: 🚧 in progress +Type: AFK + +## What to build + +`tests/ci/unit/crate_lifecycle_spec.lua` β€” busted coverage for the `CTLDCrateManager` *manager* +methods and their event contracts (the existing `crate_manager_spec.lua` covers only the +entity-level `crate:load/unload/unpack` transitions and `getCrateByName`/`getCratesInRange`). + +Re-integrates relics: +- F-027 `registerMMCrate` β€” register mission-maker crate + guards (duplicate, unknown type) +- F-028 `loadCrate` β€” β†’ LOADED, publishes `OnCrateLoaded`, guards (unknown, not-on-ground) +- F-029 `unloadCrate` β€” β†’ LANDED, publishes `OnCrateUnloaded` (method) + `OnCrateSpawned` +- F-030 `unpackCrate` β€” β†’ UNPACKED, publishes `OnCrateUnpacked`, unregisters +- F-031 `dropCrate` ≀ maxDropHeight β€” β†’ LANDED, publishes `OnCrateUnloaded` (method="drop") +- F-032 `dropCrate` > maxDropHeight β€” destroyed, publishes `OnCrateDestroyed` (reason="drop_impact") +- F-041 `registerMMCrate` β€” publishes `OnMMCrateDetected` + +## Pattern (pilot β€” validate in CI first) + +- Capture events by subscribing to `EventDispatcher.getInstance()` around the call, then + `unsubscribe` (see `crate_manager_spec.lua` `spawnCrate` block for the proven approach). +- `unloadCrate`/`dropCrate`(safe) re-spawn a DCS static β†’ mock `coalition.addStaticObject` + + `StaticObject.getByName` in `before_each`/`after_each` (same as the `spawnCrate` describe block). +- Reset `cm.crates = {}` in `before_each`. + +## Acceptance criteria + +- [ ] `luac5.1 -p` clean. +- [ ] Covers F-027/028/030/041 (pilot subset β€” no static respawn) AND F-029/031/032 (with the + addStaticObject mock). +- [ ] Each manager method asserted on: state transition + event payload (count + key fields) + + at least one guard (no-op / no event on invalid input). +- [ ] `busted` job green in CI. + +## Blocked by + +None. First ticket β€” its green CI run validates the busted pattern for tickets 02–06. diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/02-legacy-api-wrappers.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/02-legacy-api-wrappers.md new file mode 100644 index 0000000..445af00 --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/02-legacy-api-wrappers.md @@ -0,0 +1,33 @@ +# 02 β€” Legacy API wrappers: routing + deprecation + +Status: todo +Type: AFK + +## What to build + +Busted coverage for the `src/legacy/legacy_api.lua` wrappers β€” each must route to its modern +target and emit a deprecation warning. Pure logic, fully mockable. + +Re-integrates relics: +- F-094 Troops wrappers (6 fn) β€” routing + deprecation warning +- F-095 Zones wrappers (10 fn) β€” routing (e.g. `activatePickupZone`β†’`setTroopZoneActive`) +- F-096 Crates wrappers (3 fn) + `spawnCrateAtZone` functional +- F-097 `createRadioBeaconAtZone` β†’ `createAtZone` + deprecation warning +- F-098 JTAC wrappers (3 fn) β€” routing (e.g. `JTACAutoLase`β†’`autoLase`) + +## Approach + +For each wrapper: stub the modern target method (spy), call the legacy fn, assert the target was +called with the right args and that a deprecation warning was logged. Confirm signatures against +`src/legacy/legacy_api.lua` (relics point at the old `src/compat/` path β€” dead). + +## Acceptance criteria + +- [ ] `luac5.1 -p` clean. +- [ ] Every wrapper listed above asserted: routes to correct target + args preserved. +- [ ] Deprecation warning path exercised at least once per wrapper family. +- [ ] `busted` job green. + +## Blocked by + +Ticket 01 (pattern validation). diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/03-scenes-minefields.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/03-scenes-minefields.md new file mode 100644 index 0000000..f007f4b --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/03-scenes-minefields.md @@ -0,0 +1,35 @@ +# 03 β€” Scene structures + minefields + +Status: todo +Type: AFK + +## What to build + +Busted coverage for scene model structure and minefield mine-count logic (pure/deterministic). + +Re-integrates relics: +- F-043 "FARP Alpha" scene β€” structure (steps, registry keys, polar layout) via the registered + scene model (`src/scenes/CTLD_farpAlphaScene.lua`) +- F-091 `farpScene` β€” structure (6 steps, SINGLE_HELIPAD/FARP_Tent…), Part 1 only (visual spawn + is the `ia` part, out of scope here) +- F-083 `setLandMine` 1Γ—1 β†’ 1 mine +- F-084 `setLandMine` 5Γ—15 staggered β†’ 68 mines (8Γ—5 + 7Γ—4) +- F-085 `setLandMine` 4Γ—3 staggered β†’ 11 mines (4 + 3 + 4) +- F-087 `setLandMineAuto` parametric count + single-mine branch + guards + +## Approach + +Structure: assert the registered scene model's step count / keys / polar params against +`src/scenes/*`. Minefields: stub the spawn primitive and count invocations for each grid. + +## Acceptance criteria + +- [ ] `luac5.1 -p` clean. +- [ ] Scene structure assertions track the CURRENT models (not the stale relic figures β€” verify + against `src/scenes/` before writing expected values). +- [ ] Mine counts asserted for 1Γ—1, 5Γ—15, 4Γ—3, and the auto/parametric path. +- [ ] `busted` job green. + +## Blocked by + +Ticket 01. diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/04-deploy-managers.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/04-deploy-managers.md new file mode 100644 index 0000000..f6c6ce8 --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/04-deploy-managers.md @@ -0,0 +1,36 @@ +# 04 β€” Deploy managers: AA assembly, FOB events, pack vehicle + +Status: todo +Type: AFK + +## What to build + +Busted coverage for the crate-assembly, FOB-manager and pack-vehicle contracts. + +Re-integrates relics: +- F-021 AA assembly complete (`tryUnpackOrRepair`/`_assemble`) β†’ `OnAASystemDeployed` + crates destroyed +- F-022 AA assembly incomplete β†’ no deployment +- F-023 AA repair (`_repair`) β†’ `OnAASystemRepaired` + group replaced +- F-012 FOB deployed (`_registerDeployedFOB`) β†’ `OnFOBDeployed` payload + `_objectToFOB` reverse lookup +- F-013 FOB `onDead`/`_destroyFOB` β†’ integrity threshold + `OnFOBDestroyed` + cleanup +- F-099 `findPackableVehicles` + `packVehicle` β†’ `OnVehiclePacked` + crates spawned + +## Approach + +Event capture via `EventDispatcher`. For AA/FOB/pack: stub spawn/destroy primitives +(`coalition.addStaticObject`, group spawn, `crate:destroy`) and assert events + registry effects. +NB (from audit): `OnAASystemRepaired` is published nowhere else β€” F-023 is its only coverage. +Pack vehicle now spawns crates via `spawnCratesAligned` and refreshes via deferred +`timer.scheduleFunction` β€” assert accordingly, not the stale relic assumptions. + +## Acceptance criteria + +- [ ] `luac5.1 -p` clean. +- [ ] AA: complete (deploy + crates consumed), incomplete (no deploy), repair (event) all asserted. +- [ ] FOB: deploy event + reverse lookup, destroy event + cleanup asserted. +- [ ] Pack vehicle: `findPackableVehicles` result + `packVehicle` event/crates asserted. +- [ ] `busted` job green. + +## Blocked by + +Ticket 01. diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/05-menu-gating-player-wiring.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/05-menu-gating-player-wiring.md new file mode 100644 index 0000000..4ccf895 --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/05-menu-gating-player-wiring.md @@ -0,0 +1,45 @@ +# 05 β€” Menu gating by config + player event wiring + +Status: todo +Type: AFK + +## What to build + +Busted coverage for F10 menu section gating (by config flag / transport capability) and the +player-manager event wiring. + +Re-integrates relics: +- F-048 buildMenu all flags ON β†’ all sections present +- F-049 `enableCrates`=false β†’ Request Equipment/Crate Commands absent, Smoke/Beacons present +- F-050 `enabledRadioBeaconDrop`=false β†’ Radio Beacons absent +- F-052 `JTAC_jtacStatusF10`=false β†’ JTAC absent +- F-053 `enabledFOBBuilding`=false β†’ List FOBs absent (under Crate) +- F-054 `enablePackingVehicles`=false β†’ Pack Vehicle absent +- F-055 non-transport player β†’ root + Check Cargo only; RECON/JTAC still present +- F-056 `canCarryVehicles`=true β†’ Vehicle Commands present +- F-075 clearBranch + repopulate + refresh β†’ new cmds present, old absent +- F-077 `refreshMenuForGroup` unknown group β†’ `{success=false, refreshedCount=0}` +- F-088 `_loadUserConfig` ingests `ctld_config_user.customLoadableGroups`/`disableLoadableGroups` +- F-089 troop menu filters by disabled / side / capacity +- F-025 `OnVehicleLoaded/Unloaded` events β†’ `player.loadedVehicles` updated + +## Approach + +Use the `pm:buildMenu(playerObj)` + `menu:_getNode(path)` pattern (confirmed working in +`troop_multi_spec`). Toggle `ctld.gs(configKey)` per test (save/restore). Section presence via +node lookup. NB: `refresh()` is debounced/async now β€” drive synchronous rebuilds via +`refreshMenuForGroup`. F-089: `buildMenu(unit,gid,opts)` is gone β†’ target `refreshMenuSection` +(side filter at `CTLD_troop.lua:1858`). + +## Acceptance criteria + +- [ ] `luac5.1 -p` clean. +- [ ] Each config flag toggles the expected section on/off; non-transport & capacity gates asserted. +- [ ] `refreshMenuForGroup` unknown-group failure shape asserted. +- [ ] `_loadUserConfig` custom+disable ingestion asserted. +- [ ] Player `loadedVehicles` event wiring asserted. +- [ ] `busted` job green. + +## Blocked by + +Ticket 01. diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/06-jtac-recon.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/06-jtac-recon.md new file mode 100644 index 0000000..1788e6f --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/06-jtac-recon.md @@ -0,0 +1,31 @@ +# 06 β€” JTAC config/deregister + recon auto-refresh + +Status: todo +Type: AFK + +## What to build + +Busted coverage for JTAC config defaults / deregistration and recon auto-refresh. + +Re-integrates relics: +- F-110 `JTAC_unitTypeNames` config defaults (Hummer / SKP-11 per coalition, drone radius/altitude) +- F-112 `deregisterJTAC` β€” silent: no false `OnJTACDead`, laser code returned to pool, idempotent +- F-011 recon `enableAutoRefresh` / `disableAutoRefresh` + events (previous/new state) + +## Approach + +F-110: pure config assertions (`ctld.gs("JTAC_unitTypeNames")` etc.). F-112: sandbox β€” register a +JTAC, deregister, assert no `OnJTACDead` fired, `_laserPool` code freed, second deregister is a +no-op. F-011: subscribe to the auto-refresh events, toggle, assert payload state fields. + +## Acceptance criteria + +- [ ] `luac5.1 -p` clean. +- [ ] JTAC config defaults asserted per coalition. +- [ ] `deregisterJTAC`: no `OnJTACDead`, code pool freed, idempotent β€” all asserted. +- [ ] Recon auto-refresh enable/disable events asserted. +- [ ] `busted` job green. + +## Blocked by + +Ticket 01. diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/07-live-dcs-coverage.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/07-live-dcs-coverage.md new file mode 100644 index 0000000..88b41ed --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/07-live-dcs-coverage.md @@ -0,0 +1,38 @@ +# 07 β€” Live-DCS coverage (real engine required) + +Status: todo +Type: hybrid (AFK authoring + live DCS validation) + +## What to build + +Tagged `@tier` scenarios under `tests/dcs/noPlayer/` (contract-of-return, injected via +VEAF-dcs-bridge) for the behaviours that genuinely need the real DCS engine β€” not busted-able. + +Re-integrates relics: +- F-006 `dropBeacon` β€” spawns 3 real units + freq + `OnBeaconDropped` (real spawn) +- F-007 `removeClosestBeacon` β€” removal + `OnBeaconRemoved` (reason=manual) +- F-092 `dropBeacon` with `overridePosition`=centroid, isFOB β†’ infinite battery +- F-009 recon `scan()` β€” `OnReconScan` payload (activeLayers/targets), needs real enemies in mission +- F-018 `loadVehicle` method=`dcs_native` β†’ LOADED + `OnVehicleLoaded` (real DCS unit) +- F-019 `unloadVehicle` method=`dcs_native` on ground β†’ WAITING + `OnVehicleUnloaded` + +## Approach + +New scenarios from `tests/dcs/_template_noPlayer.lua` (or `_template_scenario.lua`), tier `auto` +or `auto-check`. Follow the return contract (`_SCN__RESULT`, verdict grammar). Validate live +via `python tools/integration-runner/run_scenarios.py --scenario --no-ai` against a running +DCS mission (requires David's DCS + dcs-serve up). + +NB: this is the ONLY ticket needing a live DCS session; it runs after the busted tickets. +Corrections to relic assumptions flagged by the audit (F-018 `unit` kept alive in dcs_native; +F-092 beacon position) must be applied when authoring. + +## Acceptance criteria + +- [ ] Each scenario carries a valid `-- @tier:` header and the return contract. +- [ ] `luac5.1 -p` clean on each. +- [ ] Each scenario PASSes live via the headless runner (needs DCS up β€” validate with David). + +## Blocked by + +Tickets 01–06 (busted lots first). Live validation gated on a DCS session. From 643f56cb94d2f93d1625ff5f26092c9a6bf95786 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sat, 11 Jul 2026 16:30:09 +0200 Subject: [PATCH 02/12] test(ci): re-integrate crate lifecycle manager coverage (ticket 01 pilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CTLDCrateManager loadCrate/unpackCrate/registerMMCrate + their OnCrate*/ OnMMCrateDetected events and guards β€” previously covered only by dead relics F-027/F-028/F-030/F-041. Complements crate_manager_spec.lua (entity level only). Pilot subset (no static respawn): validates the busted pattern in CI before the rest of the lot. F-029/031/032 (with addStaticObject mock) follow once green. Co-Authored-By: Claude Opus 4.8 --- tests/ci/unit/crate_lifecycle_spec.lua | 183 +++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/ci/unit/crate_lifecycle_spec.lua diff --git a/tests/ci/unit/crate_lifecycle_spec.lua b/tests/ci/unit/crate_lifecycle_spec.lua new file mode 100644 index 0000000..879c712 --- /dev/null +++ b/tests/ci/unit/crate_lifecycle_spec.lua @@ -0,0 +1,183 @@ +---@diagnostic disable +-- tests/ci/unit/crate_lifecycle_spec.lua +-- busted specs for CTLDCrateManager lifecycle methods + their published events. +-- Re-integrates coverage that previously lived ONLY in the dead FullGas relics +-- (framework ctld_test, never re-tooled at the VEAF bootstrap): +-- F-027 registerMMCrate β€” register + guards +-- F-028 loadCrate β€” LOADED + OnCrateLoaded +-- F-030 unpackCrate β€” UNPACKED + OnCrateUnpacked + registry removal +-- F-041 registerMMCrate β€” OnMMCrateDetected event +-- crate_manager_spec.lua covers the entity-level crate:load/unload/unpack transitions; +-- this file covers the *manager* methods, their event contracts, and their guards. +-- ============================================================ + +describe("CTLDCrateManager lifecycle + events", function() + + local cm + + -- Capture every payload published for `eventName` during fn(), then clean up. + local function capture(eventName, fn) + local ed = EventDispatcher.getInstance() + local fired = {} + local cb = function(p) fired[#fired + 1] = p end + ed:subscribe(eventName, cb) + local ok, err = pcall(fn) + ed:unsubscribe(eventName, cb) + assert(ok, err) + return fired + end + + local function transport(name) + return { + getName = function() return name or "heli_test" end, + isExist = function() return true end, + } + end + + local function makeCrate(name, xPos) + return CTLDCrate:new({ + crateName = name, + descriptor = { unit = "M92_Ammo_Pallet", cratesRequired = 1 }, + spawnMethod = CTLDCrate.SPAWN_METHOD.CRATE_SPAWN, + position = { x = xPos or 0, y = 0, z = 0 }, + coalition = coalition.side.BLUE, + }) + end + + before_each(function() + cm = CTLDCrateManager.getInstance() + cm.crates = {} + end) + + -- ── F-028 : loadCrate ───────────────────────────────────────────────────── + describe("loadCrate (F-028)", function() + + it("transitions the crate to LOADED", function() + cm.crates["c1"] = makeCrate("c1") + cm:loadCrate("c1", transport("heli1")) + assert.equals(CTLDCrate.STATE.LOADED, cm.crates["c1"].state) + end) + + it("publishes OnCrateLoaded once with the carrier unit name", function() + cm.crates["c1"] = makeCrate("c1") + local fired = capture("OnCrateLoaded", function() + cm:loadCrate("c1", transport("heli1")) + end) + assert.equals(1, #fired) + assert.equals("c1", fired[1].crateName) + assert.equals("heli1", fired[1].carrierUnitName) + end) + + it("does nothing for an unknown crate name (no error, no event)", function() + local fired = capture("OnCrateLoaded", function() + cm:loadCrate("does_not_exist", transport()) + end) + assert.equals(0, #fired) + end) + + it("does not re-load a crate that is not on the ground", function() + cm.crates["c1"] = makeCrate("c1") + cm:loadCrate("c1", transport("heli1")) -- now LOADED + local fired = capture("OnCrateLoaded", function() + cm:loadCrate("c1", transport("heli2")) -- guard: not on ground + end) + assert.equals(0, #fired) + end) + + end) + + -- ── F-030 : unpackCrate ─────────────────────────────────────────────────── + describe("unpackCrate (F-030)", function() + + it("transitions the crate to UNPACKED", function() + local crate = makeCrate("c1") + cm.crates["c1"] = crate + cm:unpackCrate("c1", transport("heli1")) + -- crate is unregistered afterwards, so hold our own reference to check final state + assert.equals(CTLDCrate.STATE.UNPACKED, crate.state) + end) + + it("publishes OnCrateUnpacked once", function() + cm.crates["c1"] = makeCrate("c1") + local fired = capture("OnCrateUnpacked", function() + cm:unpackCrate("c1", transport("heli1")) + end) + assert.equals(1, #fired) + assert.equals("c1", fired[1].crateName) + end) + + it("removes the crate from the registry", function() + cm.crates["c1"] = makeCrate("c1") + cm:unpackCrate("c1", transport("heli1")) + assert.is_nil(cm.crates["c1"]) + end) + + it("does not unpack a crate that is not on the ground", function() + cm.crates["c1"] = makeCrate("c1") + cm:loadCrate("c1", transport("heli1")) -- LOADED, not on ground + local fired = capture("OnCrateUnpacked", function() + cm:unpackCrate("c1", transport("heli1")) + end) + assert.equals(0, #fired) + assert.is_not_nil(cm.crates["c1"]) -- still registered + end) + + end) + + -- ── F-027 / F-041 : registerMMCrate ─────────────────────────────────────── + describe("registerMMCrate (F-027, F-041)", function() + + -- A valid cargo typeName is one whose descriptor.unit matches (findDescriptorByTypeName). + -- Derive it from the config rather than hardcoding, so the test tracks the real dataset. + local function validTypeName() + local d = cm:findDescriptorByUnitType("M1043 HMMWV Armament") + return d and d.unit + end + + local function mmStatic(name, typeName) + return { + getName = function() return name end, + getPoint = function() return { x = 10, y = 0, z = 20 } end, + getCoalition = function() return coalition.side.BLUE end, + getDesc = function() return { typeName = typeName } end, + } + end + + it("registers a mission-maker crate in the registry", function() + local tn = validTypeName() + assert.is_not_nil(tn) + cm:registerMMCrate(mmStatic("mm_1", tn), { typeName = tn }) + assert.is_not_nil(cm.crates["mm_1"]) + assert.equals(CTLDCrate.SPAWN_METHOD.MISSION_MAKER, cm.crates["mm_1"].spawnMethod) + end) + + it("publishes OnMMCrateDetected once for a valid crate", function() + local tn = validTypeName() + local fired = capture("OnMMCrateDetected", function() + cm:registerMMCrate(mmStatic("mm_2", tn), { typeName = tn }) + end) + assert.equals(1, #fired) + assert.equals("mm_2", fired[1].crateName) + end) + + it("skips an unknown cargo type (no register, no event)", function() + local fired = capture("OnMMCrateDetected", function() + cm:registerMMCrate(mmStatic("mm_bad", "__NotACargoType__"), + { typeName = "__NotACargoType__" }) + end) + assert.equals(0, #fired) + assert.is_nil(cm.crates["mm_bad"]) + end) + + it("does not register the same crate name twice", function() + local tn = validTypeName() + cm:registerMMCrate(mmStatic("mm_3", tn), { typeName = tn }) + local fired = capture("OnMMCrateDetected", function() + cm:registerMMCrate(mmStatic("mm_3", tn), { typeName = tn }) -- duplicate + end) + assert.equals(0, #fired) + end) + + end) + +end) From c1ce671a44485fca5230a01051aa7ddcaa07acb1 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sat, 11 Jul 2026 16:33:17 +0200 Subject: [PATCH 03/12] =?UTF-8?q?test(ci):=20complete=20ticket=2001=20?= =?UTF-8?q?=E2=80=94=20unloadCrate=20+=20dropCrate=20coverage=20(F-029/031?= =?UTF-8?q?/032)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the static-respawn cases to crate_lifecycle_spec: unloadCrate (LANDED + OnCrateUnloaded + re-register), dropCrate safe (method=drop) / destroy (drop_impact + unregister), and the not-loaded guard. Mocks coalition. addStaticObject + StaticObject.getByName as in crate_manager_spec's spawnCrate. Co-Authored-By: Claude Opus 4.8 --- tests/ci/unit/crate_lifecycle_spec.lua | 93 ++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/ci/unit/crate_lifecycle_spec.lua b/tests/ci/unit/crate_lifecycle_spec.lua index 879c712..03a19c2 100644 --- a/tests/ci/unit/crate_lifecycle_spec.lua +++ b/tests/ci/unit/crate_lifecycle_spec.lua @@ -180,4 +180,97 @@ describe("CTLDCrateManager lifecycle + events", function() end) + -- ── F-029 / F-031 / F-032 : unloadCrate + dropCrate ─────────────────────── + -- These re-spawn (unload) a DCS static on the ground β†’ mock coalition.addStaticObject + + -- StaticObject.getByName (same proven approach as crate_manager_spec's spawnCrate block). + describe("unloadCrate + dropCrate (F-029, F-031, F-032)", function() + + local origAddStatic, origGetByName, origGetAbsTime + + before_each(function() + origAddStatic = coalition.addStaticObject + origGetByName = StaticObject.getByName + origGetAbsTime = timer.getAbsTime + local spawned = {} + coalition.addStaticObject = function(cId, data) + spawned[data.name] = true + return { getName = function() return data.name end } + end + StaticObject.getByName = function(name) + return spawned[name] and { _name = name } or nil + end + timer.getAbsTime = function() return 100 end + end) + + after_each(function() + coalition.addStaticObject = origAddStatic + StaticObject.getByName = origGetByName + timer.getAbsTime = origGetAbsTime + end) + + it("unloadCrate transitions LOADED β†’ LANDED (F-029)", function() + local crate = makeCrate("c1") + cm.crates["c1"] = crate + cm:loadCrate("c1", transport("heli1")) + cm:unloadCrate("c1", { x = 100, y = 0, z = 100 }, "menu_ctld") + assert.equals(CTLDCrate.STATE.LANDED, crate.state) + end) + + it("unloadCrate publishes OnCrateUnloaded with the method (F-029)", function() + local crate = makeCrate("c1") + cm.crates["c1"] = crate + cm:loadCrate("c1", transport("heli1")) + local fired = capture("OnCrateUnloaded", function() + cm:unloadCrate("c1", { x = 100, y = 0, z = 100 }, "menu_ctld") + end) + assert.equals(1, #fired) + assert.equals("menu_ctld", fired[1].method) + end) + + it("unloadCrate re-registers the crate on the ground (F-029)", function() + local crate = makeCrate("c1") + cm.crates["c1"] = crate + cm:loadCrate("c1", transport("heli1")) + cm:unloadCrate("c1", { x = 100, y = 0, z = 100 }, "menu_ctld") + -- crate may have been renamed by the respawn; look it up by its (updated) name + assert.equals(crate, cm.crates[crate.crateName]) + end) + + it("dropCrate below maxDropHeight lands safely, method=drop (F-031)", function() + local maxH = ctld.gs("maxDropHeight") or 7.5 + local crate = makeCrate("c1") + cm.crates["c1"] = crate + cm:loadCrate("c1", transport("heli1")) + local fired = capture("OnCrateUnloaded", function() + cm:dropCrate("c1", maxH - 1) + end) + assert.equals(CTLDCrate.STATE.LANDED, crate.state) + assert.equals(1, #fired) + assert.equals("drop", fired[1].method) + end) + + it("dropCrate above maxDropHeight destroys the crate (F-032)", function() + local maxH = ctld.gs("maxDropHeight") or 7.5 + local crate = makeCrate("c1") + cm.crates["c1"] = crate + cm:loadCrate("c1", transport("heli1")) + local fired = capture("OnCrateDestroyed", function() + cm:dropCrate("c1", maxH + 1000) + end) + assert.equals(1, #fired) + assert.equals("drop_impact", fired[1].reason) + assert.is_nil(cm.crates["c1"]) -- unregistered on impact + end) + + it("dropCrate does nothing for a crate that is not loaded (F-031)", function() + local crate = makeCrate("c1") + cm.crates["c1"] = crate -- SPAWNED, never loaded + local fired = capture("OnCrateUnloaded", function() + cm:dropCrate("c1", 1) + end) + assert.equals(0, #fired) + end) + + end) + end) From 7f9f37d2dde9671cf3468cdaa641ac735e93a462 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sat, 11 Jul 2026 16:34:39 +0200 Subject: [PATCH 04/12] docs(backlog): mark ticket 01 done Co-Authored-By: Claude Opus 4.8 --- .backlog/REINTEGRATE-ORPHAN-TESTS/tickets/01-crate-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/01-crate-lifecycle.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/01-crate-lifecycle.md index 90e92cb..435ed2c 100644 --- a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/01-crate-lifecycle.md +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/01-crate-lifecycle.md @@ -1,6 +1,6 @@ # 01 β€” Crate lifecycle manager methods + events (PILOT) -Status: 🚧 in progress +Status: βœ… done Type: AFK ## What to build From 20f9753e4ef6abcd7893806ab852a3e1c2dc11c7 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sat, 11 Jul 2026 16:38:31 +0200 Subject: [PATCH 05/12] test(ci): re-integrate legacy API wrapper coverage (ticket 02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit legacy_api_spec covers all 21 v1β†’v2 wrappers (Troops/Zones/Crates/Beacon/JTAC): each routes to the correct v2 manager method with args preserved and logs a DEPRECATED warning. An is_function guard asserts the routing target actually exists on the manager. Re-integrates dead relics F-094..F-098. Also loads src/legacy/legacy_api.lua in the busted helper (loader.lua) β€” it was missing from the module list, so the wrappers were unreachable from any spec. Co-Authored-By: Claude Opus 4.8 --- .../tickets/02-legacy-api-wrappers.md | 2 +- tests/ci/helpers/loader.lua | 3 + tests/ci/unit/legacy_api_spec.lua | 193 ++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 tests/ci/unit/legacy_api_spec.lua diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/02-legacy-api-wrappers.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/02-legacy-api-wrappers.md index 445af00..063bc9f 100644 --- a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/02-legacy-api-wrappers.md +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/02-legacy-api-wrappers.md @@ -1,6 +1,6 @@ # 02 β€” Legacy API wrappers: routing + deprecation -Status: todo +Status: βœ… done Type: AFK ## What to build diff --git a/tests/ci/helpers/loader.lua b/tests/ci/helpers/loader.lua index 5efc563..4a4a624 100644 --- a/tests/ci/helpers/loader.lua +++ b/tests/ci/helpers/loader.lua @@ -52,3 +52,6 @@ dofile(SRC .. "CTLD_player.lua") -- ── Orchestrator ────────────────────────────────────────────── dofile(SRC .. "CTLD_core.lua") + +-- ── Legacy v1β†’v2 API wrappers (last in listToMerge, before CTLD_userConfig) ── +dofile(SRC .. "legacy/legacy_api.lua") diff --git a/tests/ci/unit/legacy_api_spec.lua b/tests/ci/unit/legacy_api_spec.lua new file mode 100644 index 0000000..40ce416 --- /dev/null +++ b/tests/ci/unit/legacy_api_spec.lua @@ -0,0 +1,193 @@ +---@diagnostic disable +-- tests/ci/unit/legacy_api_spec.lua +-- busted specs for the CTLD v1β†’v2 legacy API wrappers (src/legacy/legacy_api.lua). +-- Each wrapper must (a) route to the correct v2 manager method with args preserved, +-- and (b) log a DEPRECATED warning. Re-integrates the dead FullGas relics +-- F-094 (Troops), F-095 (Zones), F-096 (Crates), F-097 (Beacon), F-098 (JTAC). +-- ============================================================ + +describe("legacy API wrappers", function() + + local origWarn, warns + + before_each(function() + origWarn = ctld.logWarning + warns = {} + ctld.logWarning = function(m) warns[#warns + 1] = tostring(m) end + end) + + after_each(function() + ctld.logWarning = origWarn + end) + + local function warnedDeprecated() + for _, m in ipairs(warns) do + if m:find("DEPRECATED", 1, true) then return true end + end + return false + end + + -- Assert that calling wrapperFn() routes exactly once to instance[method] with `expected` + -- args (positional), the routing target actually exists, and a DEPRECATED warning fired. + local function assertRoute(instanceFn, method, wrapperFn, expected) + local inst = instanceFn() + assert.is_function(inst[method]) -- routing target must exist on the manager + local orig = inst[method] + local calls = {} + inst[method] = function(self, ...) calls[#calls + 1] = { ... }; return true end + wrapperFn() + inst[method] = orig + assert.equals(1, #calls) + for i, v in ipairs(expected) do + assert.equals(v, calls[1][i]) + end + assert.is_true(warnedDeprecated()) + end + + local function troop() return CTLDTroopManager.getInstance() end + local function zone() return CTLDZoneManager.getInstance() end + local function crate() return CTLDCrateManager.getInstance() end + local function beacon()return CTLDBeaconManager.getInstance()end + local function jtac() return CTLDJTACManager.getInstance() end + + -- ── F-094 : Troops / Transport ──────────────────────────────────────────── + describe("Troops / Transport (F-094)", function() + + it("spawnGroupAtTrigger β†’ CTLDTroopManager:spawnGroupAtTrigger", function() + assertRoute(troop, "spawnGroupAtTrigger", + function() ctld.spawnGroupAtTrigger(2, 3, "trig", 100) end, { 2, 3, "trig", 100 }) + end) + + it("spawnGroupAtPoint β†’ CTLDTroopManager:spawnGroupAtPoint", function() + local pt = { x = 1, y = 0, z = 2 } + assertRoute(troop, "spawnGroupAtPoint", + function() ctld.spawnGroupAtPoint(2, 3, pt, 100) end, { 2, 3, pt, 100 }) + end) + + it("preLoadTransport β†’ CTLDTroopManager:preLoadTransport", function() + assertRoute(troop, "preLoadTransport", + function() ctld.preLoadTransport("u1", 5, "troops") end, { "u1", 5, "troops" }) + end) + + it("unloadTransport β†’ CTLDTroopManager:unloadTransport", function() + assertRoute(troop, "unloadTransport", + function() ctld.unloadTransport("u1") end, { "u1" }) + end) + + it("loadTransport β†’ CTLDTroopManager:loadTransport", function() + assertRoute(troop, "loadTransport", + function() ctld.loadTransport("u1") end, { "u1" }) + end) + + it("unloadInProximityToEnemy β†’ CTLDTroopManager:unloadInProximityToEnemy", function() + assertRoute(troop, "unloadInProximityToEnemy", + function() ctld.unloadInProximityToEnemy("u1", 500) end, { "u1", 500 }) + end) + + end) + + -- ── F-095 : Zones (+ dropped-count watchers, routed to the troop manager) ── + describe("Zones (F-095)", function() + + it("activatePickupZone β†’ setTroopZoneActive(name, true)", function() + assertRoute(zone, "setTroopZoneActive", + function() ctld.activatePickupZone("Z1") end, { "Z1", true }) + end) + + it("deactivatePickupZone β†’ setTroopZoneActive(name, false)", function() + assertRoute(zone, "setTroopZoneActive", + function() ctld.deactivatePickupZone("Z1") end, { "Z1", false }) + end) + + it("changeRemainingGroupsForPickupZone β†’ changeRemainingGroups", function() + assertRoute(zone, "changeRemainingGroups", + function() ctld.changeRemainingGroupsForPickupZone("Z1", -2) end, { "Z1", -2 }) + end) + + it("activateWaypointZone β†’ activateWaypointZone", function() + assertRoute(zone, "activateWaypointZone", + function() ctld.activateWaypointZone("W1") end, { "W1" }) + end) + + it("deactivateWaypointZone β†’ deactivateWaypointZone", function() + assertRoute(zone, "deactivateWaypointZone", + function() ctld.deactivateWaypointZone("W1") end, { "W1" }) + end) + + it("createExtractZone β†’ createExtractZone", function() + assertRoute(zone, "createExtractZone", + function() ctld.createExtractZone("E1", 7, "green") end, { "E1", 7, "green" }) + end) + + it("removeExtractZone β†’ removeExtractZone", function() + assertRoute(zone, "removeExtractZone", + function() ctld.removeExtractZone("E1", 7) end, { "E1", 7 }) + end) + + it("countDroppedGroupsInZone β†’ CTLDTroopManager:startGroupCountWatcher", function() + assertRoute(troop, "startGroupCountWatcher", + function() ctld.countDroppedGroupsInZone("Z1", 10, 11) end, { "Z1", 10, 11 }) + end) + + it("countDroppedUnitsInZone β†’ CTLDTroopManager:startUnitCountWatcher", function() + assertRoute(troop, "startUnitCountWatcher", + function() ctld.countDroppedUnitsInZone("Z1", 10, 11) end, { "Z1", 10, 11 }) + end) + + end) + + -- ── F-096 : Crates ──────────────────────────────────────────────────────── + describe("Crates (F-096)", function() + + it("spawnCrateAtZone β†’ CTLDCrateManager:spawnCrateAtZone", function() + assertRoute(crate, "spawnCrateAtZone", + function() ctld.spawnCrateAtZone(2, 500, "Z1") end, { 2, 500, "Z1" }) + end) + + it("spawnCrateAtPoint β†’ CTLDCrateManager:spawnCrateAtPoint", function() + local pt = { x = 1, y = 0, z = 2 } + assertRoute(crate, "spawnCrateAtPoint", + function() ctld.spawnCrateAtPoint(2, 500, pt, 180) end, { 2, 500, pt, 180 }) + end) + + it("cratesInZone β†’ CTLDCrateManager:startCrateCountWatcher", function() + assertRoute(crate, "startCrateCountWatcher", + function() ctld.cratesInZone("Z1", 12) end, { "Z1", 12 }) + end) + + end) + + -- ── F-097 : Beacon ──────────────────────────────────────────────────────── + describe("Beacon (F-097)", function() + + it("createRadioBeaconAtZone β†’ CTLDBeaconManager:createAtZone", function() + assertRoute(beacon, "createAtZone", + function() ctld.createRadioBeaconAtZone("Z1", 2, 3600, "B1") end, + { "Z1", 2, 3600, "B1" }) + end) + + end) + + -- ── F-098 : JTAC ────────────────────────────────────────────────────────── + describe("JTAC (F-098)", function() + + it("JTACAutoLase β†’ CTLDJTACManager:autoLase", function() + assertRoute(jtac, "autoLase", + function() ctld.JTACAutoLase("J1", 1688, true, false, "red", 30) end, + { "J1", 1688, true, false, "red", 30 }) + end) + + it("JTACStart β†’ CTLDJTACManager:startLase", function() + assertRoute(jtac, "startLase", + function() ctld.JTACStart("J1", 1688, true, false, "red", 30) end, + { "J1", 1688, true, false, "red", 30 }) + end) + + it("JTACAutoLaseStop β†’ CTLDJTACManager:stopAutoLase", function() + assertRoute(jtac, "stopAutoLase", + function() ctld.JTACAutoLaseStop("J1") end, { "J1" }) + end) + + end) + +end) From 019a2c6822e4646f7d24309c9e0de87cb893c1aa Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sat, 11 Jul 2026 16:48:23 +0200 Subject: [PATCH 06/12] test(ci): re-integrate JTAC config/deregister + recon auto-refresh (ticket 06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F-110: JTAC drone config defaults (droneRadius/droneAltitude/lock) + per- coalition JTAC drone descriptors. NB: the relic's JTAC_unitTypeNames key is gone β€” the JTAC role is now declared via isJTAC=true in spawnableCrates; re-integrated against the current model, not the dead key. - F-112: deregisterJTAC is silent (no OnJTACDead), returns the laser code to the pool, idempotent, unknown group is a no-op. - F-011: recon enable/disableAutoRefresh toggle scan.autoRefresh and publish OnReconAutoRefresh{Enabled,Disabled} with previous/new state. Co-Authored-By: Claude Opus 4.8 --- .../tickets/06-jtac-recon.md | 2 +- .../ci/unit/jtac_recon_reintegration_spec.lua | 249 ++++++++++++++++++ 2 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 tests/ci/unit/jtac_recon_reintegration_spec.lua diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/06-jtac-recon.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/06-jtac-recon.md index 1788e6f..953f213 100644 --- a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/06-jtac-recon.md +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/06-jtac-recon.md @@ -1,6 +1,6 @@ # 06 β€” JTAC config/deregister + recon auto-refresh -Status: todo +Status: βœ… done Type: AFK ## What to build diff --git a/tests/ci/unit/jtac_recon_reintegration_spec.lua b/tests/ci/unit/jtac_recon_reintegration_spec.lua new file mode 100644 index 0000000..e1507ff --- /dev/null +++ b/tests/ci/unit/jtac_recon_reintegration_spec.lua @@ -0,0 +1,249 @@ +---@diagnostic disable +-- tests/ci/unit/jtac_recon_reintegration_spec.lua +-- busted specs re-integrating coverage that previously lived ONLY in the dead +-- FullGas relics under tests/dcs/noPlayer/F-1NN_*.lua (never re-tooled at the +-- VEAF bootstrap). Each block is anchored on the CURRENT src behaviour, not the +-- relic's stale intent: +-- F-110 JTAC config defaults (drone radius/altitude + JTAC unit types/coalition) +-- F-112 deregisterJTAC β€” silent (no OnJTACDead), frees laser code, idempotent +-- F-011 recon enable/disableAutoRefresh + their previous/new-state events +-- +-- NOTE on F-110 divergence from the relic: the relic asserted a per-coalition +-- `JTAC_unitTypeNames` table (Hummer / SKP-11). That key NO LONGER EXISTS in the +-- current src β€” JTAC role is now declared via `isJTAC=true` descriptors inside +-- `spawnableCrates` (see CTLD_config.lua l.336-338). The per-coalition JTAC unit +-- declaration is re-integrated against that current model; the drone radius/altitude +-- defaults survive as first-class config keys and are asserted directly. +-- ============================================================ + +describe("JTAC + recon relic re-integration", function() + + -- Capture every payload published for `eventName` during fn(), then clean up. + local function capture(eventName, fn) + local ed = EventDispatcher.getInstance() + local fired = {} + local cb = function(p) fired[#fired + 1] = p end + ed:subscribe(eventName, cb) + local ok, err = pcall(fn) + ed:unsubscribe(eventName, cb) + assert(ok, err) + return fired + end + + -- ── F-110 : JTAC config defaults ────────────────────────────────────────── + describe("JTAC config defaults (F-110)", function() + + -- Find a spawnableCrates "Drone" descriptor by its `unit` type name. + local function droneDescriptor(unitType) + local crates = ctld.gs("spawnableCrates") + local drones = crates and crates["Drone"] or {} + for _, d in ipairs(drones) do + if d.unit == unitType then return d end + end + return nil + end + + it("JTAC_droneRadius default is 1000 m", function() + assert.equals(1000, ctld.gs("JTAC_droneRadius")) + end) + + it("JTAC_droneAltitude default is 4000 m", function() + assert.equals(4000, ctld.gs("JTAC_droneAltitude")) + end) + + it("BLUE JTAC drone is the MQ-9 Reaper (side 2), flagged isJTAC", function() + -- Current model's replacement for the relic's per-coalition JTAC_unitTypeNames. + local d = droneDescriptor("MQ-9 Reaper") + assert.is_not_nil(d) + assert.equals(coalition.side.BLUE, d.side) + assert.is_true(d.isJTAC) + end) + + it("RED JTAC drone is the RQ-1A Predator (side 1), flagged isJTAC", function() + local d = droneDescriptor("RQ-1A Predator") + assert.is_not_nil(d) + assert.equals(coalition.side.RED, d.side) + assert.is_true(d.isJTAC) + end) + + it("JTAC_lock default is 'all'", function() + assert.equals("all", ctld.gs("JTAC_lock")) + end) + + end) + + -- ── F-112 : deregisterJTAC ───────────────────────────────────────────────── + describe("deregisterJTAC (F-112)", function() + + local mgr, origGetByName + + -- Mock ground infantry group (same shape as functional/jtac_manager_spec). + local function makeMockGroup(name, unitName) + local mockUnit = { + _desc = { attributes = { Infantry = true } }, + getName = function(self) return unitName end, + getID = function(self) return 9901 end, + getTypeName = function(self) return "Soldier M4 GRG" end, + getPoint = function(self) return { x = 0, y = 0, z = 0 } end, + getDesc = function(self) return self._desc end, + } + return { + _name = name, + _coa = coalition.side.BLUE, + getName = function(self) return self._name end, + getID = function(self) return 9900 end, + getCoalition = function(self) return self._coa end, + getUnits = function(self) return { mockUnit } end, + getUnit = function(self, _) return mockUnit end, + getController = function(self) return nil end, + isExist = function(self) return true end, + } + end + + before_each(function() + CTLDJTACManager._instance = nil + EventDispatcher._instance = nil + origGetByName = Group.getByName + mgr = CTLDJTACManager.get() + end) + + after_each(function() + Group.getByName = origGetByName + end) + + -- Spawn a JTAC letting the manager assign a code from the pool (tail removal), + -- so the "code returned to pool" assertion is meaningful. + local function spawnPooled(groupName, unitName) + local grp = makeMockGroup(groupName, unitName) + Group.getByName = function(n) + if n == groupName then return grp end + return origGetByName(n) + end + return mgr:spawnJTAC(groupName, { smokeEnabled = false, lockMode = "all" }, nil) + end + + it("removes the JTAC from the registry", function() + spawnPooled("JTAC_Dereg_A", "JTAC_Unit_A") + assert.is_not_nil(mgr:getJTACByName("JTAC_Dereg_A")) + mgr:deregisterJTAC("JTAC_Dereg_A") + assert.is_nil(mgr:getJTACByName("JTAC_Dereg_A")) + end) + + it("does NOT publish OnJTACDead (pack is not a combat death)", function() + spawnPooled("JTAC_Dereg_B", "JTAC_Unit_B") + local fired = capture("OnJTACDead", function() + mgr:deregisterJTAC("JTAC_Dereg_B") + end) + assert.equals(0, #fired) + end) + + it("returns the laser code to the pool (tail, pool +1)", function() + local jtac = spawnPooled("JTAC_Dereg_C", "JTAC_Unit_C") + local assignedCode = jtac.laserCode + local poolBefore = #mgr._laserPool + mgr:deregisterJTAC("JTAC_Dereg_C") + assert.equals(poolBefore + 1, #mgr._laserPool) + assert.equals(assignedCode, mgr._laserPool[#mgr._laserPool]) + end) + + it("is idempotent: a second call is a no-op (no event, no pool change)", function() + spawnPooled("JTAC_Dereg_D", "JTAC_Unit_D") + mgr:deregisterJTAC("JTAC_Dereg_D") -- first call: real deregister + local poolAfterFirst = #mgr._laserPool + local fired = capture("OnJTACDead", function() + mgr:deregisterJTAC("JTAC_Dereg_D") -- second call: no-op + end) + assert.equals(0, #fired) + assert.equals(poolAfterFirst, #mgr._laserPool) + end) + + it("does nothing for an unknown group (no error, no event)", function() + local fired = capture("OnJTACDead", function() + mgr:deregisterJTAC("JTAC_DoesNotExist") + end) + assert.equals(0, #fired) + end) + + end) + + -- ── F-011 : recon enable / disable auto-refresh ──────────────────────────── + describe("recon auto-refresh (F-011)", function() + + local rm + + local function makePlayerUnit(name) + return { + getName = function() return name or "P1_Unit" end, + getCoalition = function() return coalition.side.BLUE end, + getGroup = function() return { getID = function() return 42 end } end, + } + end + + before_each(function() + CTLDReconManager._instance = nil + rm = CTLDReconManager.getInstance() + -- Neutralise the F10 menu rebuild β€” irrelevant to the event contract. + rm._rebuildReconBranch = function() end + end) + + it("enableAutoRefresh flips scan.autoRefresh false β†’ true", function() + rm._activeScans["P1"] = { targets = { {}, {}, {} }, autoRefresh = false } + rm:enableAutoRefresh(makePlayerUnit("P1_Unit"), "P1") + assert.is_true(rm._activeScans["P1"].autoRefresh) + end) + + it("enableAutoRefresh publishes OnReconAutoRefreshEnabled with previous=false / new=true", function() + rm._activeScans["P1"] = { targets = { {}, {}, {} }, autoRefresh = false } + local fired = capture("OnReconAutoRefreshEnabled", function() + rm:enableAutoRefresh(makePlayerUnit("P1_Unit"), "P1") + end) + assert.equals(1, #fired) + assert.is_false(fired[1].previousState) + assert.is_true(fired[1].newState) + assert.equals(3, fired[1].targetsCount) + assert.equals(10, fired[1].refreshInterval) -- reconRefreshInterval default + end) + + it("enableAutoRefresh is idempotent when already ON (no second event)", function() + rm._activeScans["P1"] = { targets = { {} }, autoRefresh = true } + local fired = capture("OnReconAutoRefreshEnabled", function() + rm:enableAutoRefresh(makePlayerUnit("P1_Unit"), "P1") + end) + assert.equals(0, #fired) + end) + + it("enableAutoRefresh with no active scan publishes nothing", function() + local fired = capture("OnReconAutoRefreshEnabled", function() + rm:enableAutoRefresh(makePlayerUnit("P1_Unit"), "P1") + end) + assert.equals(0, #fired) + end) + + it("disableAutoRefresh flips scan.autoRefresh true β†’ false", function() + rm._activeScans["P1"] = { targets = { {}, {} }, autoRefresh = true, refreshTimer = nil } + rm:disableAutoRefresh(makePlayerUnit("P1_Unit"), "P1") + assert.is_false(rm._activeScans["P1"].autoRefresh) + end) + + it("disableAutoRefresh publishes OnReconAutoRefreshDisabled with previous=true / new=false", function() + rm._activeScans["P1"] = { targets = { {}, {} }, autoRefresh = true, refreshTimer = nil } + local fired = capture("OnReconAutoRefreshDisabled", function() + rm:disableAutoRefresh(makePlayerUnit("P1_Unit"), "P1") + end) + assert.equals(1, #fired) + assert.is_true(fired[1].previousState) + assert.is_false(fired[1].newState) + assert.equals(2, fired[1].targetsCount) + end) + + it("disableAutoRefresh is a no-op when already OFF (no event)", function() + rm._activeScans["P1"] = { targets = { {} }, autoRefresh = false } + local fired = capture("OnReconAutoRefreshDisabled", function() + rm:disableAutoRefresh(makePlayerUnit("P1_Unit"), "P1") + end) + assert.equals(0, #fired) + end) + + end) + +end) From 3586f03da0a0dcffb90aaaa76c887af46fd9653e Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sat, 11 Jul 2026 16:50:46 +0200 Subject: [PATCH 07/12] test(ci): re-integrate scene structures + minefield counts (ticket 03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F-043/F-091: FARP Alpha (14 steps) and farpScene (6 steps) model structure via getModel(), asserting steps/registry keys/polar params against current src. - F-083/084/085: setLandMine mine counts β€” staggered formula T(N,R)=R*N-floor(R/2) gives 1 / 68 / 11 (verified by running the real src). - F-087: setLandMineAuto parametric count (39), single-mine branch, and guards. Loads src/scenes/* and i18n fr/es/ko in setup (same as minefield_spec/i18n_spec), since loader.lua does not pull those in. Complements minefield_spec (which covers structure + guards) with the actual counts and the FARP scenes. Co-Authored-By: Claude Opus 4.8 --- .../tickets/03-scenes-minefields.md | 2 +- tests/ci/unit/scenes_minefields_spec.lua | 315 ++++++++++++++++++ 2 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 tests/ci/unit/scenes_minefields_spec.lua diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/03-scenes-minefields.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/03-scenes-minefields.md index f007f4b..7bbd941 100644 --- a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/03-scenes-minefields.md +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/03-scenes-minefields.md @@ -1,6 +1,6 @@ # 03 β€” Scene structures + minefields -Status: todo +Status: βœ… done Type: AFK ## What to build diff --git a/tests/ci/unit/scenes_minefields_spec.lua b/tests/ci/unit/scenes_minefields_spec.lua new file mode 100644 index 0000000..2826107 --- /dev/null +++ b/tests/ci/unit/scenes_minefields_spec.lua @@ -0,0 +1,315 @@ +---@diagnostic disable +-- tests/ci/unit/scenes_minefields_spec.lua +-- busted specs for the FARP / minefield scene models. Re-integrates coverage that +-- previously lived ONLY in the dead FullGas relics (framework ctld_test, never re-tooled): +-- F-043 FARP Alpha scene structure (src/scenes/CTLD_farpAlphaScene.lua) +-- F-091 farpScene structure β€” Part 1 only (src/scenes/CTLD_farpScene.lua) +-- F-083 setLandMine 1x1 β†’ 1 mine (src/scenes/CTLD_mineFieldScene.lua) +-- F-084 setLandMine 5x15 quinconce β†’ 68 (recomputed from src, see below) +-- F-085 setLandMine 4x3 quinconce β†’ 11 +-- F-087 setLandMineAuto parametric + single-mine branch + guards +-- +-- Scenes self-register into CTLDSceneManager; the model table is retrieved via +-- getModel(name). The mineField model exposes setLandMine / setLandMineAuto and its +-- own _sets registry as plain table fields, so they can be driven directly here. +-- +-- Mine counting: the spawn primitive is CTLDObjectRegistry.spawnObject("Landmine", ...). +-- We stub it to count invocations and return a fake DCS static; setLandMine collects +-- every truthy return into its result array, so #result == number of mines spawned. +-- +-- Quinconce mine-count formula (src ~l.210, nbMinesColumns=N >= 2, nbMinesPerColumns=R): +-- odd rows (r=1,3,...) : N mines +-- even rows (r=2,4,...) : N-1 mines +-- total T(N,R) = R*N - floor(R/2) +-- F-084 N=5, R=15 : 15*5 - floor(15/2) = 75 - 7 = 68 +-- F-085 N=4, R=3 : 3*4 - floor(3/2) = 12 - 1 = 11 +-- ============================================================ + +-- Resolve repo root so we can dofile the scene files. The CI loader (helpers/loader.lua) +-- loads src/ managers but NOT src/scenes/*, so each scene spec loads its own scene files. +local _thisFile = debug.getinfo(1, "S").source:match("^@(.+)tests[\\/]ci[\\/]unit[\\/]") +if not _thisFile then _thisFile = "" end -- relative path: cwd is repo root + +-- Scene files write i18n into ctld.i18n["fr"/"es"/"ko"], but loader.lua only loads the EN +-- dictionary. Load the non-EN dicts here (idempotent) so the scene dofiles below never index +-- a nil language table, regardless of spec execution order. Same pattern as i18n_spec.lua. +dofile(_thisFile .. "src/CTLD_i18n_fr.lua") +dofile(_thisFile .. "src/CTLD_i18n_es.lua") +dofile(_thisFile .. "src/CTLD_i18n_ko.lua") + +describe("FARP / minefield scenes", function() + + local sm + + -- Register the scene models once. registerSceneModel is a no-op if a model of the + -- same name is already registered (e.g. mineField loaded by another spec), so this is + -- safe regardless of spec execution order. + setup(function() + dofile(_thisFile .. "src/scenes/CTLD_mineFieldScene.lua") + dofile(_thisFile .. "src/scenes/CTLD_farpAlphaScene.lua") + dofile(_thisFile .. "src/scenes/CTLD_farpScene.lua") + end) + + before_each(function() + sm = CTLDSceneManager.getInstance() + end) + + -- ── F-043 : FARP Alpha scene structure ──────────────────────────────────── + describe("FARP Alpha scene structure (F-043)", function() + + local model + before_each(function() model = sm:getModel("FARP Alpha") end) + + it("is registered as a built-in scene model", function() + assert.is_not_nil(model) + assert.equals("FARP Alpha", model.name) + end) + + it("has 14 steps (13 spawn + 1 completion func)", function() + assert.equals(14, #model.steps) + end) + + it("step 1 spawns SINGLE_HELIPAD at polar 100/0 with a fuel-warehouse func", function() + local s = model.steps[1] + assert.equals("SINGLE_HELIPAD", s.registryKey) + assert.is_not_nil(s.polar) + assert.equals(100, s.polar.distance) + assert.equals(0, s.polar.angle) + assert.is_function(s.func) + end) + + it("step 2 spawns FARP_Tent and carries relativeHeadingInDegrees", function() + local s = model.steps[2] + assert.equals("FARP_Tent", s.registryKey) + assert.is_not_nil(s.relativeHeadingInDegrees) + end) + + it("step 14 is func-only (completion message, no registryKey)", function() + local s = model.steps[14] + assert.is_nil(s.registryKey) + assert.is_function(s.func) + end) + + it("every step carries a delayAfterPreviousStep", function() + for _, s in ipairs(model.steps) do + assert.is_not_nil(s.delayAfterPreviousStep) + end + end) + + it("exposes its crate descriptor (weight, deploy/i18n keys, 1 crate required)", function() + assert.is_not_nil(model.crate) + assert.equals(1001.23, model.crate.weight) + assert.equals("FARP Alpha Crate", model.crate.i18nKey) + assert.equals("Deploy FARP Alpha", model.crate.deployKey) + assert.equals(1, model.crate.cratesRequired) + end) + + end) + + -- ── F-091 : farpScene structure (Part 1 only β€” visual spawn is out of busted scope) ── + describe("farpScene structure (F-091 Part 1)", function() + + local model + before_each(function() model = sm:getModel("farpScene") end) + + it("is registered after its scene file loads", function() + assert.is_not_nil(model) + assert.equals("farpScene", model.name) + end) + + it("has 6 steps (prescript + 5 objects)", function() + assert.equals(6, #model.steps) + end) + + it("step 1 is the prescript func (no registryKey, delay 0)", function() + local s = model.steps[1] + assert.is_nil(s.registryKey) + assert.is_function(s.func) + assert.equals(0, s.delayAfterPreviousStep) + end) + + it("step 2 spawns SINGLE_HELIPAD at the reference point (polar 0/0, delay 0)", function() + local s = model.steps[2] + assert.equals("SINGLE_HELIPAD", s.registryKey) + assert.equals(0, s.polar.distance) + assert.equals(0, s.polar.angle) + assert.equals(0, s.delayAfterPreviousStep) + end) + + it("step 3 spawns FARP_Tent at 30 m / 90Β° (delay 1 s)", function() + local s = model.steps[3] + assert.equals("FARP_Tent", s.registryKey) + assert.equals(30, s.polar.distance) + assert.equals(90, s.polar.angle) + assert.equals(1, s.delayAfterPreviousStep) + end) + + it("step 4 spawns FARP_Ammo_Storage at 30 m / 135Β°", function() + local s = model.steps[4] + assert.equals("FARP_Ammo_Storage", s.registryKey) + assert.equals(30, s.polar.distance) + assert.equals(135, s.polar.angle) + end) + + it("step 5 spawns Windsock at 15 m / 270Β°", function() + local s = model.steps[5] + assert.equals("Windsock", s.registryKey) + assert.equals(15, s.polar.distance) + assert.equals(270, s.polar.angle) + end) + + it("step 6 spawns Fuel_Truck at 35 m / 225Β° (delay 1 s)", function() + local s = model.steps[6] + assert.equals("Fuel_Truck", s.registryKey) + assert.equals(35, s.polar.distance) + assert.equals(225, s.polar.angle) + assert.equals(1, s.delayAfterPreviousStep) + end) + + it("object steps 2-6 all carry relativeAltitudeInMeters", function() + for i = 2, 6 do + assert.is_not_nil(model.steps[i].relativeAltitudeInMeters) + end + end) + + end) + + -- ── F-083 / F-084 / F-085 / F-087 : minefield spawning ───────────────────── + describe("minefield spawning (F-083, F-084, F-085, F-087)", function() + + local scene + local origSpawnObject + local spawnCount + + -- A minimal DCS Unit stub. getHeadingInRadians(rawHeading=true) reads + -- getPosition().x (orientation vec3) β†’ atan2(0,1) = 0 rad heading. + local function transport() + return { + getName = function() return "heli_test" end, + isExist = function() return true end, + getCoalition= function() return coalition.side.BLUE end, + getCountry = function() return country.id.USA end, + getPosition = function() + return { + x = { x = 1, y = 0, z = 0 }, -- orientation (heading 0) + p = { x = 0, y = 0, z = 0 }, -- position + } + end, + } + end + + before_each(function() + scene = sm:getModel("mineField") + scene._sets = {} -- reset deployed-set registry between tests + + -- Stub the spawn primitive: count invocations, return a fake DCS static. + origSpawnObject = CTLDObjectRegistry.spawnObject + spawnCount = 0 + CTLDObjectRegistry.spawnObject = function(objectKey) + spawnCount = spawnCount + 1 + local n = spawnCount + return { + getName = function() return "Mine-" .. n end, + isExist = function() return true end, + destroy = function() end, + } + end + end) + + after_each(function() + CTLDObjectRegistry.spawnObject = origSpawnObject + end) + + -- ── F-083 : 1x1 single mine ──────────────────────────────────────────── + describe("setLandMine 1x1 (F-083)", function() + + it("spawns exactly 1 mine and returns it in a table", function() + local ok, result = scene.setLandMine(transport(), 20, 1, 1, 6, 12) + assert.is_true(ok) + assert.is_not_nil(result) + assert.equals("table", type(result)) + assert.equals(1, #result) + assert.equals(1, spawnCount) + end) + + end) + + -- ── F-084 : 5x15 quinconce β†’ 68 ──────────────────────────────────────── + describe("setLandMine 5x15 quinconce (F-084)", function() + + it("spawns 68 mines (T(5,15) = 15*5 - floor(15/2) = 68)", function() + local ok, result = scene.setLandMine(transport(), 20, 5, 15, 6, 12) + assert.is_true(ok) + assert.equals(68, #result) + assert.equals(68, spawnCount) + end) + + end) + + -- ── F-085 : 4x3 quinconce β†’ 11 ───────────────────────────────────────── + describe("setLandMine 4x3 quinconce (F-085)", function() + + it("spawns 11 mines (T(4,3) = 3*4 - floor(3/2) = 11)", function() + local ok, result = scene.setLandMine(transport(), 20, 4, 3, 6, 12) + assert.is_true(ok) + assert.equals(11, #result) + assert.equals(11, spawnCount) + end) + + end) + + -- ── F-087 : setLandMineAuto parametric ───────────────────────────────── + describe("setLandMineAuto (F-087)", function() + + -- Layout solver picks the (N,R) whose T(N,R) is closest to nbMines while + -- honouring the width/length aspect ratio. For (width=50, length=80, nbMines=40): + -- N0 = floor(sqrt(40*50/80) + 0.5) = floor(5.5) = 5 + -- candidates settle on N=4, R=11 β†’ T(4,11) = 11*4 - floor(11/2) = 39 mines. + it("lays 39 mines for a 50 m x 80 m / 40-mine request", function() + local ok, result = scene.setLandMineAuto(transport(), 20, 50, 80, 40) + assert.is_true(ok) + assert.equals("table", type(result)) + assert.equals(39, #result) + assert.equals(39, spawnCount) + end) + + it("routes nbMines=1 to the single-mine branch (exactly 1 mine)", function() + local ok, result = scene.setLandMineAuto(transport(), 20, 50, 80, 1) + assert.is_true(ok) + assert.equals(1, #result) + assert.equals(1, spawnCount) + end) + + it("returns false + message for a nil trigger unit (no spawn)", function() + local ok, msg = scene.setLandMineAuto(nil, 20, 50, 80, 40) + assert.is_false(ok) + assert.is_not_nil(msg) + assert.equals(0, spawnCount) + end) + + it("returns false + message for nbMines < 1", function() + local ok, msg = scene.setLandMineAuto(transport(), 20, 50, 80, 0) + assert.is_false(ok) + assert.is_not_nil(msg) + assert.equals(0, spawnCount) + end) + + it("returns false + message for a non-positive width", function() + local ok, msg = scene.setLandMineAuto(transport(), 20, 0, 80, 10) + assert.is_false(ok) + assert.is_not_nil(msg) + assert.equals(0, spawnCount) + end) + + it("returns false + message for a non-positive length", function() + local ok, msg = scene.setLandMineAuto(transport(), 20, 50, 0, 10) + assert.is_false(ok) + assert.is_not_nil(msg) + assert.equals(0, spawnCount) + end) + + end) + + end) + +end) From b4d1607cfd87230bbe6548e0112fc95900a286b6 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sat, 11 Jul 2026 16:51:32 +0200 Subject: [PATCH 08/12] test(ci): re-integrate deploy managers coverage (ticket 04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F-021/022/023: CTLDCrateAssemblyManager tryUnpackOrRepair β€” complete assembly (OnAASystemDeployed + crates consumed), incomplete (no deploy), repair (OnAASystemRepaired). Template pulled from TEMPLATES, no hardcoded types. - F-012/013: CTLDFOBManager _registerDeployedFOB (OnFOBDeployed + _objectToFOB reverse lookup) and onDead/_destroyFOB (integrity threshold + OnFOBDestroyed + cleanup). - F-099: findPackableVehicles + packVehicle (OnVehiclePacked, cratesSpawned derived from descriptor.cratesRequired, deferred refresh via scheduleFunction). Assertions recomputed against current src (M1043 cratesRequired=3, deferred refresh, _repairFor contract), not the stale relic assumptions. Co-Authored-By: Claude Opus 4.8 --- .../tickets/04-deploy-managers.md | 2 +- tests/ci/unit/deploy_managers_spec.lua | 565 ++++++++++++++++++ 2 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 tests/ci/unit/deploy_managers_spec.lua diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/04-deploy-managers.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/04-deploy-managers.md index f6c6ce8..0eeae5f 100644 --- a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/04-deploy-managers.md +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/04-deploy-managers.md @@ -1,6 +1,6 @@ # 04 β€” Deploy managers: AA assembly, FOB events, pack vehicle -Status: todo +Status: βœ… done Type: AFK ## What to build diff --git a/tests/ci/unit/deploy_managers_spec.lua b/tests/ci/unit/deploy_managers_spec.lua new file mode 100644 index 0000000..0495fd7 --- /dev/null +++ b/tests/ci/unit/deploy_managers_spec.lua @@ -0,0 +1,565 @@ +---@diagnostic disable +-- tests/ci/unit/deploy_managers_spec.lua +-- busted specs for the three "deployment" managers and their published events. +-- Re-integrates coverage that previously lived ONLY in the dead FullGas relics +-- (tests/dcs/noPlayer/F-0NN_*.lua), recomputed against the CURRENT src/: +-- F-021 CTLDCrateAssemblyManager assembly (complete) β†’ OnAASystemDeployed +-- F-022 CTLDCrateAssemblyManager assembly (incomplete) β†’ no deployment +-- F-023 CTLDCrateAssemblyManager repair β†’ OnAASystemRepaired +-- F-012 CTLDFOBManager:_registerDeployedFOB β†’ OnFOBDeployed +-- F-013 CTLDFOBManager:onDead / _destroyFOB β†’ OnFOBDestroyed +-- F-099 CTLDVehicleSpawner findPackableVehicles/packVehicle β†’ OnVehiclePacked +-- +-- The relics ran against a live DCS mission (real transport, real dynAdd). Here the +-- DCS group/static spawn is mocked so the logic, event contracts and registry +-- side-effects are exercised deterministically under busted. +-- ============================================================ + +-- Capture every payload published for `eventName` during fn(), then clean up. +local function capture(eventName, fn) + local ed = EventDispatcher.getInstance() + local fired = {} + local cb = function(p) fired[#fired + 1] = p end + ed:subscribe(eventName, cb) + local ok, err = pcall(fn) + ed:unsubscribe(eventName, cb) + assert(ok, err) + return fired +end + +-- ============================================================ +-- F-021 / F-022 / F-023 : CTLDCrateAssemblyManager +-- ============================================================ +describe("CTLDCrateAssemblyManager assembly + repair", function() + + local aam + local origDynAdd, origGetByName, origHeading + + -- name β†’ fake DCS Group built by the mocked dynAdd. Each fake group exposes the + -- subset of the API the manager touches (getName/getCoalition/getUnits/destroy). + local spawnedGroups + + -- Pull the KUB template straight from the live TEMPLATES so the test tracks the + -- real dataset (2 parts, both required, count=2) instead of hardcoding type names. + local function kubTemplate() + local t = aam:getTemplateByName("KUB AA System") + if not t then return nil end + local launcher, radar + for _, p in ipairs(t.parts) do + if p.launcher then launcher = p.DCSTypename else radar = p.DCSTypename end + end + return t, launcher, radar + end + + local function heli(coa) + return { + getPoint = function() return { x = 0, y = 0, z = 0 } end, + getCoalition = function() return coa or coalition.side.BLUE end, + getCountry = function() return country.id.USA end, + getName = function() return "heli_aa" end, + getGroup = function() return { getID = function() return 1 end } end, + } + end + + -- Duck-typed CTLDCrate stand-in (same shape the manager consumes: descriptor, + -- position, isOnGround, destroy). Tracks how many times it was destroyed. + local function aaCrate(unitName, repairFor) + local c = { + descriptor = { unit = unitName, _repairFor = repairFor, cratesRequired = 1 }, + position = { x = 100, y = 0, z = 0 }, + destroyedCount = 0, + } + function c:isOnGround() return true end + function c:destroy() self.destroyedCount = self.destroyedCount + 1 end + return c + end + + before_each(function() + aam = CTLDCrateAssemblyManager.getInstance() + aam._completeSystems = {} + + spawnedGroups = {} + origDynAdd = ctld.utils.dynAdd + origGetByName = Group.getByName + origHeading = ctld.utils.getHeadingInRadians + + -- Deterministic north-up heading everywhere the manager asks. + ctld.utils.getHeadingInRadians = function() return 0 end + + -- Fake group spawner: materialise one unit mock per groupData.units entry. + ctld.utils.dynAdd = function(_caller, gd) + local units = {} + for _, u in ipairs(gd.units) do + units[#units + 1] = { + getPoint = function() return { x = u.x, y = 0, z = u.y } end, + getTypeName = function() return u.type end, + getName = function() return u.name end, + getLife = function() return 1 end, + } + end + local g = { + getName = function() return gd.name end, + getCoalition = function() return coalition.side.BLUE end, + getUnits = function() return units end, + destroy = function() end, + } + spawnedGroups[gd.name] = g + return { name = gd.name } + end + Group.getByName = function(name) return spawnedGroups[name] end + end) + + after_each(function() + ctld.utils.dynAdd = origDynAdd + Group.getByName = origGetByName + ctld.utils.getHeadingInRadians = origHeading + end) + + -- ── F-021 : complete assembly ───────────────────────────────────────────── + describe("assembly of a complete system (F-021)", function() + + it("tryUnpackOrRepair returns true for an AA part crate", function() + local t, launcher, radar = kubTemplate() + assert.is_not_nil(t) + local crateLn = aaCrate(launcher) + local all = { kub_ln = crateLn, kub_sr = aaCrate(radar) } + assert.is_true(aam:tryUnpackOrRepair(heli(), crateLn, all, 500)) + end) + + it("publishes OnAASystemDeployed with the KUB payload", function() + local t, launcher, radar = kubTemplate() + assert.is_not_nil(t) + local crateLn = aaCrate(launcher) + local all = { kub_ln = crateLn, kub_sr = aaCrate(radar) } + local fired = capture("OnAASystemDeployed", function() + aam:tryUnpackOrRepair(heli(), crateLn, all, 500) + end) + assert.equals(1, #fired) + assert.equals("KUB AA System", fired[1].systemName) + assert.is_not_nil(fired[1].groupName) + assert.is_not_nil(fired[1].position) + assert.equals(coalition.side.BLUE, fired[1].coalition) + assert.is_not_nil(fired[1].timestamp) + end) + + it("registers the system and counts it as complete", function() + local t, launcher, radar = kubTemplate() + local crateLn = aaCrate(launcher) + aam:tryUnpackOrRepair(heli(), crateLn, { kub_ln = crateLn, kub_sr = aaCrate(radar) }, 500) + + local n = 0 + for _ in pairs(aam._completeSystems) do n = n + 1 end + assert.equals(1, n) + assert.equals(1, aam:countComplete(coalition.side.BLUE)) + end) + + it("destroys the consumed crates", function() + local t, launcher, radar = kubTemplate() + local crateLn = aaCrate(launcher) + local crateSr = aaCrate(radar) + aam:tryUnpackOrRepair(heli(), crateLn, { kub_ln = crateLn, kub_sr = crateSr }, 500) + assert.is_true(crateLn.destroyedCount >= 1) + assert.is_true(crateSr.destroyedCount >= 1) + end) + + end) + + -- ── F-022 : incomplete assembly ─────────────────────────────────────────── + describe("assembly with missing parts (F-022)", function() + + it("recognises the AA crate (returns true) but does not deploy", function() + local t, launcher = kubTemplate() + assert.is_not_nil(t) + local crateLn = aaCrate(launcher) -- launcher only, radar missing + local all = { kub_ln = crateLn } + local result + + local fired = capture("OnAASystemDeployed", function() + result = aam:tryUnpackOrRepair(heli(), crateLn, all, 500) + end) + + assert.is_true(result) -- AA type recognised even if incomplete + assert.equals(0, #fired) -- but no deployment event + end) + + it("leaves the registry empty and consumes no crate", function() + local t, launcher = kubTemplate() + local crateLn = aaCrate(launcher) + aam:tryUnpackOrRepair(heli(), crateLn, { kub_ln = crateLn }, 500) + + local n = 0 + for _ in pairs(aam._completeSystems) do n = n + 1 end + assert.equals(0, n) + assert.equals(0, aam:countComplete(coalition.side.BLUE)) + assert.equals(0, crateLn.destroyedCount) + end) + + end) + + -- ── F-023 : repair ──────────────────────────────────────────────────────── + describe("repair of an existing system (F-023)", function() + + it("getTemplateForUnit resolves a repair marker to its template", function() + local t = kubTemplate() + assert.is_not_nil(t) + local tmpl, isRepair = aam:getTemplateForUnit(nil, "KUB AA System") + assert.is_not_nil(tmpl) + assert.equals("KUB AA System", tmpl.name) + assert.is_true(isRepair) + end) + + it("publishes OnAASystemRepaired and keeps exactly one system registered", function() + local t, launcher, radar = kubTemplate() + assert.is_not_nil(t) + + -- STEP 1: deploy a complete KUB so there is something to repair. + local crateLn = aaCrate(launcher) + aam:tryUnpackOrRepair(heli(), crateLn, { kub_ln = crateLn, kub_sr = aaCrate(radar) }, 500) + local before = 0 + for _ in pairs(aam._completeSystems) do before = before + 1 end + assert.equals(1, before) + + -- STEP 2: repair it with a repair crate (descriptor._repairFor = template name). + local repairCrate = aaCrate(nil, "KUB AA System") + local result + local fired = capture("OnAASystemRepaired", function() + result = aam:tryUnpackOrRepair(heli(), repairCrate, {}, 500) + end) + + assert.is_true(result) + assert.equals(1, #fired) + assert.equals("KUB AA System", fired[1].systemName) + assert.is_not_nil(fired[1].groupName) + assert.equals(coalition.side.BLUE, fired[1].coalition) + assert.is_not_nil(fired[1].timestamp) + + assert.is_true(repairCrate.destroyedCount >= 1) + + local after = 0 + for _ in pairs(aam._completeSystems) do after = after + 1 end + assert.equals(1, after) -- old system replaced, not duplicated + end) + + end) + +end) + +-- ============================================================ +-- F-012 / F-013 : CTLDFOBManager +-- ============================================================ +describe("CTLDFOBManager deploy + destroy", function() + + local fm + + -- Scene object stand-in with a mutable alive flag (drives getIntegrityPercent). + local function sceneObj(name) + local o = { _alive = true, getName = function() return name end } + o.isExist = function() return o._alive end + return o + end + + -- Minimal completed-scene stand-in: _registerDeployedFOB reads _params + _spawnedObjs. + -- No transportName β†’ the beacon branch (Unit.getByName / CTLDBeaconManager) is skipped. + local function scene(objs) + return { + _params = { + centroid = { x = 10, y = 0, z = 20 }, + coalitionId = coalition.side.BLUE, + countryId = country.id.USA, + player = "auto-unpack", + cratesUsed = { "c1", "c2" }, + }, + _spawnedObjs = objs, + _refHdgRad = 0, + } + end + + before_each(function() + fm = CTLDFOBManager.getInstance() + fm._fobs = {} + fm._objectToFOB = {} + fm._fobCount = 0 + end) + + -- ── F-012 : deploy ──────────────────────────────────────────────────────── + describe("_registerDeployedFOB (F-012)", function() + + it("publishes OnFOBDeployed with the FOB payload and crate count", function() + local fired = capture("OnFOBDeployed", function() + fm:_registerDeployedFOB(scene({ sceneObj("fobA"), sceneObj("fobB") })) + end) + assert.equals(1, #fired) + assert.equals("fob_001", fired[1].fob.fobId) + assert.equals("Deployed FOB #1", fired[1].fob.name) + assert.equals(2, fired[1].totalCratesUsed) + assert.equals(10, fired[1].position.x) + assert.is_not_nil(fired[1].logisticZone) + end) + + it("registers the FOB and its reverse object lookup", function() + fm:_registerDeployedFOB(scene({ sceneObj("fobA"), sceneObj("fobB") })) + assert.is_not_nil(fm._fobs["fob_001"]) + assert.equals("fob_001", fm._objectToFOB["fobA"]) + assert.equals("fob_001", fm._objectToFOB["fobB"]) + end) + + end) + + -- ── F-013 : integrity / destroy ─────────────────────────────────────────── + describe("onDead integrity + _destroyFOB (F-013)", function() + + it("destroys the FOB and cleans up when integrity drops below threshold", function() + local o1, o2 = sceneObj("deadA"), sceneObj("deadB") + fm:_registerDeployedFOB(scene({ o1, o2 })) + + -- Both scene objects die β†’ integrity 0/2 = 0 < (1 - threshold). + o1._alive, o2._alive = false, false + + local fired = capture("OnFOBDestroyed", function() + fm:onDead({ initiator = o1 }) + end) + + assert.equals(1, #fired) + assert.equals("fob_001", fired[1].fob.fobId) + assert.equals(0, fired[1].destruction.integrityPercent) + assert.is_not_nil(fired[1].durationAlive) + + assert.is_nil(fm._fobs["fob_001"]) -- unregistered + assert.is_nil(fm._objectToFOB["deadA"]) -- reverse lookup cleaned + assert.is_nil(fm._objectToFOB["deadB"]) + end) + + it("keeps the FOB alive when integrity is at/above threshold", function() + local o1, o2 = sceneObj("halfA"), sceneObj("halfB") + fm:_registerDeployedFOB(scene({ o1, o2 })) + + -- One object dies β†’ integrity 1/2 = 0.5, NOT below (1 - 0.5) = 0.5. + o1._alive = false + + local fired = capture("OnFOBDestroyed", function() + fm:onDead({ initiator = o1 }) + end) + + assert.equals(0, #fired) -- no destruction event + assert.is_not_nil(fm._fobs["fob_001"]) -- still registered + end) + + end) + +end) + +-- ============================================================ +-- F-099 : CTLDVehicleSpawner pack +-- ============================================================ +describe("CTLDVehicleSpawner pack (F-099)", function() + + local vs + local origUnitByName, origAddStatic, origGetByName, origSchedule + local scheduled + + local PACKABLE_TYPE = "M1043 HMMWV Armament" + + local function transport() + return { + _point = { x = 0, y = 5, z = 0 }, + isExist = function() return true end, + getName = function() return "Heli1" end, + getCoalition = function() return coalition.side.BLUE end, + getCountry = function() return country.id.USA end, + getTypeName = function() return "UH-1H" end, + getPoint = function(s) return s._point end, + getVelocity = function() return { x = 0, y = 0, z = 0 } end, + getDesc = function() return { box = { min = { x = -5, y = -2, z = -5 }, + max = { x = 5, y = 3, z = 5 } } } end, + -- north-facing (heading 0) + getPosition = function(s) return { x = { x = 1, y = 0, z = 0 }, + y = { x = 0, y = 1, z = 0 }, p = s._point } end, + } + end + + before_each(function() + vs = CTLDVehicleSpawner.getInstance() + vs._vehicles = {} + + origUnitByName = Unit.getByName + origAddStatic = coalition.addStaticObject + origGetByName = StaticObject.getByName + origSchedule = timer.scheduleFunction + + -- Capture deferred functions instead of running them synchronously. + scheduled = {} + timer.scheduleFunction = function(fn) scheduled[#scheduled + 1] = fn; return #scheduled end + + -- Track spawned statics so StaticObject.getByName can hand back a live handle. + local spawned = {} + coalition.addStaticObject = function(_cId, data) + spawned[data.name] = true + return { getName = function() return data.name end } + end + StaticObject.getByName = function(name) + return spawned[name] and { _name = name } or nil + end + vs._spawnedStatics = spawned -- exposed for assertions + end) + + after_each(function() + Unit.getByName = origUnitByName + coalition.addStaticObject = origAddStatic + StaticObject.getByName = origGetByName + timer.scheduleFunction = origSchedule + end) + + -- ── findPackableVehicles ─────────────────────────────────────────────────── + describe("findPackableVehicles", function() + + local function registerWaiting(unitName) + vs._vehicles["veh_1"] = CTLDVehicle:new({ + id = "veh_1", + vehicleType = PACKABLE_TYPE, + spawnData = { unitName = unitName }, + }) + end + + it("returns a WAITING CTLD vehicle in range with its descriptor", function() + registerWaiting("HumveeMG1") + Unit.getByName = function(name) + if name == "HumveeMG1" then + return { + isExist = function() return true end, + getName = function() return "HumveeMG1" end, + getTypeName = function() return PACKABLE_TYPE end, + getPoint = function() return { x = 10, y = 0, z = 10 } end, + } + end + return nil + end + + local packable = vs:findPackableVehicles(transport()) + assert.equals(1, #packable) + assert.equals("HumveeMG1", packable[1].unitName) + assert.is_not_nil(packable[1].descriptor) + assert.equals(PACKABLE_TYPE, packable[1].descriptor.unit) + end) + + it("ignores a vehicle outside the pack search radius", function() + registerWaiting("FarHumvee") + Unit.getByName = function(name) + if name == "FarHumvee" then + return { + isExist = function() return true end, + getName = function() return "FarHumvee" end, + getTypeName = function() return PACKABLE_TYPE end, + getPoint = function() return { x = 5000, y = 0, z = 5000 } end, + } + end + return nil + end + + assert.equals(0, #vs:findPackableVehicles(transport())) + end) + + end) + + -- ── packVehicle ──────────────────────────────────────────────────────────── + describe("packVehicle", function() + + local function packableVehicle(destroyedRef) + return { + isExist = function() return not destroyedRef.done end, + getName = function() return "HumveeMG1" end, + getTypeName = function() return PACKABLE_TYPE end, + getPoint = function() return { x = 3, y = 0, z = 3 } end, + getGroup = function() return nil end, -- skip JTAC deregister + destroy = function() destroyedRef.done = true end, + } + end + + it("destroys the vehicle and publishes OnVehiclePacked with the crate count", function() + local t = transport() + local ref = { done = false } + local veh = packableVehicle(ref) + Unit.getByName = function(name) + if name == "Heli1" then return t end + if name == "HumveeMG1" then return veh end + return nil + end + + local descriptor = CTLDCrateManager.getInstance():findDescriptorByUnitType(PACKABLE_TYPE) + assert.is_not_nil(descriptor) + local expected = descriptor.cratesRequired or 1 + + local packed, spawnedCrates + capture("OnCrateSpawned", function() + spawnedCrates = 0 + local ed = EventDispatcher.getInstance() + local cb = function() spawnedCrates = spawnedCrates + 1 end + ed:subscribe("OnCrateSpawned", cb) + packed = capture("OnVehiclePacked", function() + vs:packVehicle("Heli1", "HumveeMG1", { unitName = "Heli1", groupId = 1, + coalition = coalition.side.BLUE }) + end) + ed:unsubscribe("OnCrateSpawned", cb) + end) + + assert.is_true(ref.done) -- vehicle unit destroyed + assert.equals(1, #packed) + assert.equals(PACKABLE_TYPE, packed[1].vehicleType) + assert.equals(expected, packed[1].cratesSpawned) + assert.equals(expected, spawnedCrates) -- one OnCrateSpawned per crate + + -- one DCS static per crate + local n = 0 + for _ in pairs(vs._spawnedStatics) do n = n + 1 end + assert.equals(expected, n) + end) + + it("defers a Pack-menu refresh for the transport unit", function() + local t = transport() + local ref = { done = false } + local veh = packableVehicle(ref) + Unit.getByName = function(name) + if name == "Heli1" then return t end + if name == "HumveeMG1" then return veh end + return nil + end + + vs:packVehicle("Heli1", "HumveeMG1", { unitName = "Heli1", groupId = 1, + coalition = coalition.side.BLUE }) + + -- Spy refreshForUnit, then run every deferred function captured above. + local pm = CTLDPlayerManager.getInstance() + local refreshed = {} + pm.refreshForUnit = function(_self, n) refreshed[#refreshed + 1] = n end + for _, fn in ipairs(scheduled) do pcall(fn) end + pm.refreshForUnit = nil -- drop the instance shadow, class method resurfaces + + assert.is_true(#scheduled >= 1) + assert.equals("Heli1", refreshed[1]) + end) + + it("guards against packing a vehicle that no longer exists", function() + local t = transport() + Unit.getByName = function(name) + if name == "Heli1" then return t end + if name == "Ghost" then + return { + isExist = function() return false end, + getName = function() return "Ghost" end, + getTypeName = function() return PACKABLE_TYPE end, + getPoint = function() return { x = 0, y = 0, z = 0 } end, + destroy = function() end, + } + end + return nil + end + + local packed = capture("OnVehiclePacked", function() + vs:packVehicle("Heli1", "Ghost", { unitName = "Heli1", groupId = 1, + coalition = coalition.side.BLUE }) + end) + assert.equals(0, #packed) -- no packing happened + end) + + end) + +end) From 437d036370954fd3c86b60c1f464443046b1263c Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sat, 11 Jul 2026 16:53:36 +0200 Subject: [PATCH 09/12] test(ci): re-integrate menu gating + player event wiring (ticket 05) buildMenu F10 section gating by config flag / transport capability (F-048/049/ 050/052/053/054/055/056), clearBranch+repopulate (F-075), refreshMenuForGroup unknown-group failure shape (F-077), _loadUserConfig ingestion (F-088), troop Embark filter disabled/side/capacity (F-089), and player OnVehicleLoaded/ Unloaded -> loadedVehicles wiring (F-025). Deviations recomputed against current src (not the relic intent): FOBs is now a top-level "FOBs List" section, "Pack Vehicle" -> "Pack Equipt" with a combined enableFARPRepack/enablePackingVehicles gate, CTLDTroopManager:buildMenu gone -> buildMenuSection/refreshMenuSection. Co-Authored-By: Claude Opus 4.8 --- .../tickets/05-menu-gating-player-wiring.md | 2 +- tests/ci/unit/menu_gating_spec.lua | 474 ++++++++++++++++++ 2 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 tests/ci/unit/menu_gating_spec.lua diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/05-menu-gating-player-wiring.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/05-menu-gating-player-wiring.md index 4ccf895..677c1a9 100644 --- a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/05-menu-gating-player-wiring.md +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/05-menu-gating-player-wiring.md @@ -1,6 +1,6 @@ # 05 β€” Menu gating by config + player event wiring -Status: todo +Status: βœ… done Type: AFK ## What to build diff --git a/tests/ci/unit/menu_gating_spec.lua b/tests/ci/unit/menu_gating_spec.lua new file mode 100644 index 0000000..8d5a4c8 --- /dev/null +++ b/tests/ci/unit/menu_gating_spec.lua @@ -0,0 +1,474 @@ +---@diagnostic disable +-- tests/ci/unit/menu_gating_spec.lua +-- busted specs for F10 menu section gating (config flags + unit capabilities) +-- and the CTLDPlayerManager cargo-event wiring. +-- +-- Re-integrates coverage that previously lived ONLY in the dead FullGas DCS +-- relics (tests/dcs/noPlayer/F-0NN_*.lua). The relic *intent* is preserved; node +-- names / gates were re-verified against the CURRENT src (several were refactored): +-- F-048 buildMenu all flags ON β†’ every section present +-- F-049 enableCrates=false β†’ Request Equipment + Crate Commands absent +-- F-050 enabledRadioBeaconDrop=false β†’ Radio Beacons absent +-- F-052 JTAC_jtacStatusF10=false β†’ JTAC absent +-- F-053 enabledFOBBuilding=false β†’ FOBs List absent (was "List FOBs") +-- F-054 packing flags off β†’ Pack Equipt absent (was "Pack Vehicle") +-- F-055 non-transport player β†’ only root + Check Cargo; RECON/JTAC present +-- F-056 canCarryVehicles=true β†’ Vehicle Commands present +-- F-075 clearBranch + repopulate β†’ new commands present, old absent +-- F-077 refreshMenuForGroup unknown group β†’ {success=false, refreshedCount=0} +-- F-088 _loadUserConfig ingests customLoadableGroups + disableLoadableGroups +-- F-089 troop Embark menu filters by disabled / side / capacity +-- F-025 OnVehicleLoaded/Unloaded β†’ CTLDPlayer.loadedVehicles maintained +-- +-- Menu inspection uses the same mechanism as troop_multi_spec: +-- pm:buildMenu(playerObj) β†’ ctld.MenuManager:getMenuByGroupId(gid) β†’ menu:_getNode(path) +-- (memory-model lookup; independent of DCS rendering / enabled flag). +-- ============================================================ + +local tr = ctld.tr + +describe("F10 menu gating (config + capability) + player-manager wiring", function() + + -- ── Config save / restore (toggle a flag via CTLDConfig settings) ──────── + local _changed + before_each(function() _changed = {} end) + after_each(function() + for k, box in pairs(_changed) do + CTLDConfig.get().settings[k] = box.orig + end + _changed = {} + end) + local function setCfg(k, v) + if _changed[k] == nil then + _changed[k] = { orig = CTLDConfig.get().settings[k] } + end + CTLDConfig.get().settings[k] = v + end + + -- ── Singleton reset + full re-instantiation (so every section re-registers) ── + local function resetSingletons() + ctld.MenuManager._instance = nil + CTLDPlayerManager._instance = nil + CTLDTroopManager._instance = nil + CTLDCrateManager._instance = nil + CTLDVehicleSpawner._instance = nil + CTLDFOBManager._instance = nil + CTLDBeaconManager._instance = nil + CTLDReconManager._instance = nil + CTLDJTACManager._instance = nil + CTLDZoneManager._instance = nil + EventDispatcher._instance = nil + CTLDDCSEventBridge._instance = nil + end + + -- Instantiate every manager: PlayerManager first (owns _menuSections), then + -- each contributor registers its section during getInstance()/init(). + local function instantiateAll() + EventDispatcher.getInstance() + CTLDDCSEventBridge.getInstance() + CTLDZoneManager.getInstance() + CTLDPlayerManager.getInstance() + CTLDTroopManager.getInstance() + CTLDVehicleSpawner.getInstance() + CTLDCrateManager.getInstance() + CTLDFOBManager.getInstance() + CTLDBeaconManager.getInstance() + CTLDReconManager.getInstance() + CTLDJTACManager.getInstance() + end + + local function makePlayer(opts) + opts = opts or {} + return { + unitName = opts.unitName or "UH-1H-1", + groupId = opts.groupId or 9901, + groupName = opts.groupName or "Grp_gate", + coalition = opts.coalition or coalition.side.BLUE, + typeName = opts.typeName or "UH-1H", + isTransport = (opts.isTransport ~= false), + canCarryVehicles = opts.canCarryVehicles or false, + } + end + + -- Build the full CTLD menu for playerObj and return its ctld.Menu model. + local function buildFullMenu(playerObj) + resetSingletons() + instantiateAll() + CTLDPlayerManager.getInstance():buildMenu(playerObj) + return ctld.MenuManager:getInstance():getMenuByGroupId(playerObj.groupId) + end + + local ROOT = tr("CTLD") + local function has(menu, path) return menu and menu:_getNode(path) ~= nil end + + -- ── F-048 : every gate ON β†’ every section present ──────────────────────── + describe("F-048 β€” all flags ON, transport heli β†’ every section present", function() + -- Default config already has all these flags true; UH-1H caps has + -- troopsEnabled + cratesEnabled. Build with defaults, assert presence. + local menu + before_each(function() + menu = buildFullMenu(makePlayer({ canCarryVehicles = false })) + end) + + it("root CTLD submenu present", function() assert.is_true(has(menu, { ROOT })) end) + it("Troop Commands present", function() assert.is_true(has(menu, { ROOT, tr("Troop Commands") })) end) + it("Request Equipment present", function() assert.is_true(has(menu, { ROOT, tr("Request Equipment") })) end) + it("Crate Commands present", function() assert.is_true(has(menu, { ROOT, tr("Crate Commands") })) end) + it("Smoke present", function() assert.is_true(has(menu, { ROOT, tr("Smoke") })) end) + it("Radio Beacons present", function() assert.is_true(has(menu, { ROOT, tr("Radio Beacons") })) end) + it("FOBs List present", function() assert.is_true(has(menu, { ROOT, tr("FOBs List") })) end) + it("RECON present", function() assert.is_true(has(menu, { ROOT, tr("RECON") })) end) + it("JTAC present", function() assert.is_true(has(menu, { ROOT, tr("JTAC") })) end) + it("Vehicle Commands absent (canCarryVehicles=false)", + function() assert.is_false(has(menu, { ROOT, tr("Vehicle Commands") })) end) + end) + + -- ── F-049 : enableCrates=false β†’ crate sections gone, others stay ──────── + describe("F-049 β€” enableCrates=false β†’ Request Equipment + Crate Commands absent", function() + local menu + before_each(function() + setCfg("enableCrates", false) + menu = buildFullMenu(makePlayer()) + end) + + it("Request Equipment absent", function() assert.is_false(has(menu, { ROOT, tr("Request Equipment") })) end) + it("Crate Commands absent", function() assert.is_false(has(menu, { ROOT, tr("Crate Commands") })) end) + it("Smoke still present (enableSmokeDrop=true)", function() assert.is_true(has(menu, { ROOT, tr("Smoke") })) end) + it("Radio Beacons still present", function() assert.is_true(has(menu, { ROOT, tr("Radio Beacons") })) end) + it("Troop Commands still present (not gated by crates)", function() assert.is_true(has(menu, { ROOT, tr("Troop Commands") })) end) + end) + + -- ── F-050 : enabledRadioBeaconDrop=false β†’ Radio Beacons gone ──────────── + describe("F-050 β€” enabledRadioBeaconDrop=false β†’ Radio Beacons absent", function() + local menu + before_each(function() + setCfg("enabledRadioBeaconDrop", false) + menu = buildFullMenu(makePlayer()) + end) + + it("Radio Beacons absent", function() assert.is_false(has(menu, { ROOT, tr("Radio Beacons") })) end) + it("Smoke still present", function() assert.is_true(has(menu, { ROOT, tr("Smoke") })) end) + it("Crate Commands still present", function() assert.is_true(has(menu, { ROOT, tr("Crate Commands") })) end) + end) + + -- ── F-052 : JTAC_jtacStatusF10=false β†’ JTAC gone ───────────────────────── + describe("F-052 β€” JTAC_jtacStatusF10=false β†’ JTAC absent", function() + local menu + before_each(function() + setCfg("JTAC_jtacStatusF10", false) + menu = buildFullMenu(makePlayer()) + end) + + it("JTAC absent", function() assert.is_false(has(menu, { ROOT, tr("JTAC") })) end) + it("RECON still present", function() assert.is_true(has(menu, { ROOT, tr("RECON") })) end) + it("Troop Commands still present", function() assert.is_true(has(menu, { ROOT, tr("Troop Commands") })) end) + end) + + -- ── F-053 : enabledFOBBuilding=false β†’ FOBs List gone ──────────────────── + -- Relic said "List FOBs absent from Crate Commands"; the current build exposes + -- FOBs as a TOP-LEVEL section "FOBs List" (configKey enabledFOBBuilding, order 55), + -- no longer nested under Crate Commands. Assertion adapted to the real node. + describe("F-053 β€” enabledFOBBuilding=false β†’ FOBs List absent", function() + it("FOBs List present when enabledFOBBuilding=true (default)", function() + local menu = buildFullMenu(makePlayer()) + assert.is_true(has(menu, { ROOT, tr("FOBs List") })) + end) + it("FOBs List absent when enabledFOBBuilding=false", function() + setCfg("enabledFOBBuilding", false) + local menu = buildFullMenu(makePlayer()) + assert.is_false(has(menu, { ROOT, tr("FOBs List") })) + end) + it("Crate Commands still present when FOB disabled", function() + setCfg("enabledFOBBuilding", false) + local menu = buildFullMenu(makePlayer()) + assert.is_true(has(menu, { ROOT, tr("Crate Commands") })) + end) + end) + + -- ── F-054 : packing flags off β†’ Pack Equipt gone ───────────────────────── + -- Relic node "Pack Vehicle" was renamed "Pack Equipt" and its gate broadened: + -- the sub-menu is created when (enableFARPRepack OR enablePackingVehicles) is + -- true AND the unit type has cratesEnabled. Assertions adapted to that combined + -- gate rather than the legacy single flag. + describe("F-054 β€” Pack Equipt visibility follows packing gates", function() + it("Pack Equipt present when enablePackingVehicles=true (FARP off)", function() + setCfg("enableFARPRepack", false) + setCfg("enablePackingVehicles", true) + local menu = buildFullMenu(makePlayer()) + assert.is_true(has(menu, { ROOT, tr("Crate Commands"), tr("Pack Equipt") })) + end) + it("Pack Equipt absent when both packing gates are false", function() + setCfg("enableFARPRepack", false) + setCfg("enablePackingVehicles", false) + local menu = buildFullMenu(makePlayer()) + assert.is_false(has(menu, { ROOT, tr("Crate Commands"), tr("Pack Equipt") })) + end) + end) + + -- ── F-055 : non-transport player β†’ only root + Check Cargo ─────────────── + describe("F-055 β€” non-transport player β†’ transport sections absent", function() + local menu + before_each(function() + menu = buildFullMenu(makePlayer({ + unitName = "F-16C-1", typeName = "F-16C bl.52d", + isTransport = false, canCarryVehicles = false, + })) + end) + + it("root CTLD present", function() assert.is_true(has(menu, { ROOT })) end) + it("Check Cargo present at root", function() assert.is_true(has(menu, { ROOT, tr("Check Cargo") })) end) + it("Troop Commands absent", function() assert.is_false(has(menu, { ROOT, tr("Troop Commands") })) end) + it("Request Equipment absent", function() assert.is_false(has(menu, { ROOT, tr("Request Equipment") })) end) + it("Crate Commands absent", function() assert.is_false(has(menu, { ROOT, tr("Crate Commands") })) end) + it("Smoke absent", function() assert.is_false(has(menu, { ROOT, tr("Smoke") })) end) + it("Radio Beacons absent", function() assert.is_false(has(menu, { ROOT, tr("Radio Beacons") })) end) + it("RECON present (no isTransport guard)", function() assert.is_true(has(menu, { ROOT, tr("RECON") })) end) + it("JTAC present (no isTransport guard)", function() assert.is_true(has(menu, { ROOT, tr("JTAC") })) end) + end) + + -- ── F-056 : canCarryVehicles=true β†’ Vehicle Commands present ───────────── + describe("F-056 β€” canCarryVehicles=true β†’ Vehicle Commands present", function() + it("Vehicle Commands present when canCarryVehicles=true", function() + local menu = buildFullMenu(makePlayer({ canCarryVehicles = true })) + assert.is_true(has(menu, { ROOT, tr("Vehicle Commands") })) + end) + it("Vehicle Commands absent when canCarryVehicles=false", function() + local menu = buildFullMenu(makePlayer({ canCarryVehicles = false })) + assert.is_false(has(menu, { ROOT, tr("Vehicle Commands") })) + end) + end) + + -- ── F-075 : clearBranch + repopulate (menu model dynamic-refresh contract) ─ + describe("F-075 β€” clearBranch empties a branch; repopulation swaps content", function() + local menu + before_each(function() + ctld.MenuManager._instance = nil + local mm = ctld.MenuManager:getInstance() + menu = mm:createMenuForGroup(7777) + menu:addSubMenu({}, "Root") + menu:addCommand({ "Root" }, "OldCmd", function() end, {}) + end) + + it("old command present before clearBranch", function() + assert.is_true(has(menu, { "Root", "OldCmd" })) + end) + it("clearBranch removes the old command", function() + menu:clearBranch({ "Root" }) + assert.is_false(has(menu, { "Root", "OldCmd" })) + end) + it("container survives clearBranch", function() + menu:clearBranch({ "Root" }) + assert.is_true(has(menu, { "Root" })) + end) + it("repopulation exposes the new command, not the old one", function() + menu:clearBranch({ "Root" }) + menu:addCommand({ "Root" }, "NewCmd", function() end, {}) + assert.is_true(has(menu, { "Root", "NewCmd" })) + assert.is_false(has(menu, { "Root", "OldCmd" })) + end) + end) + + -- ── F-077 : refreshMenuForGroup on an unknown group β†’ structured failure ─ + describe("F-077 β€” refreshMenuForGroup unknown group β†’ failure result", function() + local res + before_each(function() + ctld.MenuManager._instance = nil + res = ctld.MenuManager:getInstance():refreshMenuForGroup(424242) + end) + + it("success == false", function() assert.is_false(res.success) end) + it("refreshedCount == 0", function() assert.equals(0, res.refreshedCount) end) + it("message is a string", function() assert.is_string(res.message) end) + end) + + -- ── F-088 : _loadUserConfig ingests custom + disabled loadable groups ──── + describe("F-088 β€” _loadUserConfig ingests ctld_config_user", function() + local mgr, _origUserCfg + before_each(function() + _origUserCfg = ctld_config_user + setCfg("loadableGroups", { + { name = "Standard Group", inf = 6, mg = 2, at = 2 }, + { name = "Mortar Squad", mortar = 6 }, + { name = "JTAC Group", inf = 4, jtac = 1 }, + }) + setCfg("transportLimitByType", {}) + ctld_config_user = { + customLoadableGroups = { + { name = "Recon Team", composition = { inf = 4, jtac = 1 }, side = coalition.side.BLUE }, + { name = "AT Squad", composition = { inf = 2, at = 6 }, side = coalition.side.RED }, + { name = "Support Heavy", composition = { inf = 8, mg = 4, aa = 2 } }, + }, + disableLoadableGroups = { "Mortar Squad", "Standard Group" }, + } + resetSingletons() + CTLDPlayerManager.getInstance() + mgr = CTLDTroopManager.getInstance() -- init() runs _loadUserConfig + end) + after_each(function() + ctld_config_user = _origUserCfg + end) + + it("6 templates total (3 standard + 3 custom)", function() + assert.equals(6, #mgr._templates) + assert.equals(6, mgr._templateCount) + end) + it("Recon Team created with total=5, custom, side=BLUE", function() + local r = mgr:_findTemplate("Recon Team") + assert.is_not_nil(r) + assert.equals(5, r.total) + assert.is_true(r.custom) + assert.equals(coalition.side.BLUE, r.side) + end) + it("AT Squad created with total=8, side=RED", function() + local at = mgr:_findTemplate("AT Squad") + assert.is_not_nil(at) + assert.equals(8, at.total) + assert.equals(coalition.side.RED, at.side) + end) + it("Support Heavy created with total=14, side=nil (both)", function() + local sh = mgr:_findTemplate("Support Heavy") + assert.is_not_nil(sh) + assert.equals(14, sh.total) + assert.is_nil(sh.side) + end) + it("Mortar Squad + Standard Group flagged disabled", function() + assert.is_true(mgr:_findTemplate("Mortar Squad").disabled) + assert.is_true(mgr:_findTemplate("Standard Group").disabled) + end) + it("JTAC Group stays enabled", function() + assert.is_false(mgr:_findTemplate("JTAC Group").disabled) + end) + end) + + -- ── F-089 : troop Embark menu filters by disabled / side / capacity ────── + describe("F-089 β€” Embark troop menu filters disabled / side / capacity", function() + local menu, playerObj + local _origGetByName, _origInAir + + before_each(function() + resetSingletons() + EventDispatcher.getInstance() + CTLDDCSEventBridge.getInstance() + CTLDZoneManager.getInstance() + CTLDPlayerManager.getInstance() + local tm = CTLDTroopManager.getInstance() + + -- Controlled template set (UH-1H capacity = 8 via capabilitiesByType). + tm._templates = {} + tm:createLoadableGroup({ name = "Standard Group", composition = { inf = 4, mg = 1, at = 1 } }) -- 6 ≀ 8, both + tm:createLoadableGroup({ name = "BLUE Recon", composition = { inf = 4, jtac = 1 }, side = coalition.side.BLUE }) -- 5, BLUE + tm:createLoadableGroup({ name = "RED AT", composition = { inf = 2, at = 6 }, side = coalition.side.RED }) -- 8, RED + tm:createLoadableGroup({ name = "Big Squad", composition = { inf = 20 } }) -- 20 > 8 + tm:createLoadableGroup({ name = "Hidden", composition = { inf = 3 } }) + tm:disableLoadableGroup("Hidden") + + -- On-ground stubs. + tm._isInAir = function() return false end + tm._findAllNearbyDropped = function() return {} end + + -- Zone manager: one BLUE pickup zone the player is inside, infinite stock. + local zm = CTLDZoneManager.getInstance() + zm.getTroopZonesForCoalition = function() + return { { + zoneName = "Z1", + pickMaxStock = 0, -- 0 β†’ infinite (see refreshMenuSection) + pickCurrentStock = 0, + hasPickup = function() return true end, + isInZone = function() return true end, + } } + end + + _origGetByName = Unit.getByName + Unit.getByName = function(n) + if n == "BLUE_UH1H_1" then + return { + getName = function() return "BLUE_UH1H_1" end, + isExist = function() return true end, + getPoint = function() return { x = 0, y = 0, z = 0 } end, + } + end + return _origGetByName and _origGetByName(n) or nil + end + + playerObj = makePlayer({ + unitName = "BLUE_UH1H_1", coalition = coalition.side.BLUE, typeName = "UH-1H", + }) + CTLDPlayerManager.getInstance():buildMenu(playerObj) + menu = ctld.MenuManager:getInstance():getMenuByGroupId(playerObj.groupId) + end) + + after_each(function() + Unit.getByName = _origGetByName + end) + + local function loadPath(name) + return { ROOT, tr("Troop Commands"), tr("Embark / Extract Troops"), + tr("Load from %1", "TRZ_Z1"), tr("Load ") .. name } + end + + it("Standard Group present (6 ≀ cap 8, no side restriction)", function() + assert.is_true(has(menu, loadPath("Standard Group"))) + end) + it("BLUE Recon present (5 ≀ cap, side=BLUE matches)", function() + assert.is_true(has(menu, loadPath("BLUE Recon"))) + end) + it("RED AT absent (side=RED, player is BLUE)", function() + assert.is_false(has(menu, loadPath("RED AT"))) + end) + it("Big Squad absent (20 > cap 8)", function() + assert.is_false(has(menu, loadPath("Big Squad"))) + end) + it("Hidden absent (disabled)", function() + assert.is_false(has(menu, loadPath("Hidden"))) + end) + end) + + -- ── F-025 : OnVehicleLoaded/Unloaded β†’ CTLDPlayer.loadedVehicles ───────── + describe("F-025 β€” player-manager maintains loadedVehicles on vehicle events", function() + local pm, playerObj, transport, veh + + before_each(function() + resetSingletons() + EventDispatcher.getInstance() + CTLDDCSEventBridge.getInstance() + pm = CTLDPlayerManager.getInstance() -- init() subscribes the cargo events + + playerObj = CTLDPlayer:new({ + unitName = "UH-1H-1", groupId = 9901, groupName = "Grp_F25", + coalition = coalition.side.BLUE, typeName = "UH-1H", isTransport = true, + }) + pm._players["UH-1H-1"] = playerObj + + transport = { getName = function() return "UH-1H-1" end, isExist = function() return true end } + veh = { id = "veh_F25", vehicleType = "M1045 HMMWV TOW" } + end) + + it("loadedVehicles starts empty", function() + assert.equals(0, #playerObj.loadedVehicles) + end) + + it("OnVehicleLoaded appends the ctldVehicleObject", function() + EventDispatcher.getInstance():publish("OnVehicleLoaded", { + transportUnitObject = transport, ctldVehicleObject = veh, + }) + assert.equals(1, #playerObj.loadedVehicles) + assert.equals(veh, playerObj.loadedVehicles[1]) + end) + + it("OnVehicleUnloaded removes it again", function() + local ed = EventDispatcher.getInstance() + ed:publish("OnVehicleLoaded", { transportUnitObject = transport, ctldVehicleObject = veh }) + ed:publish("OnVehicleUnloaded", { transportUnitObject = transport, ctldVehicleObject = veh }) + assert.equals(0, #playerObj.loadedVehicles) + end) + + it("event for an untracked unit is ignored (no error)", function() + local other = { getName = function() return "GHOST-9" end, isExist = function() return true end } + EventDispatcher.getInstance():publish("OnVehicleLoaded", { + transportUnitObject = other, ctldVehicleObject = veh, + }) + assert.equals(0, #playerObj.loadedVehicles) + end) + end) + +end) From 990c0cc58c2ec3a774b26b175983c2f7d92dbfab Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sat, 11 Jul 2026 17:06:39 +0200 Subject: [PATCH 10/12] =?UTF-8?q?fix(ci):=20menu=5Fgating=5Fspec=20?= =?UTF-8?q?=E2=80=94=20busted=20insulation=20+=20vehicle-section=20unit=20?= =?UTF-8?q?mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI-only failures the local harness could not surface: - F-088 (_loadUserConfig): busted insulates each describe's environment, so a bare `ctld_config_user = {...}` landed in the sandbox while _loadUserConfig reads the real _G global β€” it saw nil and created no custom groups (3 templates instead of 6). Write/read/restore via _G.ctld_config_user explicitly. - F-056 (canCarryVehicles): buildFullMenu now stubs Unit.getByName for the build, so the vehicle section's findLoadableVehicles gets a real unit object (getTypeName/getCoalition/getPoint) instead of crashing on nil. Restored immediately after the build to avoid polluting other specs. Co-Authored-By: Claude Opus 4.8 --- tests/ci/unit/menu_gating_spec.lua | 34 ++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/tests/ci/unit/menu_gating_spec.lua b/tests/ci/unit/menu_gating_spec.lua index 8d5a4c8..cc5c9d6 100644 --- a/tests/ci/unit/menu_gating_spec.lua +++ b/tests/ci/unit/menu_gating_spec.lua @@ -91,10 +91,33 @@ describe("F10 menu gating (config + capability) + player-manager wiring", functi end -- Build the full CTLD menu for playerObj and return its ctld.Menu model. + -- The vehicle section calls findLoadableVehicles(transport), which needs a real DCS + -- unit object (getTypeName/getCoalition/getPoint). Stub Unit.getByName for the duration + -- of the build only β€” the menu model is fully built by the time buildMenu returns, so + -- node lookups afterwards don't need it, and we restore immediately to avoid polluting + -- other specs in the shared busted process. local function buildFullMenu(playerObj) resetSingletons() instantiateAll() - CTLDPlayerManager.getInstance():buildMenu(playerObj) + local origGBN = Unit.getByName + Unit.getByName = function(n) + if n == playerObj.unitName then + return { + getName = function() return playerObj.unitName end, + getTypeName = function() return playerObj.typeName end, + getCoalition = function() return playerObj.coalition end, + getPoint = function() return { x = 0, y = 0, z = 0 } end, + getPlayerName = function() return "tester" end, + isExist = function() return true end, + } + end + return origGBN(n) + end + local ok, err = pcall(function() + CTLDPlayerManager.getInstance():buildMenu(playerObj) + end) + Unit.getByName = origGBN + assert(ok, err) return ctld.MenuManager:getInstance():getMenuByGroupId(playerObj.groupId) end @@ -285,14 +308,17 @@ describe("F10 menu gating (config + capability) + player-manager wiring", functi describe("F-088 β€” _loadUserConfig ingests ctld_config_user", function() local mgr, _origUserCfg before_each(function() - _origUserCfg = ctld_config_user + -- Write to _G explicitly: busted insulates each describe's environment, so a bare + -- `ctld_config_user = {...}` would land in the sandbox and _loadUserConfig (which + -- reads the real _G global) would see nil β€” the custom groups would never load. + _origUserCfg = _G.ctld_config_user setCfg("loadableGroups", { { name = "Standard Group", inf = 6, mg = 2, at = 2 }, { name = "Mortar Squad", mortar = 6 }, { name = "JTAC Group", inf = 4, jtac = 1 }, }) setCfg("transportLimitByType", {}) - ctld_config_user = { + _G.ctld_config_user = { customLoadableGroups = { { name = "Recon Team", composition = { inf = 4, jtac = 1 }, side = coalition.side.BLUE }, { name = "AT Squad", composition = { inf = 2, at = 6 }, side = coalition.side.RED }, @@ -305,7 +331,7 @@ describe("F10 menu gating (config + capability) + player-manager wiring", functi mgr = CTLDTroopManager.getInstance() -- init() runs _loadUserConfig end) after_each(function() - ctld_config_user = _origUserCfg + _G.ctld_config_user = _origUserCfg end) it("6 templates total (3 standard + 3 custom)", function() From 4c372e0f7fef45eabfea78a2e64077480b599f2f Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sun, 12 Jul 2026 19:25:47 +0200 Subject: [PATCH 11/12] =?UTF-8?q?test(dcs):=20re-integrate=20live-DCS=20co?= =?UTF-8?q?verage=20=E2=80=94=20beacon/recon/vehicle=20(ticket=2007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tagged live-DCS scenarios for the relics that need the real engine (spawn, world events) and can't be busted-mocked: - scenario_beacon_lifecycle (F-006/007/092): dropBeacon spawn + OnBeaconDropped, removeClosestBeacon + OnBeaconRemoved, FOB beacon infinite battery. - scenario_recon_scan_payload (F-009): scan() OnReconScan payload + marks. - scenario_vehicle_dcs_native (F-018/019): loadVehicle/unloadVehicle dcs_native. Parity fixes vs the stale relics: in dcs_native the unit stays alive (relic expected unit==nil), unload -> WAITING, recon payload exposes totalTargetsDetected. Validated live against Test_CTLDNEXT_01: BCN 30/30, RCN 10/10, VEH 22/22. Also migrates the runner auth to Authorization: Bearer (dcs-bridge LOT-018; /api/exec now requires the SUPERUSER role). Older X-API-Key is rejected. Co-Authored-By: Claude Opus 4.8 --- .../tickets/07-live-dcs-coverage.md | 2 +- .../noPlayer/scenario_beacon_lifecycle.lua | 256 ++++++++++++++++++ .../noPlayer/scenario_recon_scan_payload.lua | 223 +++++++++++++++ .../noPlayer/scenario_vehicle_dcs_native.lua | 226 ++++++++++++++++ tools/integration-runner/run_scenarios.py | 6 +- 5 files changed, 711 insertions(+), 2 deletions(-) create mode 100644 tests/dcs/noPlayer/scenario_beacon_lifecycle.lua create mode 100644 tests/dcs/noPlayer/scenario_recon_scan_payload.lua create mode 100644 tests/dcs/noPlayer/scenario_vehicle_dcs_native.lua diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/07-live-dcs-coverage.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/07-live-dcs-coverage.md index 88b41ed..8a8ab2b 100644 --- a/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/07-live-dcs-coverage.md +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/07-live-dcs-coverage.md @@ -1,6 +1,6 @@ # 07 β€” Live-DCS coverage (real engine required) -Status: todo +Status: βœ… done Type: hybrid (AFK authoring + live DCS validation) ## What to build diff --git a/tests/dcs/noPlayer/scenario_beacon_lifecycle.lua b/tests/dcs/noPlayer/scenario_beacon_lifecycle.lua new file mode 100644 index 0000000..d05062f --- /dev/null +++ b/tests/dcs/noPlayer/scenario_beacon_lifecycle.lua @@ -0,0 +1,256 @@ +---@diagnostic disable +-- @tier: auto +-- ============================================================================= +-- AUTO β€” Beacon lifecycle : dropBeacon / removeClosestBeacon / FOB beacon +-- ============================================================================= +-- RΓ©-intΓ¨gre 3 reliques mortes qui exigent le VRAI moteur DCS (spawn rΓ©el de +-- 3 groupes TACAN_beacon + Group.getByName), impossibles Γ  mocker en busted : +-- +-- F-006 : dropBeacon (non-FOB) β†’ 3 unitΓ©s DCS spawnΓ©es, 3 frΓ©quences assignΓ©es, +-- batterie temporaire (batteryEndTime != -1), event OnBeaconDropped. +-- F-007 : removeClosestBeacon (< 500 m) β†’ beacon retirΓ© de _beacons, +-- event OnBeaconRemoved avec reason="manual" + frequenciesFreed. +-- F-092 : dropBeacon(isFOB=true, overridePosition=centroid) β†’ beacon spawnΓ© +-- au centroid exact (pas sous l'hΓ©lico), batterie infinie (=-1). +-- +-- Le transport est un mock (fournit position/coalition/pays) : la partie NON +-- mockable β€” le spawn rΓ©el des unitΓ©s beacon et leur comptage via Group.getByName +-- β€” passe par le vrai moteur. RΓ©solution 100 % synchrone β†’ tier `auto`. +-- +-- PrΓ©-requis mission : AUCUN (le transport est mockΓ©, les beacons sont spawnΓ©s +-- par le scΓ©nario ; ancre positionnelle dΓ©rivΓ©e d'une unitΓ© existante ou (0,0)). +-- +-- Signatures vΓ©rifiΓ©es dans src/CTLD_beacon.lua (2026-07-11) : +-- dropBeacon(transport, player, isFOB, overridePosition) l.324 +-- publish OnBeaconDropped {player, coalition, beacon, timestamp} l.410 +-- isFOB β†’ batteryEndTime = -1 (batterie infinie) l.376 +-- removeClosestBeacon(transport, player) l.424 (rayon 500 m) +-- publish OnBeaconRemoved {reason="manual", frequenciesFreed, ...} l.453 +-- ============================================================================= + +-- ── 1. CTLD-ready guard ────────────────────────────────────────────────────── +if not ctld or not ctld.utils then + trigger.action.outText("[BCN] ABORT: CTLD not initialized. Inject CTLD.lua first.", 15) + _SCN_BCN_RESULT = "[BCN] ABORT: CTLD not initialized" + return _SCN_BCN_RESULT +end + +-- ── 2. Double-injection guard ──────────────────────────────────────────────── +if _SCN_BCN_RUNNING then + trigger.action.outText("[BCN] already running β€” wait or restart DCS.", 10) + return _SCN_BCN_RESULT or "[BCN] RUNNING" +end +_SCN_BCN_RUNNING = true +_SCN_BCN_RESULT = "[BCN] STARTED" + +do -- isolation scope +local TAG = "[BCN]" +local _t0 = os.clock() + +-- ── Config: force beacon feature ON, save previous state ───────────────────── +local cfg = CTLDConfig.get() +local _savedDebug = cfg.settings["debug"] +local _savedDebugScreenLog = cfg.settings["debugScreenLog"] +local _savedEnabled = cfg.settings["enabledRadioBeaconDrop"] +local _savedBattery = cfg.settings["deployedBeaconBattery"] +cfg.settings["debug"] = true +cfg.settings["debugScreenLog"] = true +cfg.settings["enabledRadioBeaconDrop"] = true +cfg.settings["deployedBeaconBattery"] = 30 + +-- ── Test helpers ───────────────────────────────────────────────────────────── +local passed, failed, failReasons = 0, 0, {} +local function pass(id, msg) passed = passed + 1; ctld.utils.log("INFO", "%s [PASS] %s: %s", TAG, id, msg or "") end +local function fail(id, msg) + failed = failed + 1 + table.insert(failReasons, id .. ": " .. (msg or "")) + ctld.utils.log("ERROR", "%s [FAIL] %s: %s", TAG, id, msg or "") +end +local function check(id, cond, detail) if cond then pass(id, detail) else fail(id, detail) end end + +local bm = CTLDBeaconManager.getInstance() + +-- ── Positional anchor: reuse an existing unit's position, else map origin ──── +local function anchorPos() + local cats = { Group.Category.GROUND, Group.Category.AIRPLANE, + Group.Category.HELICOPTER, Group.Category.SHIP } + for _, side in ipairs({ coalition.side.BLUE, coalition.side.RED }) do + for _, cat in ipairs(cats) do + local groups = coalition.getGroups(side, cat) + if groups then + for _, g in ipairs(groups) do + local u = g:getUnit(1) + if u and u:isExist() then return u:getPoint() end + end + end + end + end + return { x = 0, y = land.getHeight({ x = 0, y = 0 }), z = 0 } +end + +local anchor = anchorPos() +anchor.y = land.getHeight({ x = anchor.x, y = anchor.z }) + +-- ── Mock transport (BLUE / USA) at the anchor ──────────────────────────────── +local mockTransport = { + getName = function() return "MOCK_BCN_TRANSPORT" end, + getCoalition = function() return coalition.side.BLUE end, + getCountry = function() return country.id.USA end, + getPoint = function() return { x = anchor.x, y = anchor.y, z = anchor.z } end, + getPosition = function() return { + p = { x = anchor.x, y = anchor.y, z = anchor.z }, + x = { x = 1, y = 0, z = 0 }, y = { x = 0, y = 1, z = 0 }, z = { x = 0, y = 0, z = 1 }, + } end, + getDesc = function() return { box = { min = { x = -8, y = 0, z = -3 }, + max = { x = 8, y = 4, z = 3 } } } end, + inAir = function() return false end, + isExist = function() return true end, +} + +-- ── Event capture (unsubscribed in cleanup) ────────────────────────────────── +local droppedPayload, removedPayload +local onDropped = function(p) droppedPayload = p end +local onRemoved = function(p) removedPayload = p end +local ed = EventDispatcher.getInstance() +ed:subscribe("OnBeaconDropped", onDropped) +ed:subscribe("OnBeaconRemoved", onRemoved) + +-- Track spawned beacon group names for guaranteed teardown. +local spawnedGroups = {} +local function trackBeacon(b) + if not b then return end + spawnedGroups[#spawnedGroups + 1] = b.vhfGroupName + spawnedGroups[#spawnedGroups + 1] = b.uhfGroupName + spawnedGroups[#spawnedGroups + 1] = b.fmGroupName +end + +-- ── Cleanup (always runs) ──────────────────────────────────────────────────── +local function cleanup() + ed:unsubscribe("OnBeaconDropped", onDropped) + ed:unsubscribe("OnBeaconRemoved", onRemoved) + -- Destroy any beacon group still on the map and purge the manager registry. + for _, gname in ipairs(spawnedGroups) do + if gname then + local g = Group.getByName(gname) + if g then + pcall(function() trigger.action.stopRadioTransmission(gname) end) + g:destroy() + end + bm._beacons[gname] = nil + end + end + cfg.settings["debug"] = _savedDebug + cfg.settings["debugScreenLog"] = _savedDebugScreenLog + cfg.settings["enabledRadioBeaconDrop"] = _savedEnabled + cfg.settings["deployedBeaconBattery"] = _savedBattery +end + +-- ── Test body (pcall β€” any crash still reaches cleanup) ────────────────────── +local _ok, _err = pcall(function() + + -- ==== F-006 : dropBeacon (non-FOB) ====================================== + droppedPayload = nil + local b1 = bm:dropBeacon(mockTransport, "TestPilot", false) + trackBeacon(b1) + check("F-006.0", b1 ~= nil, "dropBeacon returned a beacon (non-FOB)") + if b1 then + check("F-006.1", type(b1.vhf) == "number" and b1.vhf > 0, "vhf assigned > 0 | got=" .. tostring(b1.vhf)) + check("F-006.2", type(b1.uhf) == "number" and b1.uhf > 0, "uhf assigned > 0 | got=" .. tostring(b1.uhf)) + check("F-006.3", type(b1.fm) == "number" and b1.fm > 0, "fm assigned > 0 | got=" .. tostring(b1.fm)) + check("F-006.4", b1.batteryEndTime ~= -1, "temporary battery (batteryEndTime != -1) | got=" .. tostring(b1.batteryEndTime)) + check("F-006.5", b1:isBatteryAlive() == true, "battery alive right after drop") + local alive1 = b1:countAliveUnits() + check("F-006.6", alive1 == 3, "3 DCS beacon units spawned | expected=3 got=" .. tostring(alive1)) + end + check("F-006.7", droppedPayload ~= nil, "OnBeaconDropped published") + if droppedPayload then + check("F-006.8", droppedPayload.player == "TestPilot", + "payload.player == 'TestPilot' | got=" .. tostring(droppedPayload.player)) + check("F-006.9", droppedPayload.beacon ~= nil, "payload.beacon present") + check("F-006.10", droppedPayload.beacon ~= nil and droppedPayload.beacon.frequencies ~= nil, + "payload.beacon.frequencies present") + check("F-006.11", droppedPayload.coalition == coalition.side.BLUE, + "payload.coalition == BLUE | got=" .. tostring(droppedPayload.coalition)) + end + + -- ==== F-007 : removeClosestBeacon (< 500 m) ============================= + removedPayload = nil + local countBefore = 0 + for _ in pairs(bm._beacons) do countBefore = countBefore + 1 end + check("F-007.0", b1 ~= nil and countBefore >= 1, "at least 1 beacon present before remove | count=" .. tostring(countBefore)) + + if b1 then + bm:removeClosestBeacon(mockTransport, "TestPilot") + local countAfter = 0 + for _ in pairs(bm._beacons) do countAfter = countAfter + 1 end + check("F-007.1", countAfter == countBefore - 1, + "beacon count decreased by 1 | before=" .. countBefore .. " after=" .. countAfter) + check("F-007.2", bm._beacons[b1.beaconName] == nil, "removed beacon absent from _beacons registry") + check("F-007.3", removedPayload ~= nil, "OnBeaconRemoved published") + if removedPayload then + check("F-007.4", removedPayload.reason == "manual", + "payload.reason == 'manual' | got=" .. tostring(removedPayload.reason)) + check("F-007.5", removedPayload.player == "TestPilot", + "payload.player == 'TestPilot' | got=" .. tostring(removedPayload.player)) + check("F-007.6", removedPayload.beacon ~= nil, "payload.beacon present") + check("F-007.7", removedPayload.frequenciesFreed ~= nil, "payload.frequenciesFreed present") + end + end + + -- ==== F-092 : FOB beacon at centroid (overridePosition, isFOB) ========== + droppedPayload = nil + -- Centroid = 100 m devant l'ancre (heading 0 sur le mock β†’ +X). + local centroid = { + x = anchor.x + 100, + z = anchor.z, + y = land.getHeight({ x = anchor.x + 100, y = anchor.z }), + } + local b2 = bm:dropBeacon(mockTransport, "TestPilot", true, centroid) + trackBeacon(b2) + check("F-092.0", b2 ~= nil, "dropBeacon returned a beacon (FOB, overridePosition)") + if b2 then + local dx = math.abs(b2.position.x - centroid.x) + local dz = math.abs(b2.position.z - centroid.z) + check("F-092.1", dx < 1 and dz < 1, + string.format("beacon spawned at centroid (not under aircraft) | dx=%.2f dz=%.2f", dx, dz)) + check("F-092.2", type(b2.vhf) == "number" and b2.vhf > 0, "vhf assigned > 0") + check("F-092.3", type(b2.uhf) == "number" and b2.uhf > 0, "uhf assigned > 0") + check("F-092.4", type(b2.fm) == "number" and b2.fm > 0, "fm assigned > 0") + check("F-092.5", b2.batteryEndTime == -1, + "infinite battery (FOB β†’ batteryEndTime == -1) | got=" .. tostring(b2.batteryEndTime)) + check("F-092.6", b2.isFOB == true, "beacon.isFOB == true") + local alive2 = b2:countAliveUnits() + check("F-092.7", alive2 == 3, "3 DCS beacon groups spawned for FOB | expected=3 got=" .. tostring(alive2)) + end + check("F-092.8", droppedPayload ~= nil, "OnBeaconDropped published for FOB beacon") + if droppedPayload then + check("F-092.9", droppedPayload.player == "TestPilot", + "payload.player == 'TestPilot' | got=" .. tostring(droppedPayload.player)) + end + +end) + +-- ── Result + cleanup ───────────────────────────────────────────────────────── +pcall(cleanup) +_SCN_BCN_RUNNING = false +local _ms = math.floor((os.clock() - _t0) * 1000) + +if not _ok then + _SCN_BCN_RESULT = TAG .. " FAIL: crash β€” " .. tostring(_err) + trigger.action.outText(_SCN_BCN_RESULT, 60, true) + ctld.utils.log("ERROR", _SCN_BCN_RESULT) + return _SCN_BCN_RESULT +end + +local total = passed + failed +if failed == 0 then + _SCN_BCN_RESULT = TAG .. " PASS " .. passed .. "/" .. total .. " (" .. _ms .. "ms)" +else + _SCN_BCN_RESULT = TAG .. " FAIL " .. failed .. "/" .. total .. ": " .. table.concat(failReasons, "; ") +end +trigger.action.outText(_SCN_BCN_RESULT, 30, true) +ctld.utils.log("INFO", _SCN_BCN_RESULT) +return _SCN_BCN_RESULT + +end -- do isolation scope +return _SCN_BCN_RESULT diff --git a/tests/dcs/noPlayer/scenario_recon_scan_payload.lua b/tests/dcs/noPlayer/scenario_recon_scan_payload.lua new file mode 100644 index 0000000..46ddacb --- /dev/null +++ b/tests/dcs/noPlayer/scenario_recon_scan_payload.lua @@ -0,0 +1,223 @@ +---@diagnostic disable +-- @tier: auto +-- ============================================================================= +-- AUTO β€” Recon scan : OnReconScan payload (activeLayers / targets / markId) +-- ============================================================================= +-- RΓ©-intΓ¨gre la relique morte F-009 qui exige le VRAI moteur DCS (raycast LOS +-- rΓ©el via land.isVisible contre de vraies unitΓ©s ennemies), impossible Γ  mocker +-- en busted : +-- +-- F-009 : scan() β†’ publie OnReconScan avec un payload {activeLayers, targets}, +-- et crΓ©e un markId (> 0) pour chaque cible ennemie dΓ©tectΓ©e en LOS. +-- +-- L'observateur est une VRAIE unitΓ© au sol spawnΓ©e par le scΓ©nario (getUnitsLOS +-- rΓ©sout l'observateur via Unit.getByName β€” un mock ne serait pas trouvΓ©). Le +-- scan est rΓ©solu de faΓ§on SYNCHRONE (targets calculΓ©s + event publiΓ© dans le +-- mΓͺme appel) β†’ tier `auto`. Le timer d'auto-refresh laissΓ© par scan() est +-- arrΓͺtΓ© dans le cleanup via stopScan(). +-- +-- PrΓ©-requis mission : au moins UNE unitΓ© ennemie (RED ou BLUE) prΓ©sente, sinon +-- il n'y a rien Γ  dΓ©tecter en LOS β†’ ABORT clair. Les assertions de markId ne +-- s'exΓ©cutent que si des cibles sont rΓ©ellement dΓ©tectΓ©es (la LOS dΓ©pend du +-- terrain/relief ; l'observateur est spawnΓ© ~1500 m d'un ennemi pour maximiser +-- les chances, mais une LOS bloquΓ©e reste possible et n'est PAS un Γ©chec). +-- +-- Signatures vΓ©rifiΓ©es dans src/CTLD_recon.lua (2026-07-11) : +-- scan(playerUnit, player) l.577 β€” gate: ctld.gs("reconF10Menu"), l.580 +-- publish OnReconScan {player, activeLayers, targets, totalTargetsDetected, ...} l.650 +-- _scanLOS via ctld.utils.getUnitsLOS (Unit.getByName sur l'observateur) l.397 +-- ============================================================================= + +-- ── 1. CTLD-ready guard ────────────────────────────────────────────────────── +if not ctld or not ctld.utils then + trigger.action.outText("[RCN] ABORT: CTLD not initialized. Inject CTLD.lua first.", 15) + _SCN_RCN_RESULT = "[RCN] ABORT: CTLD not initialized" + return _SCN_RCN_RESULT +end + +-- ── 2. Double-injection guard ──────────────────────────────────────────────── +if _SCN_RCN_RUNNING then + trigger.action.outText("[RCN] already running β€” wait or restart DCS.", 10) + return _SCN_RCN_RESULT or "[RCN] RUNNING" +end +_SCN_RCN_RUNNING = true +_SCN_RCN_RESULT = "[RCN] STARTED" + +do -- isolation scope +local TAG = "[RCN]" +local PLAYER = "RECON_TESTER" +local _t0 = os.clock() + +local cfg = CTLDConfig.get() +local _savedDebug = cfg.settings["debug"] +local _savedDebugScreenLog = cfg.settings["debugScreenLog"] +local _savedReconMenu = cfg.settings["reconF10Menu"] +local _savedMinAlt = cfg.settings["reconMinAltitude"] +local _savedRadius = cfg.settings["reconSearchRadius"] +cfg.settings["debug"] = true +cfg.settings["debugScreenLog"] = true +cfg.settings["reconF10Menu"] = true +cfg.settings["reconMinAltitude"] = 0 -- allow ground-level observer (test env) +cfg.settings["reconSearchRadius"] = 15000 + +local passed, failed, failReasons = 0, 0, {} +local function pass(id, msg) passed = passed + 1; ctld.utils.log("INFO", "%s [PASS] %s: %s", TAG, id, msg or "") end +local function fail(id, msg) + failed = failed + 1 + table.insert(failReasons, id .. ": " .. (msg or "")) + ctld.utils.log("ERROR", "%s [FAIL] %s: %s", TAG, id, msg or "") +end +local function check(id, cond, detail) if cond then pass(id, detail) else fail(id, detail) end end + +local rm = CTLDReconManager.getInstance() +local ed = EventDispatcher.getInstance() +local CATS = { Group.Category.GROUND, Group.Category.AIRPLANE, + Group.Category.HELICOPTER, Group.Category.SHIP } + +-- ── Determine which coalition holds units β†’ observer is on the OPPOSITE side ─ +local redNames = ctld.utils.getUnitsListNamesByCategory("recon_scn", coalition.side.RED, CATS) +local blueNames = ctld.utils.getUnitsListNamesByCategory("recon_scn", coalition.side.BLUE, CATS) + +local observerCoa, observerCountry, observerType, enemyNames +if #redNames > 0 then + observerCoa, observerCountry, observerType, enemyNames = + coalition.side.BLUE, country.id.USA, "Soldier M4", redNames +elseif #blueNames > 0 then + observerCoa, observerCountry, observerType, enemyNames = + coalition.side.RED, country.id.RUSSIA, "Infantry AK", blueNames +end + +-- ── ABORT if no enemy targets exist at all ────────────────────────────────── +if not enemyNames then + -- restore config before bailing + cfg.settings["debug"] = _savedDebug + cfg.settings["debugScreenLog"] = _savedDebugScreenLog + cfg.settings["reconF10Menu"] = _savedReconMenu + cfg.settings["reconMinAltitude"] = _savedMinAlt + cfg.settings["reconSearchRadius"] = _savedRadius + _SCN_RCN_RUNNING = false + _SCN_RCN_RESULT = TAG .. " ABORT: no enemy units in mission (need >=1 unit on either coalition for recon LOS)" + trigger.action.outText(_SCN_RCN_RESULT, 30, true) + ctld.utils.log("WARN", _SCN_RCN_RESULT) + return _SCN_RCN_RESULT +end + +-- ── Anchor: ~1500 m from the first enemy unit to maximise LOS ──────────────── +local enemyUnit = Unit.getByName(enemyNames[1]) +local base = enemyUnit and enemyUnit:getPoint() or { x = 0, y = 0, z = 0 } +local obsX, obsZ = base.x + 1500, base.z +local anchor = { x = obsX, y = land.getHeight({ x = obsX, y = obsZ }), z = obsZ } + +-- ── Spawn the real observer unit ───────────────────────────────────────────── +local obsUid = ctld.utils.getNextUniqId() +local obsGroup = "RCN_OBS_" .. obsUid +local groupData = { + visible = true, + hidden = false, + category = Group.Category.GROUND, + country = observerCountry, + name = obsGroup, + task = {}, + units = { + { type = observerType, name = obsGroup, x = anchor.x, y = anchor.z, + heading = 0, skill = "Average", playerCanDrive = false }, + }, +} +local spawnRes = ctld.utils.dynAdd("recon_scn:spawnObserver", groupData) +local obsUnit = spawnRes and Group.getByName(spawnRes.name) and Group.getByName(spawnRes.name):getUnit(1) or nil + +-- ── Event capture (unsubscribed in cleanup) ────────────────────────────────── +local reconPayload +local onScan = function(p) reconPayload = p end +ed:subscribe("OnReconScan", onScan) + +-- ── Cleanup (always runs) ──────────────────────────────────────────────────── +local function cleanup() + ed:unsubscribe("OnReconScan", onScan) + -- Stop RECON (removes auto-refresh timer + marks + clears _activeScans[player]). + if obsUnit then pcall(function() rm:stopScan(obsUnit, PLAYER) end) end + rm._activeScans[PLAYER] = nil + rm._playerLayers[PLAYER] = nil + if rm._farpMarks then rm._farpMarks[PLAYER] = nil end + local g = Group.getByName(obsGroup) + if g then g:destroy() end + cfg.settings["debug"] = _savedDebug + cfg.settings["debugScreenLog"] = _savedDebugScreenLog + cfg.settings["reconF10Menu"] = _savedReconMenu + cfg.settings["reconMinAltitude"] = _savedMinAlt + cfg.settings["reconSearchRadius"] = _savedRadius +end + +-- ── Test body (pcall β€” any crash still reaches cleanup) ────────────────────── +local _ok, _err = pcall(function() + + if not obsUnit then + error("observer spawn failed β€” dynAdd/Group.getByName returned nil (invalid spawn position?)") + end + + -- Enable ALL layers so any detected enemy category yields a target + markId. + local layers = rm:_getPlayerLayers(PLAYER) + for _, layer in ipairs(layers) do layer.enabled = true end + + reconPayload = nil + rm:scan(obsUnit, PLAYER) + + -- ==== F-009 : OnReconScan payload contract ============================= + check("F-009.1", reconPayload ~= nil, "OnReconScan published after scan()") + if reconPayload then + check("F-009.2", reconPayload.player == PLAYER, + "payload.player == '" .. PLAYER .. "' | got=" .. tostring(reconPayload.player)) + check("F-009.3", reconPayload.activeLayers ~= nil, "payload.activeLayers present") + check("F-009.4", type(reconPayload.activeLayers) == "table" and #reconPayload.activeLayers > 0, + "payload.activeLayers non-empty (all layers enabled) | n=" .. + tostring(reconPayload.activeLayers and #reconPayload.activeLayers)) + check("F-009.5", reconPayload.targets ~= nil and type(reconPayload.targets) == "table", + "payload.targets is a table") + check("F-009.6", type(reconPayload.totalTargetsDetected) == "number", + "payload.totalTargetsDetected is a number | got=" .. tostring(reconPayload.totalTargetsDetected)) + end + + -- Active scan registered for the player + local scan = rm._activeScans[PLAYER] + check("F-009.7", scan ~= nil, "active scan registered for player") + + -- markId assertions only when LOS actually detected targets + if scan and #scan.targets > 0 then + check("F-009.8", scan.targets[1].markId ~= nil, "first detected target has a markId") + check("F-009.9", type(scan.targets[1].markId) == "number" and scan.targets[1].markId > 0, + "markId > 0 | got=" .. tostring(scan.targets[1].markId)) + check("F-009.10", reconPayload and reconPayload.totalTargetsDetected == #scan.targets, + string.format("payload.totalTargetsDetected == #targets | payload=%s scan=%d", + tostring(reconPayload and reconPayload.totalTargetsDetected), #scan.targets)) + else + pass("F-009.INFO", string.format( + "0 enemy in LOS from observer (LOS blocked by terrain/relief) β€” %d enemy unit(s) exist; " .. + "payload contract validated, markId assertions skipped", #enemyNames)) + end + +end) + +-- ── Result + cleanup ───────────────────────────────────────────────────────── +pcall(cleanup) +_SCN_RCN_RUNNING = false +local _ms = math.floor((os.clock() - _t0) * 1000) + +if not _ok then + _SCN_RCN_RESULT = TAG .. " FAIL: crash β€” " .. tostring(_err) + trigger.action.outText(_SCN_RCN_RESULT, 60, true) + ctld.utils.log("ERROR", _SCN_RCN_RESULT) + return _SCN_RCN_RESULT +end + +local total = passed + failed +if failed == 0 then + _SCN_RCN_RESULT = TAG .. " PASS " .. passed .. "/" .. total .. " (" .. _ms .. "ms)" +else + _SCN_RCN_RESULT = TAG .. " FAIL " .. failed .. "/" .. total .. ": " .. table.concat(failReasons, "; ") +end +trigger.action.outText(_SCN_RCN_RESULT, 30, true) +ctld.utils.log("INFO", _SCN_RCN_RESULT) +return _SCN_RCN_RESULT + +end -- do isolation scope +return _SCN_RCN_RESULT diff --git a/tests/dcs/noPlayer/scenario_vehicle_dcs_native.lua b/tests/dcs/noPlayer/scenario_vehicle_dcs_native.lua new file mode 100644 index 0000000..a4f0e63 --- /dev/null +++ b/tests/dcs/noPlayer/scenario_vehicle_dcs_native.lua @@ -0,0 +1,226 @@ +---@diagnostic disable +-- @tier: auto +-- ============================================================================= +-- AUTO β€” Vehicle dcs_native : loadVehicle / unloadVehicle (C-130 physique) +-- ============================================================================= +-- RΓ©-intΓ¨gre 2 reliques mortes qui exigent le VRAI moteur DCS (spawn rΓ©el d'un +-- vΓ©hicule au sol + rΓ©cupΓ©ration de l'unitΓ© vivante via Group.getByName aprΓ¨s +-- load), impossibles Γ  mocker en busted : +-- +-- F-018 : loadVehicle(method="dcs_native") β†’ Γ©tat LOADED, event OnVehicleLoaded +-- avec method="dcs_native". En dcs_native l'unitΓ© DCS reste VIVANTE +-- (physiquement liΓ©e dans l'avion) β€” elle N'EST PAS dΓ©truite ni mise +-- Γ  nil (cf. src l.455-460 : seul le reverse-lookup est purgΓ©). +-- F-019 : unloadVehicle(method="dcs_native") au sol β†’ l'unitΓ© est rΓ©cupΓ©rΓ©e du +-- groupe toujours vivant, Γ©tat WAITING (re-loadable), event +-- OnVehicleUnloaded avec method="dcs_native". +-- +-- Le transport (C-130) est un mock (position/coalition/pays) ; la partie NON +-- mockable β€” spawnVehicleForTransport qui crΓ©e un vrai groupe GROUND, puis +-- Group.getByName au unload β€” passe par le vrai moteur. 100 % synchrone β†’ `auto`. +-- +-- NOTE de paritΓ© : la relique F-018 asserte `vehicle.unit == nil` aprΓ¨s load β€” +-- c'est PΓ‰RIMΓ‰. Le src courant garde l'unitΓ© vivante en dcs_native (l.456-460). +-- Ce scΓ©nario asserte donc `vehicle.unit ~= nil`. +-- +-- PrΓ©-requis mission : AUCUN (transport mockΓ©, vΓ©hicule spawnΓ© par le scΓ©nario ; +-- ancre positionnelle dΓ©rivΓ©e d'une unitΓ© existante ou (0,0)). +-- +-- Signatures vΓ©rifiΓ©es dans src/CTLD_vehicle.lua (2026-07-11) : +-- spawnVehicleForTransport(vehicleType, spawner, logisticZone) β†’ CTLDVehicle WAITING l.249 +-- loadVehicle(vehicle, transport, player, method) l.418 (WAITING β†’ LOADED) +-- dcs_native : unitΓ© conservΓ©e vivante l.455-460 ; publish OnVehicleLoaded l.499 +-- unloadVehicle(vehicle, transport, player, method, rearSector) l.532 (LOADED β†’ WAITING) +-- dcs_native : unit = Group.getByName(sd.groupName):getUnit(1) l.543-546 ; +-- publish OnVehicleUnloaded l.615 +-- ============================================================================= + +-- ── 1. CTLD-ready guard ────────────────────────────────────────────────────── +if not ctld or not ctld.utils then + trigger.action.outText("[VEH] ABORT: CTLD not initialized. Inject CTLD.lua first.", 15) + _SCN_VEH_RESULT = "[VEH] ABORT: CTLD not initialized" + return _SCN_VEH_RESULT +end + +-- ── 2. Double-injection guard ──────────────────────────────────────────────── +if _SCN_VEH_RUNNING then + trigger.action.outText("[VEH] already running β€” wait or restart DCS.", 10) + return _SCN_VEH_RESULT or "[VEH] RUNNING" +end +_SCN_VEH_RUNNING = true +_SCN_VEH_RESULT = "[VEH] STARTED" + +do -- isolation scope +local TAG = "[VEH]" +local _t0 = os.clock() + +local cfg = CTLDConfig.get() +local _savedDebug = cfg.settings["debug"] +local _savedDebugScreenLog = cfg.settings["debugScreenLog"] +cfg.settings["debug"] = true +cfg.settings["debugScreenLog"] = true + +local passed, failed, failReasons = 0, 0, {} +local function pass(id, msg) passed = passed + 1; ctld.utils.log("INFO", "%s [PASS] %s: %s", TAG, id, msg or "") end +local function fail(id, msg) + failed = failed + 1 + table.insert(failReasons, id .. ": " .. (msg or "")) + ctld.utils.log("ERROR", "%s [FAIL] %s: %s", TAG, id, msg or "") +end +local function check(id, cond, detail) if cond then pass(id, detail) else fail(id, detail) end end + +local vs = CTLDVehicleSpawner.getInstance() +local VEHICLE_TYPE = "M1045 HMMWV TOW" + +-- ── Positional anchor: reuse an existing unit's position, else map origin ──── +local function anchorPos() + local cats = { Group.Category.GROUND, Group.Category.AIRPLANE, + Group.Category.HELICOPTER, Group.Category.SHIP } + for _, side in ipairs({ coalition.side.BLUE, coalition.side.RED }) do + for _, cat in ipairs(cats) do + local groups = coalition.getGroups(side, cat) + if groups then + for _, g in ipairs(groups) do + local u = g:getUnit(1) + if u and u:isExist() then return u:getPoint() end + end + end + end + end + return { x = 0, y = land.getHeight({ x = 0, y = 0 }), z = 0 } +end + +local anchor = anchorPos() +anchor.y = land.getHeight({ x = anchor.x, y = anchor.z }) + +-- ── Mock transport (C-130, BLUE / USA) at the anchor ───────────────────────── +local mockTransport = { + getName = function() return "MOCK_VEH_C130" end, + getTypeName = function() return "Hercules" end, + getCoalition = function() return coalition.side.BLUE end, + getCountry = function() return country.id.USA end, + getPoint = function() return { x = anchor.x, y = anchor.y, z = anchor.z } end, + getPosition = function() return { + p = { x = anchor.x, y = anchor.y, z = anchor.z }, + x = { x = 1, y = 0, z = 0 }, y = { x = 0, y = 1, z = 0 }, z = { x = 0, y = 0, z = 1 }, + } end, + getDesc = function() return { box = { min = { x = -15, y = 0, z = -20 }, + max = { x = 15, y = 8, z = 20 } } } end, + inAir = function() return false end, + isExist = function() return true end, +} + +-- ── Event capture (unsubscribed in cleanup) ────────────────────────────────── +local loadedPayload, unloadedPayload +local onLoaded = function(p) loadedPayload = p end +local onUnloaded = function(p) unloadedPayload = p end +local ed = EventDispatcher.getInstance() +ed:subscribe("OnVehicleLoaded", onLoaded) +ed:subscribe("OnVehicleUnloaded", onUnloaded) + +-- Track spawned vehicle for guaranteed teardown. +local vehicle -- assigned inside pcall, cleaned up below +local spawnedGroupName -- DCS group name to destroy + +-- ── Cleanup (always runs) ──────────────────────────────────────────────────── +local function cleanup() + ed:unsubscribe("OnVehicleLoaded", onLoaded) + ed:unsubscribe("OnVehicleUnloaded", onUnloaded) + if spawnedGroupName then + local g = Group.getByName(spawnedGroupName) + if g then g:destroy() end + vs._unitToVehicle[spawnedGroupName] = nil + end + if vehicle then + vs._vehicles[vehicle.id] = nil + end + cfg.settings["debug"] = _savedDebug + cfg.settings["debugScreenLog"] = _savedDebugScreenLog +end + +-- ── Test body (pcall β€” any crash still reaches cleanup) ────────────────────── +local _ok, _err = pcall(function() + + -- Spawn a real DCS ground vehicle near the (mock) transport. + vehicle = vs:spawnVehicleForTransport(VEHICLE_TYPE, mockTransport, nil) + if not vehicle then + error("spawnVehicleForTransport returned nil β€” dynAdd failed (invalid spawn position?)") + end + spawnedGroupName = vehicle.spawnData and vehicle.spawnData.groupName + + check("SETUP.1", vehicle:getState() == CTLDVehicle.STATE.WAITING, + "vehicle spawned in WAITING | got=" .. tostring(vehicle:getState())) + check("SETUP.2", vehicle.unit ~= nil, "spawned DCS unit is live before load") + + -- ==== F-018 : loadVehicle dcs_native β†’ LOADED + OnVehicleLoaded ========= + loadedPayload = nil + vs:loadVehicle(vehicle, mockTransport, "TestPlayer", "dcs_native") + + check("F-018.1", vehicle:getState() == CTLDVehicle.STATE.LOADED, + "state LOADED after dcs_native load | got=" .. tostring(vehicle:getState())) + check("F-018.2", vehicle.unit ~= nil, + "dcs_native keeps the unit alive (NOT destroyed) β€” parity note vs stale relic") + check("F-018.3", vehicle.loadMethod == "dcs_native", + "loadMethod == 'dcs_native' | got=" .. tostring(vehicle.loadMethod)) + check("F-018.4", vehicle.loadTransportName == "MOCK_VEH_C130", + "loadTransportName recorded | got=" .. tostring(vehicle.loadTransportName)) + check("F-018.5", loadedPayload ~= nil, "OnVehicleLoaded published") + if loadedPayload then + check("F-018.6", loadedPayload.vehicleId == vehicle.id, + "payload.vehicleId correct | expected=" .. tostring(vehicle.id) .. " got=" .. tostring(loadedPayload.vehicleId)) + check("F-018.7", loadedPayload.method == "dcs_native", + "payload.method == 'dcs_native' | got=" .. tostring(loadedPayload.method)) + check("F-018.8", loadedPayload.transportUnitObject ~= nil, "payload.transportUnitObject present") + check("F-018.9", loadedPayload.position ~= nil, "payload.position present") + check("F-018.10", loadedPayload.timestamp ~= nil, "payload.timestamp present") + check("F-018.11", loadedPayload.dcsUnitObject ~= nil, + "payload.dcsUnitObject present (dcs_native β†’ live unit)") + end + + -- ==== F-019 : unloadVehicle dcs_native (au sol) β†’ WAITING + OnVehicleUnloaded + unloadedPayload = nil + vs:unloadVehicle(vehicle, mockTransport, "TestPlayer", "dcs_native") + + check("F-019.1", vehicle:getState() == CTLDVehicle.STATE.WAITING, + "state WAITING after dcs_native unload (re-loadable) | got=" .. tostring(vehicle:getState())) + check("F-019.2", vehicle.unit ~= nil, + "unit recovered from live group after unload") + check("F-019.3", unloadedPayload ~= nil, "OnVehicleUnloaded published") + if unloadedPayload then + check("F-019.4", unloadedPayload.vehicleId == vehicle.id, + "payload.vehicleId correct | expected=" .. tostring(vehicle.id) .. " got=" .. tostring(unloadedPayload.vehicleId)) + check("F-019.5", unloadedPayload.vehicleType == VEHICLE_TYPE, + "payload.vehicleType correct | got=" .. tostring(unloadedPayload.vehicleType)) + check("F-019.6", unloadedPayload.method == "dcs_native", + "payload.method == 'dcs_native' | got=" .. tostring(unloadedPayload.method)) + check("F-019.7", unloadedPayload.transportUnitObject ~= nil, "payload.transportUnitObject present") + check("F-019.8", unloadedPayload.position ~= nil, "payload.position present") + check("F-019.9", unloadedPayload.timestamp ~= nil, "payload.timestamp present") + end + +end) + +-- ── Result + cleanup ───────────────────────────────────────────────────────── +pcall(cleanup) +_SCN_VEH_RUNNING = false +local _ms = math.floor((os.clock() - _t0) * 1000) + +if not _ok then + _SCN_VEH_RESULT = TAG .. " FAIL: crash β€” " .. tostring(_err) + trigger.action.outText(_SCN_VEH_RESULT, 60, true) + ctld.utils.log("ERROR", _SCN_VEH_RESULT) + return _SCN_VEH_RESULT +end + +local total = passed + failed +if failed == 0 then + _SCN_VEH_RESULT = TAG .. " PASS " .. passed .. "/" .. total .. " (" .. _ms .. "ms)" +else + _SCN_VEH_RESULT = TAG .. " FAIL " .. failed .. "/" .. total .. ": " .. table.concat(failReasons, "; ") +end +trigger.action.outText(_SCN_VEH_RESULT, 30, true) +ctld.utils.log("INFO", _SCN_VEH_RESULT) +return _SCN_VEH_RESULT + +end -- do isolation scope +return _SCN_VEH_RESULT diff --git a/tools/integration-runner/run_scenarios.py b/tools/integration-runner/run_scenarios.py index 64fb0cd..db16abe 100644 --- a/tools/integration-runner/run_scenarios.py +++ b/tools/integration-runner/run_scenarios.py @@ -257,7 +257,11 @@ def http_post(code): req = urllib.request.Request( base_url + "/api/exec", data=json.dumps(body).encode("utf-8"), - headers={"Content-Type": "application/json", "X-API-Key": api_key}, + # dcs-bridge >= LOT-018 authenticates via a Bearer token whose role gates the + # endpoint (/api/exec requires the SUPERUSER role). The api_key from the config + # is used as that token. (Older builds used an X-API-Key header.) + headers={"Content-Type": "application/json", + "Authorization": "Bearer " + api_key}, method="POST", ) try: From 3d05cfa08911ad45e82fb60b136f29a474b4f9a2 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sun, 12 Jul 2026 19:26:28 +0200 Subject: [PATCH 12/12] docs(backlog): REINTEGRATE-ORPHAN-TESTS complete (7/7), pending merge Co-Authored-By: Claude Opus 4.8 --- .backlog/README.md | 2 +- .backlog/REINTEGRATE-ORPHAN-TESTS/PRD.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.backlog/README.md b/.backlog/README.md index d08dcd0..cecf357 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -14,7 +14,7 @@ authored **per lot, when the lot is started** (not in batch). | Lot | Status | Description | Branch | |-----|--------|-------------|--------| -| `REINTEGRATE-ORPHAN-TESTS` | 🚧 | Rebuild coverage for the 46 features tested only by dead FullGas relics (busted-first: 6 busted tickets + 1 live-DCS). Precedes & unblocks `CLEANUP-LEGACY-DCS-TESTS`. 10 relics are false orphans (already covered); 6 are deferred to FullGas (`dev/fullgas-report.md`). | feature/reintegrate-orphan-tests | +| `REINTEGRATE-ORPHAN-TESTS` | βœ… pending merge | Rebuilt coverage for the 46 features tested only by dead FullGas relics β€” 6 busted tickets (158 tests, green in CI) + 1 live-DCS ticket (3 scenarios: BCN 30/30, RCN 10/10, VEH 22/22). Precedes & unblocks `CLEANUP-LEGACY-DCS-TESTS`. 10 relics were false orphans (already covered); 6 deferred to FullGas (`dev/fullgas-report.md`). PR #20. | feature/reintegrate-orphan-tests | ### Planned lots diff --git a/.backlog/REINTEGRATE-ORPHAN-TESTS/PRD.md b/.backlog/REINTEGRATE-ORPHAN-TESTS/PRD.md index 5dc0e76..b6aa9bf 100644 --- a/.backlog/REINTEGRATE-ORPHAN-TESTS/PRD.md +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/PRD.md @@ -1,7 +1,7 @@ # Lot REINTEGRATE-ORPHAN-TESTS β€” rebuild coverage lost with the dead FullGas relics -Status: 🚧 in progress -Branch: feature/reintegrate-orphan-tests β†’ PR (pending) β†’ develop +Status: βœ… done β€” all 7 tickets complete (pending merge) +Branch: feature/reintegrate-orphan-tests β†’ PR #20 β†’ develop Program: re-tooling CTLD on the VMCT model (see `.backlog/README.md`). Precedes and unblocks the planned `CLEANUP-LEGACY-DCS-TESTS` purge.