also looking at this
test: qualify execution visibility across native renewal
#85
19 files
+755
-78
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -154,6 +154,16 @@ jobs: | ||
| 154 | 154 | working-directory: ${{ runner.temp }} |
| 155 | 155 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --electrical |
| 156 | 156 | working-directory: ${{ runner.temp }} |
| 157 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario host-loss --phase back --electrical | |
| 158 | working-directory: ${{ runner.temp }} | |
| 159 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario cruise-pause-resume --electrical | |
| 160 | working-directory: ${{ runner.temp }} | |
| 161 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario front-abort --electrical | |
| 162 | working-directory: ${{ runner.temp }} | |
| 163 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario pusher-open-circuit --electrical | |
| 164 | working-directory: ${{ runner.temp }} | |
| 165 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical | |
| 166 | working-directory: ${{ runner.temp }} | |
| 157 | 167 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone authority:simulate --scenario pi-loss --seconds 4 |
| 158 | 168 | working-directory: ${{ runner.temp }} |
| 159 | 169 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack |
| @@ -80,7 +80,9 @@ outputs remain inhibited. Read the [shared contract](docs/vehicles.md), [air mod | ||
| 80 | 80 | vertical takeoff, a nearby hover waypoint and contact-gated landing. The TypeScript library also |
| 81 | 81 | includes a bounded modeled front/cruise/back experiment with stable-phase pause and resume. |
| 82 | 82 | `./buddy vtol:simulate` runs its nominal route through vertical landing and reports the qualified |
| 83 | mode observations. Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements. | |
| 83 | mode observations. Named scenarios expose phase-bound sensor/manual/host loss, cruise pause/resume, | |
| 84 | declared front/back abort and recovery, pusher open-circuit timeout and global endurance expiry. | |
| 85 | Add `--electrical` to either command for fictional propulsion/battery load and delayed energy measurements. | |
| 84 | 86 | Plans can explicitly declare bounded hover-and-land transition recovery. Complete recovery/abort envelopes, |
| 85 | 87 | measured hybrid energy qualification and a selectable VTOL mission workspace remain open. |
| 86 | 88 | |
| @@ -1,27 +1,21 @@ | ||
| 1 | 1 | import { defineCommand } from '@stacksjs/cli' |
| 2 | 2 | import { print, printError } from '@stacksjs/cli/output' |
| 3 | import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile' | |
| 4 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 3 | import { runVtolSimulation, vtolSimulationPhases, vtolSimulationScenarios } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 5 | 4 | |
| 6 | 5 | export default defineCommand({ |
| 7 | 6 | name: 'vtol:simulate', |
| 8 | 7 | description: 'Exercise the fictional owned front/cruise/back/landing route; no physical control', |
| 9 | 8 | options: { |
| 10 | '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds', default: '160' }, | |
| 9 | '--scenario <name>': { description: vtolSimulationScenarios.join(', '), default: 'nominal' }, | |
| 10 | '--phase <name>': { description: `Fault injection phase: ${vtolSimulationPhases.join(', ')}; default front` }, | |
| 11 | '--seconds <n>': { description: 'Maximum virtual duration, 2..180 seconds; default 160, or 180 for recovery hold' }, | |
| 11 | 12 | '--electrical': { description: 'Use fictional electrical load and delayed battery measurements', default: false }, |
| 12 | 13 | }, |
| 13 | 14 | handle(options) { |
| 14 | 15 | try { |
| 15 | const result = runVehicleSimulation(createVtolTransitionProfile({ electrical: options.electrical }), { seconds: Number(options.seconds) }) | |
| 16 | const { trace, path, ...report } = result | |
| 17 | const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) | |
| 18 | const completeTransitions = transitionEvidence.length === 3 && transitionEvidence.every((row, index) => row.taskId === ['initial-hover', 'front', 'back'][index] && row.phase === ['hover', 'cruise', 'hover'][index]) | |
| 19 | const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0)) | |
| 20 | const landingContact = path.at(-1)?.measurement?.values.onSurface === true | |
| 21 | const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null | |
| 22 | const demonstrationMatched = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral | |
| 23 | print(JSON.stringify({ ...report, scope: 'development-lift-and-cruise', transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length }, (_key, value) => typeof value === 'bigint' ? String(value) : value, 2)) | |
| 24 | if (!demonstrationMatched) process.exitCode = 1 | |
| 16 | const report = runVtolSimulation(options.scenario, { seconds: options.seconds === undefined ? undefined : Number(options.seconds), electrical: options.electrical, phase: options.phase }) | |
| 17 | print(JSON.stringify(report, (_key, value) => typeof value === 'bigint' ? String(value) : value, 2)) | |
| 18 | if (!report.demonstrationMatched) process.exitCode = 1 | |
| 25 | 19 | } |
| 26 | 20 | catch (error) { printError(error instanceof Error ? error.message : String(error)); process.exitCode = 1 } |
| 27 | 21 | }, |
| @@ -56,7 +56,8 @@ surface and subsea profiles. [Optional external drivers](drivers.md) have | ||
| 56 | 56 | [acknowledged protocol services](driver-protocols.md); scripted peers are distinct |
| 57 | 57 | from [firmware SITL](sitl.md) and physical testing. |
| 58 | 58 | |
| 59 | The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only and nominal full-route commands, and an | |
| 59 | The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only and full-route commands, | |
| 60 | named fault/operator scenarios, and an | |
| 60 | 61 | experimental TypeScript library profile for modeled front transition, cruise, back transition and |
| 61 | 62 | vertical landing, with stable-phase pause/resume and explicitly declared hover-and-land recovery |
| 62 | 63 | routes. Complete recovery/abort envelopes, hybrid energy qualification and the common VTOL mission |
| @@ -16,7 +16,7 @@ means there is no native implementation yet. A protocol-compatible transport is | ||
| 16 | 16 | | Diagnostics, logs, replay, firmware update and configuration provenance | Owned runtime journal, loss accounting, replay, CLI/viewer and [protected signed preparation, owned candidates, host slots, observed image/session health and fresh-policy rollback](../runtime-updates.md); full mission correlation, key repair, complete storage faults and board install/boot/rollback qualification remain pending | Named parameter read/write with bounded readback in the SITL adapter; no log/FTP services, full-table synchronization or reboot persistence qualification | [#9](https://github.com/stacksjs/drone/issues/9) | |
| 17 | 17 | | Missions, tasks, waypoints, terrain, rally and resume | Owned versioned graph, flat-terrain/fence/rally/energy validation, physics execution, pause/resume, recorded recovery, CLI and stx viewer; correlated durable checkpoints, driver translation and hardware evidence pending | Bounded SITL waypoint upload/download and acknowledged item selection/resume; no arming, automatic flight-mode selection or owned-plan translation | [#10](https://github.com/stacksjs/drone/issues/10) | |
| 18 | 18 | | Avoidance, route planning, precision landing, follow and orbit | Owned modeled range detection, inflated route planning, terrain-clearance/RTL/selected-site landing physics; metric follow/orbit goals; physical calibration and dynamic obstacle campaigns pending | No avoidance/landing integration | [#11](https://github.com/stacksjs/drone/issues/11) | |
| 19 | | Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing runway takeoff/survey/landing, hover-only and nominal full-route VTOL commands and [experimental VTOL library](../vehicles-vtol.md) with modeled front/cruise/back/vertical landing, pause/resume, opt-in hover-and-land recovery and fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, CLI fault/operator scenarios, workspace selection, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) | | |
| 19 | | Fixed-wing, VTOL, heli, blimp and experimental airframes | Owned fixed-wing runway takeoff/survey/landing, hover-only and full-route VTOL commands with named phase-bound source/manual/host failures, pause/resume, declared abort/recovery, open-circuit timeout and endurance cases; [experimental VTOL library](../vehicles-vtol.md) includes fictional electrical load/battery/sensor accounting; complete recovery/abort envelopes, measured hybrid energy, workspace selection, helicopter, soaring, blimp and other airframe families remain open | Current drivers reject non-quadcopter heartbeats | [#12](https://github.com/stacksjs/drone/issues/12) | | |
| 20 | 20 | | Rovers, mowing, logistics and materials handling | Owned differential traction/braking, encoder/GNSS odometry, guarded mowing coverage and scoped material-drop simulation; hardware and broader chassis/implements pending | Not exposed | [#13](https://github.com/stacksjs/drone/issues/13) | |
| 21 | 21 | | Surface vessels, bathymetry, water sampling and harbours | Owned planar hull/current/wind, collision inhibition, corrected single-beam export, scoped sample ACK and energy-return simulation; physical survey/sampling and broader hulls pending | Not exposed | [#14](https://github.com/stacksjs/drone/issues/14) | |
| 22 | 22 | | Subsea ROV, depth hold, tether and leak handling | Owned eight-thruster 6DOF physics, pressure/DVL local estimation, depth hold, tether/leak supervision and scoped synthetic camera/sonar evidence; hardware, full hydrodynamics and physical inspection pending | Not exposed | [#15](https://github.com/stacksjs/drone/issues/15) | |
| @@ -75,26 +75,74 @@ is a separate development experiment on the same owned runtime. Its nominal miss | ||
| 75 | 75 | hover, takes off vertically to 15 m, requests a front transition, flies a cruise leg, requests back |
| 76 | 76 | transition, returns to the back request's capture point, observes settled hover, and lands vertically. |
| 77 | 77 | A turning leg also has an actual completed model campaign. The native `vtol:simulate` command runs |
| 78 | the nominal route. This profile is not a selectable workspace profile. | |
| 78 | the nominal route and named cases below. This profile is not a selectable workspace profile. | |
| 79 | 79 | |
| 80 | 80 | ```sh |
| 81 | 81 | ./buddy vtol:simulate |
| 82 | 82 | ./buddy vtol:simulate --electrical |
| 83 | ./buddy vtol:simulate --scenario host-loss --phase back --electrical | |
| 84 | ./buddy vtol:simulate --scenario front-abort --electrical | |
| 83 | 85 | ./storage/runtime-build/cli/drone vtol:simulate --electrical |
| 84 | 86 | ``` |
| 85 | 87 | |
| 86 | The command defaults to 160 virtual seconds and accepts an integer `--seconds` from 2 to 180. | |
| 88 | The nominal command defaults to 160 virtual seconds and accepts an integer `--seconds` from 2 to 180. | |
| 87 | 89 | It reports the existing runtime's actual status and the three qualified initial-hover, cruise and |
| 88 | back-hover observations, including their event, task, reference and capture epochs. Exit 0 requires | |
| 90 | back-hover observations, including their event, task, reference and capture epochs. Nominal exit 0 requires | |
| 89 | 91 | those ordered confirmations, observed estimated flight, completed landing with qualified modeled |
| 90 | 92 | contact, no runtime fault, neutral board outputs and no active output lease. An incomplete route |
| 91 | 93 | retains its partial observations and status and exits 1. The condensed report omits the full path |
| 92 | 94 | and trace. The library still exposes both for replay and evaluation. |
| 93 | 95 | |
| 94 | 96 | Buddy discovers the typed command natively; the standalone binary registers the same definition. |
| 95 | This command currently exposes the nominal route only. Fault scenarios, operator schedules and | |
| 96 | recovery route selection remain library capabilities. Simulation completion does not qualify | |
| 97 | physical transitions, arbitrary flight envelopes or hardware landing. | |
| 97 | `runVtolSimulation(scenario, options)` in `resources/functions/drone/vehicles/vtol/simulation.ts` | |
| 98 | owns the case logic and condensed report for both. Simulation completion does not qualify physical | |
| 99 | transitions, arbitrary flight envelopes or hardware landing. | |
| 100 | ||
| 101 | ### Named fault and operator cases | |
| 102 | ||
| 103 | `--scenario` accepts `imu-loss`, `position-loss`, `vehicle-sensor-loss`, `host-loss`, `rc-override` | |
| 104 | and `kill`. These six cases also accept `--phase`, which defaults to `front`: | |
| 105 | ||
| 106 | | Phase | Declared injection epoch | Required pre-injection task | | |
| 107 | | --- | --- | --- | | |
| 108 | | `front` | 13,000 ms | Front transition | | |
| 109 | | `cruise` | 20,000 ms | Cruise leg | | |
| 110 | | `back` | 30,000 ms | Back transition | | |
| 111 | | `landing` | 68,000 ms | Vertical landing | | |
| 112 | ||
| 113 | These are fixed model epochs. The checker verifies the actual task immediately before injection; | |
| 114 | a changed model that reaches another task cannot silently pass under the requested phase name. | |
| 115 | Reports retain final source epochs observed at the controller boundary. Source failures must block | |
| 116 | within 500 ms, with the selected source absent or captured before injection. Position and vehicle | |
| 117 | sensor cases also require the other relevant source epochs to reach injection time. RC and kill require the | |
| 118 | matching authority observation at injection. Host loss requires the independently expired output | |
| 119 | lease and an older host authority snapshot. None can claim mission completion or landing. | |
| 120 | ||
| 121 | The remaining named cases use captured schedules and explicit, counted recovery routes: | |
| 122 | ||
| 123 | | Scenario | Declared behavior | Required final outcome | | |
| 124 | | --- | --- | --- | | |
| 125 | | `cruise-pause-resume` | Pause at 20,000 ms, resume at 40,000 ms in the cruise leg | Completed nominal landing | | |
| 126 | | `front-abort` | Abort at 13,000 ms through front recovery | `aborted`, `vtol-abort-landed` | | |
| 127 | | `back-abort` | Abort at 30,000 ms through back recovery | `aborted`, `vtol-abort-landed` | | |
| 128 | | `front-recover-resume` | Recover at 13,000 ms, resume at 40,000 ms | Completed recovery landing | | |
| 129 | | `back-recover-resume` | Recover at 30,000 ms, resume at 80,000 ms | Completed recovery landing | | |
| 130 | | `pusher-open-circuit` | Remove pusher demand/thrust; let front confirmation time out | Completed recovery landing, no cruise or retry | | |
| 131 | | `back-recovery-hold` | Recover at 30,000 ms with no resume | Blocked by global endurance, no landing | | |
| 132 | ||
| 133 | Recovery cases require the original event, replacement cancellation and source task to match, plus | |
| 134 | the ordered mode confirmations. `operatorEvidence` retains actual actions, tasks before those actions, | |
| 135 | pause and recovery evidence. The hold case defaults to 180 virtual seconds so it reaches the existing | |
| 136 | 176,520 ms complete mission allowance. A 160-second run remains duration-exhausted and exits 1. | |
| 137 | The landing task may be queued while paused; it must never run without resume. The open-circuit case | |
| 138 | requires attempted pusher command, zero measured thrust and one front attempt. It does not model | |
| 139 | locked-rotor electrical current. | |
| 140 | ||
| 141 | For a named case, exit 0 means its specific expected outcome matched. It does not turn `blocked`, | |
| 142 | `host-lost` or `aborted` into a completed mission. `completeTransitions` still describes the nominal | |
| 143 | three confirmations. Invalid selectors, `--phase` on a non-fault case, mismatched outcomes or insufficient | |
| 144 | duration exit 1. A duration that excludes a scheduled operator action is rejected before resources | |
| 145 | are created. Custom schedules and recovery graphs remain TypeScript inputs to the shared runtime. | |
| 98 | 146 | |
| 99 | 147 | The controller receives measured relative airspeed, differential pressure, airflow angles and modeled |
| 100 | 148 | rotor/pusher response, together with the qualified raw gyro and estimated navigation. Delayed airflow |
| @@ -169,8 +217,8 @@ and releases outputs. Neither result establishes an airborne landing. | ||
| 169 | 217 | Task execution time stops during pause. Global endurance and the declared energy charge continue, |
| 170 | 218 | so a later scheduled resume can be preempted by reserve, endurance, health or authority refusal. |
| 171 | 219 | Operator actions never extend the original planning allowance or rearm revoked authority. The |
| 172 | operator schedules remain library-only; neither CLI command exposes them, and the common workspace | |
| 173 | does not select this VTOL profile. | |
| 220 | named presets above expose specific schedules through `vtol:simulate`. Custom schedules remain | |
| 221 | library inputs, and the common workspace does not select this VTOL profile. | |
| 174 | 222 | |
| 175 | 223 | ## Declared transition recovery routes |
| 176 | 224 | |
| @@ -292,7 +340,7 @@ it does not model a seized motor's electrical current. | ||
| 292 | 340 | |
| 293 | 341 | [Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal |
| 294 | 342 | controller and opt-in recovery routes do not establish complete VTOL support. Complete recovery/abort |
| 295 | envelopes, fresh-process restoration, identified hybrid power/voltage/cell behavior, CLI fault/operator scenarios and | |
| 343 | envelopes, fresh-process restoration, identified hybrid power/voltage/cell behavior and | |
| 296 | 344 | the common mission workspace still require implementation and qualification. Wind campaigns and |
| 297 | 345 | broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms, |
| 298 | 346 | helicopters and other airframe families remain separate work. Native board control, measured airframe |
| @@ -62,11 +62,11 @@ All evidence is owned physics simulation. The Pico neutral-only board image does | ||
| 62 | 62 | TypeScript controllers. Native board execution, external firmware SITL, HIL and measured physical |
| 63 | 63 | vehicle acceptance remain separate gates. A separate [VTOL development guide](vehicles-vtol.md) |
| 64 | 64 | covers the hover-only command and an experimental lift-and-cruise library profile with a native |
| 65 | `vtol:simulate` nominal-route command. The latter | |
| 65 | `vtol:simulate` command for nominal and named fault/operator scenarios. The latter | |
| 66 | 66 | executes a bounded modeled front/cruise/back/vertical-landing route with stable-phase pause and |
| 67 | 67 | resume, plus explicitly declared hover-and-land transition recovery. Complete recovery/abort |
| 68 | 68 | envelopes, measured hybrid energy qualification, |
| 69 | CLI fault/operator scenarios and a VTOL selection in the common workspace remain open. Helicopter, | |
| 69 | a VTOL selection in the common workspace remain open. Helicopter, | |
| 70 | 70 | soaring, blimp and other airframe families remain explicit roadmap work. |
| 71 | 71 | |
| 72 | 72 | Read [air](vehicles-air.md), [ground](vehicles-ground.md), [surface](vehicles-surface.md), [subsea](vehicles-subsea.md), |
| @@ -0,0 +1,121 @@ | ||
| 1 | import { createVtolTransitionProfile } from './transition-profile' | |
| 2 | import { runVehicleSimulation } from '../runtime' | |
| 3 | import type { VehicleMissionAction } from '../types' | |
| 4 | ||
| 5 | const faults = { 'host-loss': 'hostLost', 'imu-loss': 'dropImu', 'position-loss': 'dropPosition', 'vehicle-sensor-loss': 'dropVehicleSensor', 'rc-override': 'rcOverride', 'kill': 'kill' } as const | |
| 6 | interface OperatorCase { actions: readonly { atMs: number, action: Exclude<VehicleMissionAction, 'start'> }[], recovery?: 'front' | 'back', outcome: 'abort' | 'resume' | 'timeout' | 'hold-expiry', openCircuit?: boolean, seconds?: number } | |
| 7 | const operatorCases = { | |
| 8 | 'cruise-pause-resume': { actions: [{ atMs: 20000, action: 'pause' }, { atMs: 40000, action: 'resume' }], outcome: 'resume' }, | |
| 9 | 'front-abort': { actions: [{ atMs: 13000, action: 'abort' }], recovery: 'front', outcome: 'abort' }, | |
| 10 | 'back-abort': { actions: [{ atMs: 30000, action: 'abort' }], recovery: 'back', outcome: 'abort' }, | |
| 11 | 'front-recover-resume': { actions: [{ atMs: 13000, action: 'recover' }, { atMs: 40000, action: 'resume' }], recovery: 'front', outcome: 'resume' }, | |
| 12 | 'back-recover-resume': { actions: [{ atMs: 30000, action: 'recover' }, { atMs: 80000, action: 'resume' }], recovery: 'back', outcome: 'resume' }, | |
| 13 | 'pusher-open-circuit': { actions: [], recovery: 'front', outcome: 'timeout', openCircuit: true }, | |
| 14 | 'back-recovery-hold': { actions: [{ atMs: 30000, action: 'recover' }], recovery: 'back', outcome: 'hold-expiry', seconds: 180 }, | |
| 15 | } as const satisfies Record<string, OperatorCase> | |
| 16 | export const vtolSimulationScenarios = Object.freeze(['nominal', ...Object.keys(faults), ...Object.keys(operatorCases)]) | |
| 17 | export const vtolSimulationPhases = Object.freeze(['front', 'cruise', 'back', 'landing'] as const) | |
| 18 | const phases = [ | |
| 19 | { phase: 'front', atMs: 13000, taskId: 'front' }, | |
| 20 | { phase: 'cruise', atMs: 20000, taskId: 'cruise-leg' }, | |
| 21 | { phase: 'back', atMs: 30000, taskId: 'back' }, | |
| 22 | { phase: 'landing', atMs: 68000, taskId: 'land' }, | |
| 23 | ] as const | |
| 24 | interface SourceEpochs { atMs: number, attitudeCapturedAtMs: number | null, positionCapturedAtMs: number | null, measurementCapturedAtMs: number | null } | |
| 25 | ||
| 26 | /** Reproducible modeled cases on the existing runtime, not a physical control endpoint. */ | |
| 27 | export function runVtolSimulation(scenario = 'nominal', options: { seconds?: number, electrical?: boolean, phase?: string } = {}) { | |
| 28 | if (!(vtolSimulationScenarios as readonly string[]).includes(scenario)) throw new RangeError('Unknown VTOL simulation scenario') | |
| 29 | if (options.phase !== undefined && !(vtolSimulationPhases as readonly string[]).includes(options.phase)) throw new RangeError('Unknown VTOL simulation phase') | |
| 30 | const faultCase = Object.hasOwn(faults, scenario) | |
| 31 | if (!faultCase && options.phase !== undefined) throw new RangeError('VTOL phase selection requires a fault scenario') | |
| 32 | const phase = faultCase ? phases.find(row => row.phase === (options.phase ?? 'front')) : null | |
| 33 | if (phase === undefined) throw new RangeError('Unknown VTOL simulation phase') | |
| 34 | const profile = createVtolTransitionProfile({ electrical: options.electrical }) | |
| 35 | const operator: OperatorCase | undefined = Object.hasOwn(operatorCases, scenario) ? operatorCases[scenario as keyof typeof operatorCases] : undefined | |
| 36 | const actions = operator?.actions | |
| 37 | if (operator?.recovery) { | |
| 38 | const source = profile.mission.tasks.find(task => task.id === operator.recovery) | |
| 39 | if (!source || source.kind !== 'vtol-mode') throw new TypeError('Missing VTOL recovery source') | |
| 40 | source.onFailure = 'recover-hover' | |
| 41 | const back = operator.recovery === 'back' | |
| 42 | profile.mission.tasks.push( | |
| 43 | { id: 'recover-hover', kind: 'vtol-mode', mode: 'hover', headingRad: 0, timeoutMs: back ? 40000 : 45000, next: 'recover-land' }, | |
| 44 | { id: 'recover-land', kind: 'land', positionM: [back ? 180 : 0, 0, 0], acceptanceM: 0.3, maxArrivalSpeedMps: 0.2, settleMs: 200, timeoutMs: back ? 30000 : 35000 }, | |
| 45 | ) | |
| 46 | } | |
| 47 | if (scenario !== 'nominal') { profile.scenario = scenario; profile.scenarios = vtolSimulationScenarios } | |
| 48 | const sources: { last: SourceEpochs | null } = { last: null } | |
| 49 | if (phase) { | |
| 50 | const fault = faults[scenario as keyof typeof faults], factory = profile.createSession | |
| 51 | profile.faults = (_scenario, atMs) => atMs >= phase.atMs ? { [fault]: true } : {} | |
| 52 | profile.createSession = () => { | |
| 53 | const session = factory() | |
| 54 | return { ...session, createController() { | |
| 55 | const controller = session.createController() | |
| 56 | return { step(context) { | |
| 57 | sources.last = { atMs: context.nowMs, attitudeCapturedAtMs: context.estimate.attitude?.capturedAtMs ?? null, positionCapturedAtMs: context.estimate.localPosition?.capturedAtMs ?? null, measurementCapturedAtMs: context.measurement?.capturedAtMs ?? null } | |
| 58 | return controller.step(context) | |
| 59 | } } | |
| 60 | } } | |
| 61 | } | |
| 62 | } | |
| 63 | if (operator?.openCircuit) { | |
| 64 | const factory = profile.createSession | |
| 65 | profile.createSession = () => { | |
| 66 | const session = factory() | |
| 67 | return { ...session, createPlant() { | |
| 68 | const plant = session.createPlant() | |
| 69 | return { snapshot: () => plant.snapshot(), step(input, dtS, world) { | |
| 70 | const demand = [...input] | |
| 71 | demand[4] = 0 | |
| 72 | return plant.step(demand, dtS, world) | |
| 73 | } } | |
| 74 | } } | |
| 75 | } | |
| 76 | } | |
| 77 | const result = runVehicleSimulation(profile, { seconds: options.seconds ?? operator?.seconds ?? 160, missionActions: actions }) | |
| 78 | const { trace, path, ...report } = result | |
| 79 | const transitionEvidence = trace.rows.flatMap(row => row.observation.transition?.outcome === 'confirmed' ? [row.observation.transition] : []) | |
| 80 | const confirmed = (tasks: readonly string[]) => transitionEvidence.length === tasks.length && transitionEvidence.every((row, index) => row.taskId === tasks[index] && row.phase === (row.taskId === 'front' ? 'cruise' : 'hover')) | |
| 81 | const completeTransitions = confirmed(['initial-hover', 'front', 'back']) | |
| 82 | const observedFlight = path.some(row => row.positionM && row.positionM[2] < -1 && row.actuators.slice(0, 4).some(n => n > 0)) | |
| 83 | const landingContact = path.at(-1)?.measurement?.values.onSurface === true | |
| 84 | const boardNeutral = result.board.actuators.every(n => n === 0) && result.board.expiresAtMs === null | |
| 85 | const before = phase ? path.find(row => row.atMs === phase.atMs - 20) : undefined | |
| 86 | let faultObserved = false | |
| 87 | if (phase && before?.mission.taskId === phase.taskId) { | |
| 88 | if (scenario === 'host-loss') faultObserved = result.status === 'host-lost' && result.board.fault === 'output-lease-expired' && result.board.lastCheckedAtMs !== null && result.board.lastCheckedAtMs >= phase.atMs + 100 && result.authority.lastCheckedAtMs !== null && result.authority.lastCheckedAtMs < phase.atMs | |
| 89 | else if (result.status === 'blocked' && sources.last && sources.last.atMs >= phase.atMs && sources.last.atMs <= phase.atMs + 500) { | |
| 90 | const last = sources.last | |
| 91 | const lost = (epoch: number | null) => epoch === null || epoch < phase.atMs | |
| 92 | if (scenario === 'imu-loss') faultObserved = lost(last.attitudeCapturedAtMs) | |
| 93 | if (scenario === 'position-loss') faultObserved = lost(last.positionCapturedAtMs) && !lost(last.attitudeCapturedAtMs) | |
| 94 | if (scenario === 'vehicle-sensor-loss') faultObserved = lost(last.measurementCapturedAtMs) && !lost(last.attitudeCapturedAtMs) && !lost(last.positionCapturedAtMs) | |
| 95 | if (scenario === 'rc-override' || scenario === 'kill') faultObserved = trace.rows.some(row => row.observation.nowMs === phase.atMs && row.observation.authority === (scenario === 'kill' ? 'killed' : 'manual')) | |
| 96 | } | |
| 97 | } | |
| 98 | const completedRoute = result.status === 'completed' && result.fault === null && result.mission.taskId === 'land' && result.mission.reason === 'landed' && completeTransitions && observedFlight && landingContact && boardNeutral | |
| 99 | const actualActions = trace.rows.flatMap(row => row.action && row.action !== 'start' ? [{ atMs: row.observation.nowMs, action: row.action }] : []) | |
| 100 | const tasksBeforeActions = actions?.map(action => { | |
| 101 | const previous = path.find(row => row.atMs === action.atMs - 20)?.mission | |
| 102 | return { atMs: action.atMs, taskId: previous?.taskId ?? null, state: previous?.state ?? null } | |
| 103 | }) ?? [] | |
| 104 | const observedPaused = trace.rows.some(row => row.result.state === 'paused') | |
| 105 | const failedRequest = path.find(row => row.mission.transition?.taskId === operator?.recovery)?.mission.transition | |
| 106 | const replacement = path.find(row => row.mission.transition?.taskId === 'recover-hover')?.mission.transition | |
| 107 | const recovery = result.mission.recovery | |
| 108 | const recoveryCorrelated = !!operator?.recovery && !!failedRequest && !!replacement && replacement.supersedesEventId === failedRequest.eventId && recovery?.failedEventId === failedRequest.eventId && recovery.failedTaskId === operator.recovery && recovery.triggeredAtMs === (operator.outcome === 'timeout' ? failedRequest.expiresAtMs : actions?.[0]?.atMs) && recovery.cause === (operator.outcome === 'timeout' ? 'timeout' : operator.outcome === 'abort' ? 'operator-abort' : 'operator-recover') | |
| 109 | const recoveryReason = operator?.outcome === 'abort' ? 'vtol-abort-landed' : 'vtol-recovery-landed' | |
| 110 | const recoveredRoute = !!operator?.recovery && recoveryCorrelated && confirmed(operator.recovery === 'back' ? ['initial-hover', 'front', 'recover-hover'] : ['initial-hover', 'recover-hover']) && result.mission.taskId === 'recover-land' && result.mission.state === (operator.outcome === 'abort' ? 'aborted' : 'completed') && result.mission.reason === recoveryReason && result.status === (operator.outcome === 'abort' ? 'blocked' : 'completed') && result.fault === (operator.outcome === 'abort' ? recoveryReason : null) && observedFlight && landingContact && boardNeutral | |
| 111 | const pusherOpenCircuitObserved = !!operator?.openCircuit && path.some(row => row.actuators[4]! > 0.1) && path.every(row => row.measurement?.values.pusherThrustN === 0) && !path.some(row => row.mission.taskId === 'cruise-leg') && result.transitions.filter(row => row.taskId === 'front').length === 1 | |
| 112 | const lastAtMs = trace.rows.at(-1)?.observation.nowMs ?? 0 | |
| 113 | const landingStarted = path.some(row => row.mission.taskId === 'recover-land' && row.mission.state === 'running') | |
| 114 | const globalEnduranceExpired = operator?.outcome === 'hold-expiry' && recoveryCorrelated && confirmed(['initial-hover', 'front', 'recover-hover']) && result.status === 'blocked' && result.mission.state === 'blocked' && result.mission.reason === 'mission-endurance-exhausted' && result.fault === 'mission-endurance-exhausted' && lastAtMs > 600 + result.budget.worstCaseMs && lastAtMs <= 620 + result.budget.worstCaseMs && !landingStarted && observedFlight && !landingContact && boardNeutral | |
| 115 | const operatorPhaseMatched = operator?.recovery ? recoveryCorrelated : tasksBeforeActions.length === 2 && tasksBeforeActions[0]?.taskId === 'cruise-leg' && tasksBeforeActions[0].state === 'running' && tasksBeforeActions[1]?.taskId === 'cruise-leg' && tasksBeforeActions[1].state === 'paused' | |
| 116 | const operatorMatched = !!operator && operatorPhaseMatched && JSON.stringify(actualActions) === JSON.stringify(actions) && (!['resume', 'hold-expiry'].includes(operator.outcome) || observedPaused) && (operator.outcome === 'hold-expiry' ? globalEnduranceExpired : operator.recovery ? recoveredRoute : completedRoute) && (!operator.openCircuit || pusherOpenCircuitObserved) | |
| 117 | const demonstrationMatched = scenario === 'nominal' ? completedRoute : actions ? operatorMatched : faultObserved && observedFlight && !landingContact && result.mission.state !== 'completed' && boardNeutral | |
| 118 | return { ...report, scope: 'development-lift-and-cruise' as const, transitionEvidence, completeTransitions, observedFlight, landingContact, boardNeutral, demonstrationMatched, recordedMissionRows: trace.rows.length, evaluatedPathRows: path.length, | |
| 119 | ...(phase ? { scenarioEvidence: { phase: phase.phase, injectedAtMs: phase.atMs, taskId: before?.mission.taskId ?? null, faultObserved, sources: sources.last } } : {}), | |
| 120 | ...(actions ? { operatorEvidence: { actions: actualActions, tasksBeforeActions, phaseMatched: operatorPhaseMatched, observedPaused, matched: operatorMatched, ...(operator?.recovery ? { recoveryCorrelated } : {}), ...(operator?.openCircuit ? { pusherOpenCircuitObserved } : {}), ...(operator?.outcome === 'hold-expiry' ? { globalEnduranceExpired, landingStarted } : {}) } } : {}) } | |
| 121 | } | |
| @@ -78,6 +78,7 @@ | ||
| 78 | 78 | "./unit/vtol-energy.test.ts", |
| 79 | 79 | "./unit/vtol-cli.test.ts", |
| 80 | 80 | "./unit/vtol-transition-cli.test.ts", |
| 81 | "./unit/vtol-scenarios.test.ts", | |
| 81 | 82 | "./unit/vtol-mode-mission.test.ts", |
| 82 | 83 | "./unit/vtol-transition.test.ts", |
| 83 | 84 | "./unit/ground.test.ts", |
| @@ -0,0 +1,153 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { runVtolSimulation } from '../../resources/functions/drone/vehicles/vtol/simulation' | |
| 3 | ||
| 4 | test('VTOL front IMU loss reports an observed source failure without claiming landing', () => { | |
| 5 | const report = runVtolSimulation('imu-loss', { electrical: true }) | |
| 6 | expect(report.status).toBe('blocked') | |
| 7 | expect(report.demonstrationMatched).toBe(true) | |
| 8 | expect(report.scenarioEvidence).toMatchObject({ phase: 'front', injectedAtMs: 13000, taskId: 'front', faultObserved: true }) | |
| 9 | expect(report.mission.state).toBe('blocked') | |
| 10 | expect(report.landingContact).toBe(false) | |
| 11 | expect(report.boardNeutral).toBe(true) | |
| 12 | expect(report.physicalFlightEnabled).toBe(false) | |
| 13 | }) | |
| 14 | ||
| 15 | for (const electrical of [false, true]) for (const [phase, atMs, taskId] of [['front', 13000, 'front'], ['cruise', 20000, 'cruise-leg'], ['back', 30000, 'back'], ['landing', 68000, 'land']] as const) { | |
| 16 | for (const scenario of ['host-loss', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill']) test(`${phase} ${scenario} binds the actual loss with electrical=${electrical}`, () => { | |
| 17 | const report = runVtolSimulation(scenario, { phase, electrical }) | |
| 18 | expect(report.status).toBe(scenario === 'host-loss' ? 'host-lost' : 'blocked') | |
| 19 | expect(report.scenarioEvidence).toMatchObject({ phase, injectedAtMs: atMs, taskId, faultObserved: true }) | |
| 20 | expect(report.demonstrationMatched).toBe(true) | |
| 21 | expect(report.mission.state).not.toBe('completed') | |
| 22 | expect(report.landingContact).toBe(false) | |
| 23 | expect(report.observedFlight).toBe(true) | |
| 24 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 25 | expect(report.board.expiresAtMs).toBeNull() | |
| 26 | expect(report.physicalFlightEnabled).toBe(false) | |
| 27 | if (electrical) expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0) | |
| 28 | if (scenario === 'host-loss') { | |
| 29 | expect(report.authority.lastCheckedAtMs).toBe(atMs - 20) | |
| 30 | expect(report.board.fault).toBe('output-lease-expired') | |
| 31 | expect(report.board.lastCheckedAtMs).toBeGreaterThanOrEqual(atMs + 100) | |
| 32 | } | |
| 33 | else { | |
| 34 | expect(report.mission.state).toBe('blocked') | |
| 35 | expect(report.scenarioEvidence!.sources!.atMs).toBeGreaterThanOrEqual(atMs) | |
| 36 | expect(report.scenarioEvidence!.sources!.atMs).toBeLessThanOrEqual(atMs + 500) | |
| 37 | } | |
| 38 | if (scenario === 'rc-override') expect(report.authority.events.some(event => event.to === 'manual')).toBe(true) | |
| 39 | if (scenario === 'kill') expect(report.authority.state).toBe('killed') | |
| 40 | }) | |
| 41 | } | |
| 42 | ||
| 43 | test('unknown or inapplicable selectors and pre-onset duration cannot qualify a fault case', () => { | |
| 44 | expect(() => runVtolSimulation('foreign')).toThrow('Unknown VTOL simulation scenario') | |
| 45 | expect(() => runVtolSimulation('host-loss', { phase: 'foreign' })).toThrow('Unknown VTOL simulation phase') | |
| 46 | expect(() => runVtolSimulation('host-loss', { phase: null } as never)).toThrow('Unknown VTOL simulation phase') | |
| 47 | expect(() => runVtolSimulation('nominal', { phase: 'back' })).toThrow('requires a fault scenario') | |
| 48 | for (const scenario of ['host-loss', 'imu-loss', 'position-loss', 'vehicle-sensor-loss', 'rc-override', 'kill']) { | |
| 49 | const report = runVtolSimulation(scenario, { seconds: 2 }) | |
| 50 | expect(report.status).toBe('duration-exhausted') | |
| 51 | expect(report.scenarioEvidence).toMatchObject({ taskId: null, faultObserved: false }) | |
| 52 | expect(report.demonstrationMatched).toBe(false) | |
| 53 | } | |
| 54 | }) | |
| 55 | ||
| 56 | test('cruise pause and resume require both observed operator actions and the full route', () => { | |
| 57 | const report = runVtolSimulation('cruise-pause-resume', { electrical: true }) | |
| 58 | expect(report.status).toBe('completed') | |
| 59 | expect(report.demonstrationMatched).toBe(true) | |
| 60 | expect(report.completeTransitions).toBe(true) | |
| 61 | expect(report.operatorEvidence).toMatchObject({ matched: true, observedPaused: true, actions: [{ atMs: 20000, action: 'pause' }, { atMs: 40000, action: 'resume' }] }) | |
| 62 | expect(report.operatorEvidence!.tasksBeforeActions).toEqual([{ atMs: 20000, taskId: 'cruise-leg', state: 'running' }, { atMs: 40000, taskId: 'cruise-leg', state: 'paused' }]) | |
| 63 | expect(report.operatorEvidence!.phaseMatched).toBe(true) | |
| 64 | expect(report.mission.reason).toBe('landed') | |
| 65 | expect(report.landingContact).toBe(true) | |
| 66 | expect(report.boardNeutral).toBe(true) | |
| 67 | expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0) | |
| 68 | }) | |
| 69 | ||
| 70 | test('front abort requires declared recovery, correlated hover and contact with an aborted outcome', () => { | |
| 71 | const report = runVtolSimulation('front-abort', { electrical: true }) | |
| 72 | expect(report.status).toBe('blocked') | |
| 73 | expect(report.mission.state).toBe('aborted') | |
| 74 | expect(report.mission.reason).toBe('vtol-abort-landed') | |
| 75 | expect(report.demonstrationMatched).toBe(true) | |
| 76 | expect(report.completeTransitions).toBe(false) | |
| 77 | expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'recover-hover']) | |
| 78 | expect(report.operatorEvidence).toMatchObject({ matched: true, actions: [{ atMs: 13000, action: 'abort' }], recoveryCorrelated: true }) | |
| 79 | expect(report.mission.recovery).toMatchObject({ failedTaskId: 'front', cause: 'operator-abort', triggeredAtMs: 13000 }) | |
| 80 | expect(report.landingContact).toBe(true) | |
| 81 | expect(report.boardNeutral).toBe(true) | |
| 82 | }) | |
| 83 | ||
| 84 | test('back recovery holds qualified hover until explicit resume and preserves its complete budget', () => { | |
| 85 | const report = runVtolSimulation('back-recover-resume', { seconds: 180, electrical: true }) | |
| 86 | expect(report.demonstrationMatched).toBe(true) | |
| 87 | expect(report.mission.state).toBe('completed') | |
| 88 | expect(report.mission.reason).toBe('vtol-recovery-landed') | |
| 89 | expect(report.mission.recovery).toMatchObject({ failedTaskId: 'back', cause: 'operator-recover', triggeredAtMs: 30000 }) | |
| 90 | expect(report.operatorEvidence).toMatchObject({ actions: [{ atMs: 30000, action: 'recover' }, { atMs: 80000, action: 'resume' }], observedPaused: true, recoveryCorrelated: true, matched: true }) | |
| 91 | expect(report.budget.worstCaseMs).toBe(176520) | |
| 92 | expect(report.budget.requiredWh).toBeCloseTo(58.84, 8) | |
| 93 | expect(report.landingContact).toBe(true) | |
| 94 | expect(report.boardNeutral).toBe(true) | |
| 95 | }) | |
| 96 | ||
| 97 | test('the open-circuit pusher case confirms timeout recovery without inventing cruise or retry', () => { | |
| 98 | const report = runVtolSimulation('pusher-open-circuit', { electrical: true }) | |
| 99 | expect(report.status).toBe('completed') | |
| 100 | expect(report.mission.reason).toBe('vtol-recovery-landed') | |
| 101 | expect(report.mission.recovery?.cause).toBe('timeout') | |
| 102 | expect(report.demonstrationMatched).toBe(true) | |
| 103 | expect(report.completeTransitions).toBe(false) | |
| 104 | expect(report.transitionEvidence.map(row => row.taskId)).toEqual(['initial-hover', 'recover-hover']) | |
| 105 | expect(report.operatorEvidence).toMatchObject({ actions: [], recoveryCorrelated: true, pusherOpenCircuitObserved: true, matched: true }) | |
| 106 | expect(report.landingContact).toBe(true) | |
| 107 | expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0) | |
| 108 | }) | |
| 109 | ||
| 110 | test('unresumed recovery reaches the global endurance limit without a landing claim', () => { | |
| 111 | const report = runVtolSimulation('back-recovery-hold', { electrical: true }) | |
| 112 | expect(report.status).toBe('blocked') | |
| 113 | expect(report.mission.reason).toBe('mission-endurance-exhausted') | |
| 114 | expect(report.demonstrationMatched).toBe(true) | |
| 115 | expect(report.landingContact).toBe(false) | |
| 116 | expect(report.boardNeutral).toBe(true) | |
| 117 | expect(report.operatorEvidence).toMatchObject({ actions: [{ atMs: 30000, action: 'recover' }], observedPaused: true, recoveryCorrelated: true, globalEnduranceExpired: true, landingStarted: false, matched: true }) | |
| 118 | const short = runVtolSimulation('back-recovery-hold', { seconds: 160 }) | |
| 119 | expect(short.status).toBe('duration-exhausted') | |
| 120 | expect(short.demonstrationMatched).toBe(false) | |
| 121 | expect(short.operatorEvidence!.globalEnduranceExpired).toBe(false) | |
| 122 | }) | |
| 123 | ||
| 124 | for (const electrical of [false, true]) for (const [scenario, reason] of [ | |
| 125 | ['cruise-pause-resume', 'landed'], ['front-abort', 'vtol-abort-landed'], ['back-abort', 'vtol-abort-landed'], | |
| 126 | ['front-recover-resume', 'vtol-recovery-landed'], ['back-recover-resume', 'vtol-recovery-landed'], | |
| 127 | ['pusher-open-circuit', 'vtol-recovery-landed'], ['back-recovery-hold', 'mission-endurance-exhausted'], | |
| 128 | ] as const) test(`${scenario} keeps its declared outcome with electrical=${electrical}`, () => { | |
| 129 | const report = runVtolSimulation(scenario, { electrical }) | |
| 130 | expect(report.demonstrationMatched).toBe(true) | |
| 131 | expect(report.operatorEvidence!.matched).toBe(true) | |
| 132 | expect(report.operatorEvidence!.phaseMatched).toBe(true) | |
| 133 | expect(report.mission.reason).toBe(reason) | |
| 134 | expect(report.mission.state).toBe(reason === 'vtol-abort-landed' ? 'aborted' : reason === 'mission-endurance-exhausted' ? 'blocked' : 'completed') | |
| 135 | expect(report.status).toBe(['vtol-abort-landed', 'mission-endurance-exhausted'].includes(reason) ? 'blocked' : 'completed') | |
| 136 | expect(report.landingContact).toBe(reason !== 'mission-endurance-exhausted') | |
| 137 | expect(report.boardNeutral).toBe(true) | |
| 138 | expect(report.physicalFlightEnabled).toBe(false) | |
| 139 | if (electrical) expect(report.energy!.lastObservation!.energyUsedWh).toBeGreaterThan(0) | |
| 140 | if (scenario !== 'cruise-pause-resume') expect(report.operatorEvidence!.recoveryCorrelated).toBe(true) | |
| 141 | }) | |
| 142 | ||
| 143 | test('operator presets refuse phase selectors and duration that would drop scheduled actions', () => { | |
| 144 | expect(() => runVtolSimulation('front-abort', { phase: 'front' })).toThrow('requires a fault scenario') | |
| 145 | expect(() => runVtolSimulation('cruise-pause-resume', { seconds: 30 })).toThrow('action schedule') | |
| 146 | expect(() => runVtolSimulation('back-recover-resume', { seconds: 60 })).toThrow('action schedule') | |
| 147 | const first = runVtolSimulation('back-abort', { electrical: true }) | |
| 148 | first.operatorEvidence!.actions[0]!.atMs = 700 | |
| 149 | first.operatorEvidence!.matched = false | |
| 150 | const second = runVtolSimulation('back-abort', { electrical: true }) | |
| 151 | expect(second.operatorEvidence!.actions).toEqual([{ atMs: 30000, action: 'abort' }]) | |
| 152 | expect(second.demonstrationMatched).toBe(true) | |
| 153 | }) | |
| @@ -1,13 +1,18 @@ | ||
| 1 | 1 | import { expect, test } from 'bun:test' |
| 2 | 2 | import { command } from '@stacksjs/testing' |
| 3 | 3 | |
| 4 | test('CI qualifies both full-route commands outside the checkout', async () => { | |
| 4 | test('CI qualifies nominal and failure full-route commands outside the checkout', async () => { | |
| 5 | 5 | const workflow = Bun.YAML.parse(await Bun.file(new URL('../../.github/workflows/ci.yml', import.meta.url)).text()) as { |
| 6 | 6 | jobs: { check: { steps: { run?: string, 'working-directory'?: string }[] } } |
| 7 | 7 | } |
| 8 | 8 | expect(workflow.jobs.check.steps.filter(step => step.run?.startsWith('$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate')).map(step => ({ command: step.run, directory: step['working-directory'] }))).toEqual([ |
| 9 | 9 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate', directory: '$' }, |
| 10 | 10 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --electrical', directory: '$' }, |
| 11 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario host-loss --phase back --electrical', directory: '$' }, | |
| 12 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario cruise-pause-resume --electrical', directory: '$' }, | |
| 13 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario front-abort --electrical', directory: '$' }, | |
| 14 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario pusher-open-circuit --electrical', directory: '$' }, | |
| 15 | { command: '$GITHUB_WORKSPACE/storage/runtime-build/cli/drone vtol:simulate --scenario back-recovery-hold --electrical', directory: '$' }, | |
| 11 | 16 | ]) |
| 12 | 17 | }) |
| 13 | 18 | |
| @@ -71,3 +76,47 @@ test('native full-route command preserves partial outcomes and rejects invalid d | ||
| 71 | 76 | expect(invalid.stdout).toBe('') |
| 72 | 77 | } |
| 73 | 78 | }, 90000) |
| 79 | ||
| 80 | test('native full-route host loss binds the selected back phase and independent lease expiry', async () => { | |
| 81 | const result = await command(['vtol:simulate', '--scenario', 'host-loss', '--phase', 'back', '--electrical']).withTimeout(60000) | |
| 82 | expect(result.exitCode).toBe(0) | |
| 83 | const report = JSON.parse(result.stdout) | |
| 84 | expect(report.scenario).toBe('host-loss') | |
| 85 | expect(report.status).toBe('host-lost') | |
| 86 | expect(report.demonstrationMatched).toBe(true) | |
| 87 | expect(report.scenarioEvidence).toMatchObject({ phase: 'back', injectedAtMs: 30000, taskId: 'back', faultObserved: true }) | |
| 88 | expect(report.board.fault).toBe('output-lease-expired') | |
| 89 | expect(report.board.lastCheckedAtMs).toBeGreaterThanOrEqual(30100) | |
| 90 | expect(report.authority.lastCheckedAtMs).toBeLessThan(30000) | |
| 91 | expect(report.mission.state).toBe('running') | |
| 92 | expect(report.landingContact).toBe(false) | |
| 93 | expect(report.boardNeutral).toBe(true) | |
| 94 | expect(report.energy.lastObservation.observedAtMs).toBe(29980) | |
| 95 | }, 90000) | |
| 96 | ||
| 97 | test('native named abort and recovery hold retain different failure outcomes with neutral outputs', async () => { | |
| 98 | for (const [scenario, reason] of [['front-abort', 'vtol-abort-landed'], ['back-recovery-hold', 'mission-endurance-exhausted']]) { | |
| 99 | const result = await command(['vtol:simulate', '--scenario', scenario!, '--electrical']).withTimeout(60000) | |
| 100 | expect(result.exitCode).toBe(0) | |
| 101 | const report = JSON.parse(result.stdout) | |
| 102 | expect(report.status).toBe('blocked') | |
| 103 | expect(report.mission.reason).toBe(reason) | |
| 104 | expect(report.demonstrationMatched).toBe(true) | |
| 105 | expect(report.landingContact).toBe(scenario === 'front-abort') | |
| 106 | expect(report.boardNeutral).toBe(true) | |
| 107 | } | |
| 108 | }, 90000) | |
| 109 | ||
| 110 | test('native scenario selectors cannot silently become a nominal demonstration', async () => { | |
| 111 | for (const args of [['--scenario', 'foreign'], ['--scenario', 'host-loss', '--phase', 'foreign'], ['--phase', 'front'], ['--scenario', 'cruise-pause-resume', '--phase', 'back'], ['--scenario', 'back-recover-resume', '--seconds', '60']]) { | |
| 112 | const invalid = await command(['vtol:simulate', ...args]).withTimeout(60000) | |
| 113 | expect(invalid.exitCode).toBe(1) | |
| 114 | expect(invalid.stdout).toBe('') | |
| 115 | } | |
| 116 | const short = await command(['vtol:simulate', '--scenario', 'host-loss', '--seconds', '2']).withTimeout(60000) | |
| 117 | expect(short.exitCode).toBe(1) | |
| 118 | const report = JSON.parse(short.stdout) | |
| 119 | expect(report.status).toBe('duration-exhausted') | |
| 120 | expect(report.demonstrationMatched).toBe(false) | |
| 121 | expect(report.scenarioEvidence.faultObserved).toBe(false) | |
| 122 | }, 90000) | |