diff --git a/.backlog/README.md b/.backlog/README.md index 9657c82..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 | |-----|--------|-------------|--------| -| _(none)_ | | | | +| `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 new file mode 100644 index 0000000..b6aa9bf --- /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: ✅ 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. + +## 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..435ed2c --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/01-crate-lifecycle.md @@ -0,0 +1,40 @@ +# 01 — Crate lifecycle manager methods + events (PILOT) + +Status: ✅ done +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..063bc9f --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/02-legacy-api-wrappers.md @@ -0,0 +1,33 @@ +# 02 — Legacy API wrappers: routing + deprecation + +Status: ✅ done +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..7bbd941 --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/03-scenes-minefields.md @@ -0,0 +1,35 @@ +# 03 — Scene structures + minefields + +Status: ✅ done +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..0eeae5f --- /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: ✅ done +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..677c1a9 --- /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: ✅ done +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..953f213 --- /dev/null +++ b/.backlog/REINTEGRATE-ORPHAN-TESTS/tickets/06-jtac-recon.md @@ -0,0 +1,31 @@ +# 06 — JTAC config/deregister + recon auto-refresh + +Status: ✅ done +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..8a8ab2b --- /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: ✅ done +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. 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/crate_lifecycle_spec.lua b/tests/ci/unit/crate_lifecycle_spec.lua new file mode 100644 index 0000000..03a19c2 --- /dev/null +++ b/tests/ci/unit/crate_lifecycle_spec.lua @@ -0,0 +1,276 @@ +---@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) + + -- ── 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) 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) 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) 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) diff --git a/tests/ci/unit/menu_gating_spec.lua b/tests/ci/unit/menu_gating_spec.lua new file mode 100644 index 0000000..cc5c9d6 --- /dev/null +++ b/tests/ci/unit/menu_gating_spec.lua @@ -0,0 +1,500 @@ +---@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. + -- 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() + 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 + + 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() + -- 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", {}) + _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 }, + { 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() + _G.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) 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) 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: