also looking at this
feat: supervise modeled VTOL mission actions
#79
13 files
+962
-24
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.
| @@ -76,9 +76,11 @@ These profiles use owned raw sensors, estimation, mission policy, controls and a | ||
| 76 | 76 | outputs remain inhibited. Read the [shared contract](docs/vehicles.md), [air model](docs/vehicles-air.md), |
| 77 | 77 | [ground model](docs/vehicles-ground.md) and [surface corrections](docs/vehicles-surface.md) and [subsea pressure/DVL inspection](docs/vehicles-subsea.md). |
| 78 | 78 | |
| 79 | `./buddy vtol:hover` separately exercises [VTOL hover development](docs/vehicles-vtol.md): | |
| 80 | vertical takeoff, a nearby hover waypoint and contact-gated landing. Wing transitions, cruise, | |
| 81 | hybrid energy qualification and a selectable VTOL mission workspace remain open. | |
| 79 | `./buddy vtol:hover` separately exercises [VTOL development](docs/vehicles-vtol.md): | |
| 80 | vertical takeoff, a nearby hover waypoint and contact-gated landing. The TypeScript library also | |
| 81 | includes a bounded modeled front/cruise/back experiment with stable-phase pause and resume. The | |
| 82 | command stays hover-only. Controlled transition recovery and abort landing, complete hybrid energy | |
| 83 | qualification and a selectable VTOL mission workspace remain open. | |
| 82 | 84 | |
| 83 | 85 | ## Simulated carried load |
| 84 | 86 | |
| @@ -76,7 +76,7 @@ const config: BunPressOptions = { | ||
| 76 | 76 | items: [ |
| 77 | 77 | { text: 'Owned vehicle runtime', link: '/vehicles' }, |
| 78 | 78 | { text: 'Owned fixed-wing profile', link: '/vehicles-air' }, |
| 79 | { text: 'VTOL hover development', link: '/vehicles-vtol' }, | |
| 79 | { text: 'VTOL development', link: '/vehicles-vtol' }, | |
| 80 | 80 | { text: 'Owned ground vehicle profile', link: '/vehicles-ground' }, |
| 81 | 81 | { text: 'Owned surface vessel profile', link: '/vehicles-surface' }, |
| 82 | 82 | { text: 'Owned subsea inspection profile', link: '/vehicles-subsea' }, |
| @@ -56,8 +56,10 @@ 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 hover development command](vehicles-vtol.md) exercises vertical takeoff, | |
| 60 | hover and landing. Front/cruise/back transitions and the common VTOL mission workspace remain open. | |
| 59 | The separate [VTOL development guide](vehicles-vtol.md) covers the hover-only command and an | |
| 60 | experimental TypeScript library profile for modeled front transition, cruise, back transition and | |
| 61 | vertical landing, with stable-phase pause and resume. Controlled transition recovery and abort | |
| 62 | landing, complete hybrid energy qualification and the common VTOL mission workspace remain open. | |
| 61 | 63 | |
| 62 | 64 | ## Explore a workflow |
| 63 | 65 | |
| @@ -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 and [VTOL hover development](../vehicles-vtol.md) with vertical takeoff/hover/contact-gated landing; VTOL transitions/cruise, hybrid energy, helicopter, soaring, blimp and experimental 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 VTOL command and [experimental VTOL library](../vehicles-vtol.md) with modeled front/cruise/back/vertical landing and stable-phase pause/resume; controlled transition recovery/abort landing, complete hybrid energy, native transition CLI/workspace, 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) | |
| @@ -1,4 +1,4 @@ | ||
| 1 | # VTOL hover development | |
| 1 | # VTOL development | |
| 2 | 2 | |
| 3 | 3 | The owned lift-and-cruise model can execute a **hover-only** mission through the shared raw sensor, |
| 4 | 4 | estimator, mission, authority and independent output-lease runtime. It takes off vertically, reaches |
| @@ -106,11 +106,54 @@ reference and clock faults latch. Real modeled sensor loss, RC override, kill an | ||
| 106 | 106 | cover front transition, cruise, back transition and post-back landing through the independent output |
| 107 | 107 | lease. Neutral outputs during an airborne failure do not demonstrate a safe recovery or landing. |
| 108 | 108 | |
| 109 | ## Operator actions in modeled sessions | |
| 110 | ||
| 111 | The shared runtime accepts a captured schedule of `pause`, `resume`, `recover` and `abort` actions. | |
| 112 | For example, from a TypeScript file in the project root: | |
| 113 | ||
| 114 | ```ts | |
| 115 | import { runVehicleSimulation } from './resources/functions/drone/vehicles/runtime' | |
| 116 | import { createVtolTransitionProfile } from './resources/functions/drone/vehicles/vtol/transition-profile' | |
| 117 | ||
| 118 | const report = runVehicleSimulation(createVtolTransitionProfile(), { | |
| 119 | seconds: 160, | |
| 120 | missionActions: [ | |
| 121 | { atMs: 20000, action: 'pause' }, | |
| 122 | { atMs: 40000, action: 'resume' }, | |
| 123 | ], | |
| 124 | }) | |
| 125 | ``` | |
| 126 | ||
| 127 | Each schedule record contains only `atMs` and `action`. Up to 32 events must have unique, | |
| 128 | strictly increasing 20 ms epochs after the automatic start at 600 ms and within the requested | |
| 129 | simulation duration. Validation and capture happen before session resources are created. Events | |
| 130 | are consumed once by the existing mission policy and retained in its replayable trace. An omitted | |
| 131 | or empty schedule preserves prior recordings and report fields. | |
| 132 | ||
| 133 | A qualified hover pause holds the captured estimated position. Wing-borne cruise uses a captured | |
| 134 | clockwise 60 m holding circle, with coordinated bank and radial course feedback. The complete circle | |
| 135 | must fit inside the declared 400 m horizontal envelope. Straight and banked-entry model campaigns | |
| 136 | hold the same 8 m radial-error bound after six seconds, maintain wing support and height, then resume | |
| 137 | the route and land. Post-back airborne pause also arrests descent around the captured position. | |
| 138 | Qualified stopped ground contact stays neutral during pause, with source and envelope checks applied | |
| 139 | before that return. Sensor loss, RC takeover, kill and host loss retain authority withdrawal. | |
| 140 | ||
| 141 | `recover` pauses the already running session until explicit `resume`. Fresh-process restoration after | |
| 142 | host loss or a changed estimator reference remains a separate gate. Pending initial-hover, front or | |
| 143 | back mode work retains uncertain-outcome refusal for pause/recover. Those refusals release modeled | |
| 144 | outputs; a controlled airborne recovery and observed landing still require an explicit route. | |
| 145 | `abort` terminates the modeled mission and releases outputs as well. | |
| 146 | ||
| 147 | Task execution time stops during pause. Global endurance and the declared energy charge continue, | |
| 148 | so a later scheduled resume can be preempted by reserve, endurance, health or authority refusal. | |
| 149 | Operator actions never extend the original planning allowance or rearm revoked authority. The | |
| 150 | transition library and these actions remain separate from the hover-only CLI and common workspace. | |
| 151 | ||
| 109 | 152 | ## Remaining implementation |
| 110 | 153 | |
| 111 | 154 | [Issue #12](https://github.com/stacksjs/drone/issues/12) remains open. The experimental nominal |
| 112 | controller does not establish complete VTOL support. Phase pause/recovery, explicit bounded | |
| 113 | transition abort and landing, the complete hybrid power/reserve model, native transition CLI and | |
| 155 | controller does not establish complete VTOL support. Controlled front/back recovery and fresh-process | |
| 156 | restoration, explicit bounded transition abort and landing, the complete hybrid power/reserve model, native transition CLI and | |
| 114 | 157 | the common mission workspace still require implementation and qualification. Wind campaigns and |
| 115 | 158 | broader mission/envelope coverage remain necessary. Full TECS/soaring, tiltrotor/tailsitter mechanisms, |
| 116 | 159 | helicopters and other airframe families remain separate work. Native board control, measured airframe |
| @@ -60,10 +60,12 @@ interlocks and host loss as applicable. Tests compare public decisions and physi | ||
| 60 | 60 | recompute mission replay, reject altered traces and exercise payload uncertainty and session reuse. |
| 61 | 61 | All evidence is owned physics simulation. The Pico neutral-only board image does not run these |
| 62 | 62 | TypeScript controllers. Native board execution, external firmware SITL, HIL and measured physical |
| 63 | vehicle acceptance remain separate gates. VTOL, helicopter, soaring, blimp and other vehicle | |
| 64 | families remain explicit roadmap work. A separate [VTOL hover development command](vehicles-vtol.md) | |
| 65 | now exercises vertical takeoff, a nearby hover waypoint and contact-gated landing. It does not | |
| 66 | implement wing transitions or add a VTOL selection to the common workspace. | |
| 63 | vehicle acceptance remain separate gates. A separate [VTOL development guide](vehicles-vtol.md) | |
| 64 | covers the hover-only command and an experimental lift-and-cruise library profile. The latter | |
| 65 | executes a bounded modeled front/cruise/back/vertical-landing route with stable-phase pause and | |
| 66 | resume. Controlled transition recovery and abort landing, complete hybrid energy qualification, | |
| 67 | a native transition command and a VTOL selection in the common workspace remain open. Helicopter, | |
| 68 | soaring, blimp and other airframe families remain explicit roadmap work. | |
| 67 | 69 | |
| 68 | 70 | Read [air](vehicles-air.md), [ground](vehicles-ground.md), [surface](vehicles-surface.md), [subsea](vehicles-subsea.md), |
| 69 | 71 | [mission semantics](missions.md) and [the replacement matrix](roadmap/feature-coverage.md). |
| @@ -1,18 +1,37 @@ | ||
| 1 | 1 | import type { EstimatorSnapshot } from '../estimation/estimator' |
| 2 | 2 | import type { ImuMeasurement, SensorVector } from '../sensors/types' |
| 3 | import type { VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleProfile, VehicleVtolTransitionObservation } from './types' | |
| 3 | import type { VehicleControlOutput, VehicleControllerContext, VehicleMeasurement, VehicleMeasurementPose, VehicleMissionAction, VehicleProfile, VehicleVtolTransitionObservation } from './types' | |
| 4 | 4 | import { createPhysicsSensorFeed } from '../controls/sensors' |
| 5 | 5 | import { createEstimator } from '../estimation/estimator' |
| 6 | 6 | import { observeMission } from '../missions/engine' |
| 7 | 7 | import { createVehicleMissionEngine, validateVehicleMission } from '../missions/vehicle' |
| 8 | 8 | import { createVehicleAuthoritySupervisor, createVehicleOutputWatchdog } from '../authority/vehicle' |
| 9 | 9 | import { createVehicleMeasurementQualifier, predictVehicleMeasurementPose, vehicleReference } from './measurements' |
| 10 | type ScheduledMissionAction = { atMs: number, action: Exclude<VehicleMissionAction, 'start'> } | |
| 11 | function captureMissionActions(input: unknown, maximumMs: number): ScheduledMissionAction[] { | |
| 12 | function invalid(): never { throw new TypeError('Invalid vehicle mission action schedule') } | |
| 13 | if (input === undefined) return [] | |
| 14 | if (!Array.isArray(input) || input.length > 32 || Reflect.ownKeys(input).length !== input.length + 1) invalid() | |
| 15 | const result: ScheduledMissionAction[] = [] | |
| 16 | let previousMs = 600 | |
| 17 | for (let i = 0; i < input.length; i++) { | |
| 18 | const slot = Object.getOwnPropertyDescriptor(input, String(i)) | |
| 19 | if (!slot || !('value' in slot) || !slot.value || typeof slot.value !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(slot.value)) || Reflect.ownKeys(slot.value).length !== 2) invalid() | |
| 20 | const time = Object.getOwnPropertyDescriptor(slot.value, 'atMs'), action = Object.getOwnPropertyDescriptor(slot.value, 'action') | |
| 21 | if (!time || !('value' in time) || !Number.isSafeInteger(time.value) || time.value <= previousMs || time.value > maximumMs || time.value % 20 !== 0 || !action || !('value' in action) || !['pause', 'resume', 'recover', 'abort'].includes(action.value)) invalid() | |
| 22 | result.push({ atMs: time.value, action: action.value }) | |
| 23 | previousMs = time.value | |
| 24 | } | |
| 25 | return result | |
| 26 | } | |
| 10 | 27 | /** Owned raw sensor -> estimator -> mission -> vehicle policy -> authority -> independent board loop. */ |
| 11 | export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number } = {}) { | |
| 28 | export function runVehicleSimulation(profileInput: VehicleProfile, options: { scenario?: string, seconds?: number, neutralTailMs?: number, missionActions?: readonly ScheduledMissionAction[] } = {}) { | |
| 12 | 29 | const scenario = options.scenario ?? profileInput.scenario, seconds = options.seconds ?? 120 |
| 13 | 30 | const neutralTailMs = options.neutralTailMs ?? 0 |
| 31 | let missionActionIndex = 0 | |
| 14 | 32 | if (!Number.isSafeInteger(neutralTailMs) || neutralTailMs < 0 || neutralTailMs > 15000 || neutralTailMs % 4 !== 0) throw new RangeError('Invalid independent vehicle neutral tail') |
| 15 | 33 | if (!Number.isInteger(seconds) || seconds < 2 || seconds > 180 || scenario !== profileInput.scenario || !profileInput.scenarios.includes(scenario) || profileInput.evidence !== 'modeled-vehicle-profile' || profileInput.physicalFlightEnabled !== false) throw new RangeError('Invalid vehicle simulation profile, scenario or duration') |
| 34 | const missionActions = captureMissionActions(options.missionActions, seconds * 1000) | |
| 16 | 35 | const { plan, worstCaseMs, requiredWh } = validateVehicleMission(profileInput.mission) |
| 17 | 36 | if (profileInput.vehicle !== plan.vehicle || plan.origin.latitudeDeg !== 0 || plan.origin.longitudeDeg !== 0 || plan.origin.ellipsoidHeightM !== 100) throw new TypeError('Vehicle fixture requires its declared 0,0,100 WGS84 origin') |
| 18 | 37 | const hasTransitions = plan.tasks.some(task => task.kind === 'vtol-mode') |
| @@ -93,7 +112,10 @@ export function runVehicleSimulation(profileInput: VehicleProfile, options: { sc | ||
| 93 | 112 | const ack = payload?.poll(nowMs) ?? null |
| 94 | 113 | const missionObservation = { ...observeMission({ estimate, authority: supervisor.snapshot().state, batteryPercent, onGround: qualified?.values.onSurface === true }, profileInput.vehicle === 'subsea' ? 'LOCAL_WATER_SURFACE' : 'WGS84_ELLIPSOID'), payloadAck: ack, ...(hasTransitions ? { transition: transitionObservation } : {}) } |
| 95 | 114 | transitionObservation = null |
| 96 | mission = engine.step(missionObservation, nowMs === 600 ? 'start' : undefined) | |
| 115 | let action: VehicleMissionAction | undefined = nowMs === 600 ? 'start' : undefined | |
| 116 | const scheduled = missionActions[missionActionIndex] | |
| 117 | if (scheduled?.atMs === nowMs) { action = scheduled.action; missionActionIndex++ } | |
| 118 | mission = engine.step(missionObservation, action) | |
| 97 | 119 | if (mission.payload && !requested.has(mission.payload.eventId)) { |
| 98 | 120 | if (!payload) { fault ??= 'missing-vehicle-payload-port'; send('disarm') } |
| 99 | 121 | else { payload.observe?.(structuredClone({ ...context, mission })); payload.request(structuredClone(mission.payload)); requested.add(mission.payload.eventId) } |
| @@ -13,6 +13,7 @@ import { vtolModel } from './plant' | ||
| 13 | 13 | |
| 14 | 14 | const clamp = (n: number, low: number, high: number) => Math.max(low, Math.min(high, n)) |
| 15 | 15 | const wrap = (n: number) => Math.atan2(Math.sin(n), Math.cos(n)) |
| 16 | const pauseLoiterRadiusM = 60 | |
| 16 | 17 | |
| 17 | 18 | /** Original fictional pressure/force-aware lift-and-cruise control, undergoing owned-model qualification. */ |
| 18 | 19 | export function createVtolFlightController(): VehicleController { |
| @@ -20,6 +21,7 @@ export function createVtolFlightController(): VehicleController { | ||
| 20 | 21 | const ratePid = [0, 1, 2].map(i => createBoundedPid({ kp: i === 2 ? 0.96 : 1.6, ki: i === 2 ? 0.48 : 0.8, kd: 0.02, integralLimit: 1, outputLimit: i === 2 ? 2 : 6, derivativeTauS: 0.03, trackingGain: 4 })) |
| 21 | 22 | const velocityPid = [0, 1, 2].map(() => createBoundedPid({ kp: 1.6, ki: 0.25, kd: 0, integralLimit: 1.5, outputLimit: 3, derivativeTauS: 0.03, trackingGain: 1 })) |
| 22 | 23 | let hasWingControl = false, backSlow = false |
| 24 | let loiterCenter: SensorVector | null = null | |
| 23 | 25 | let lastNow: number | null = null, identity: string | null = null, fault = '' |
| 24 | 26 | let phase: VehicleVtolTransitionObservation['phase'] = 'hover', request: VehicleVtolTransitionCommand | null = null, signature = '', confirmedEvent = '' |
| 25 | 27 | let stableSource: number | null = null, stableDecision: number | null = null |
| @@ -77,12 +79,24 @@ export function createVtolFlightController(): VehicleController { | ||
| 77 | 79 | const [roll, pitch, yaw] = quaternionEuler(q!), values = measurement!.values |
| 78 | 80 | const speed = values.airspeedMps as number, alpha = values.alphaRad as number, beta = values.betaRad as number |
| 79 | 81 | const landing = phase === 'hover' && c.mission.state === 'running' && c.mission.taskKind === 'land' |
| 80 | if (measurement!.values.onSurface && !landing || speed > 22 || Math.abs(roll) > 0.6 || Math.abs(pitch) > 0.5 || speed > 8 && (Math.abs(alpha) > 0.32 || Math.abs(beta) > 0.4) || phase !== 'hover' && Math.abs(position![2] - request!.positionM[2]) > 2) return neutral(c, 'vtol-transition-envelope') | |
| 81 | if (landing && measurement!.values.onSurface) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], 'vertical-contact') | |
| 82 | const pausedContact = phase === 'hover' && c.mission.state === 'paused' && values.onSurface === true && values.stopped === true | |
| 83 | if (measurement!.values.onSurface && !landing && !pausedContact || speed > 22 || Math.abs(roll) > 0.6 || Math.abs(pitch) > 0.5 || speed > 8 && (Math.abs(alpha) > 0.32 || Math.abs(beta) > 0.4) || phase !== 'hover' && Math.abs(position![2] - request!.positionM[2]) > 2) return neutral(c, 'vtol-transition-envelope') | |
| 84 | if (landing && measurement!.values.onSurface || pausedContact) return output(c, [0, 0, 0, 0, 0, 0, 0, 0], pausedContact ? 'ground-paused' : 'vertical-contact') | |
| 82 | 85 | const rotary = phase === 'back-transition' || phase === 'hover' |
| 83 | const heading = phase === 'front-transition' || rotary ? request!.headingRad : Math.atan2(target![1] - position![1], target![0] - position![0]) | |
| 84 | const course = Math.hypot(velocity![0], velocity![1]) > 3 ? Math.atan2(velocity![1], velocity![0]) : yaw | |
| 85 | let bankTarget = clamp(wrap(heading - course) * 0.8, -0.3, 0.3) | |
| 86 | let heading = phase === 'front-transition' || rotary ? request!.headingRad : Math.atan2(target![1] - position![1], target![0] - position![0]) | |
| 87 | const groundSpeed = Math.hypot(velocity![0], velocity![1]) | |
| 88 | const course = groundSpeed > 3 ? Math.atan2(velocity![1], velocity![0]) : yaw | |
| 89 | const pausedCruise = phase === 'cruise' && c.mission.state === 'paused' | |
| 90 | if (!pausedCruise) loiterCenter = null | |
| 91 | else { | |
| 92 | loiterCenter ??= [position![0] - pauseLoiterRadiusM * Math.sin(course), position![1] + pauseLoiterRadiusM * Math.cos(course), target![2]] | |
| 93 | if (Math.hypot(loiterCenter[0], loiterCenter[1]) + pauseLoiterRadiusM > 400) return neutral(c, 'vtol-pause-loiter-outside-envelope') | |
| 94 | const radial = Math.atan2(position![1] - loiterCenter[1], position![0] - loiterCenter[0]) | |
| 95 | const distance = Math.hypot(position![0] - loiterCenter[0], position![1] - loiterCenter[1]) | |
| 96 | heading = radial + Math.PI / 2 + Math.atan2(distance - pauseLoiterRadiusM, 40) | |
| 97 | } | |
| 98 | const loiterBank = pausedCruise ? Math.atan2(groundSpeed ** 2, vtolModel.gravityMps2 * pauseLoiterRadiusM) : 0 | |
| 99 | let bankTarget = clamp(loiterBank + wrap(heading - course) * 0.8, -0.3, 0.3) | |
| 86 | 100 | let downAcceleration = clamp((target![2] - position![2]) * 1.2 - velocity![2] * 1.5, -3, 3) |
| 87 | 101 | const capturedRelative: SensorVector = [speed * Math.cos(alpha) * Math.cos(beta), speed * Math.sin(beta), speed * Math.sin(alpha) * Math.cos(beta)] |
| 88 | 102 | // Delayed BODY_FRD airflow belongs to its qualified capture pose. Hold that measured NED |
| @@ -115,6 +115,7 @@ | ||
| 115 | 115 | "./unit/gimbal-authority.test.ts", |
| 116 | 116 | "./unit/cinema-managed.test.ts", |
| 117 | 117 | "./unit/cinema-counted-encoder.test.ts", |
| 118 | "./unit/cinema-counted-translation.test.ts" | |
| 118 | "./unit/cinema-counted-translation.test.ts", | |
| 119 | "./unit/vehicle-lifecycle.test.ts" | |
| 119 | 120 | ] |
| 120 | 121 | } |
| @@ -0,0 +1,277 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { createVtolHoverProfile } from '../../resources/functions/drone/vehicles/vtol' | |
| 3 | import { runVehicleSimulation } from '../../resources/functions/drone/vehicles/runtime' | |
| 4 | import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle' | |
| 5 | import { createVtolTransitionProfile } from '../../resources/functions/drone/vehicles/vtol/transition-profile' | |
| 6 | import type { VehiclePlantState } from '../../resources/functions/drone/vehicles/types' | |
| 7 | ||
| 8 | // Agreed shared-runtime seam: actual sensor/estimator/mission/controller/independent lease | |
| 9 | // execution. This verifies operator lifecycle delivery, not physical pause or recovery. | |
| 10 | test('scheduled operator pause and resume reach the real hover loop and its replay without withdrawing authority', () => { | |
| 11 | const report = runVehicleSimulation(createVtolHoverProfile(), { seconds: 60, missionActions: [ | |
| 12 | { atMs: 4000, action: 'pause' }, | |
| 13 | { atMs: 6000, action: 'resume' }, | |
| 14 | ] }) | |
| 15 | const paused = report.path.filter(row => row.mission.state === 'paused') | |
| 16 | expect(paused).toHaveLength(100) | |
| 17 | expect(paused[0]!.atMs).toBe(4000) | |
| 18 | expect(paused.at(-1)!.atMs).toBe(5980) | |
| 19 | expect(paused.every(row => row.mission.reason === 'operator-pause' && row.mission.targetM?.every((n, i) => n === paused[0]!.mission.targetM![i]))).toBe(true) | |
| 20 | expect(paused.every(row => row.actuators.slice(0, 4).some(n => n > 0))).toBe(true) | |
| 21 | expect(report.trace.rows.filter(row => row.action === 'pause' || row.action === 'resume').map(row => [row.observation.nowMs, row.action])).toEqual([[4000, 'pause'], [6000, 'resume']]) | |
| 22 | expect(report.status).toBe('completed') | |
| 23 | expect(report.mission.reason).toBe('landed') | |
| 24 | expect(report.finalTruthPositionM[2]).toBe(0) | |
| 25 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 26 | expect(report.board.expiresAtMs).toBeNull() | |
| 27 | expect(report.physicalFlightEnabled).toBe(false) | |
| 28 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 29 | }) | |
| 30 | ||
| 31 | ||
| 32 | test('invalid operator schedules are refused before creating a session or invoking data accessors', () => { | |
| 33 | let accessorCalls = 0 | |
| 34 | const getterEvent = Object.defineProperty({ action: 'pause' }, 'atMs', { enumerable: true, get() { accessorCalls++; return 1000 } }) | |
| 35 | const cases: [string, unknown][] = [ | |
| 36 | ['off-grid', [{ atMs: 1001, action: 'pause' }]], | |
| 37 | ['null', null], | |
| 38 | ['not-an-array', {}], | |
| 39 | ['sparse', new Array(1)], | |
| 40 | ['too-many', Array.from({ length: 33 }, () => ({ atMs: 1000, action: 'pause' }))], | |
| 41 | ['automatic-start-collision', [{ atMs: 600, action: 'pause' }]], | |
| 42 | ['before-start', [{ atMs: 580, action: 'pause' }]], | |
| 43 | ['past-duration', [{ atMs: 2020, action: 'pause' }]], | |
| 44 | ['non-finite', [{ atMs: NaN, action: 'pause' }]], | |
| 45 | ['unsafe-integer', [{ atMs: Number.MAX_SAFE_INTEGER + 1, action: 'pause' }]], | |
| 46 | ['duplicate', [{ atMs: 1000, action: 'pause' }, { atMs: 1000, action: 'resume' }]], | |
| 47 | ['reversed', [{ atMs: 1020, action: 'pause' }, { atMs: 1000, action: 'resume' }]], | |
| 48 | ['second-start', [{ atMs: 1000, action: 'start' }]], | |
| 49 | ['unknown-action', [{ atMs: 1000, action: 'fly' }]], | |
| 50 | ['extra-field', [{ atMs: 1000, action: 'pause', retry: true }]], | |
| 51 | ['accessor', [getterEvent]], | |
| 52 | ] | |
| 53 | const violations: { label: string, created: number, message: string }[] = [] | |
| 54 | for (const [label, missionActions] of cases) { | |
| 55 | const profile = createVtolHoverProfile(), createSession = profile.createSession | |
| 56 | let created = 0, message = '' | |
| 57 | profile.createSession = () => { created++; return createSession() } | |
| 58 | try { runVehicleSimulation(profile, { seconds: 2, missionActions: missionActions as never }) } | |
| 59 | catch (error) { message = error instanceof Error ? error.message : String(error) } | |
| 60 | if (created !== 0 || message !== 'Invalid vehicle mission action schedule') violations.push({ label, created, message }) | |
| 61 | } | |
| 62 | expect(violations).toEqual([]) | |
| 63 | expect(accessorCalls).toBe(0) | |
| 64 | }) | |
| 65 | ||
| 66 | ||
| 67 | test('validated lifecycle events are independent of caller mutation during session creation', () => { | |
| 68 | const first = { atMs: 4000, action: 'pause' as const }, second = { atMs: 6000, action: 'resume' as const } | |
| 69 | const missionActions = [first, second], profile = createVtolHoverProfile(), createSession = profile.createSession | |
| 70 | profile.createSession = () => { | |
| 71 | first.atMs = 2000; second.atMs = 2020; missionActions.reverse() | |
| 72 | return createSession() | |
| 73 | } | |
| 74 | const report = runVehicleSimulation(profile, { seconds: 60, missionActions }) | |
| 75 | expect(report.trace.rows.filter(row => row.action === 'pause' || row.action === 'resume').map(row => [row.observation.nowMs, row.action])).toEqual([[4000, 'pause'], [6000, 'resume']]) | |
| 76 | expect(report.status).toBe('completed') | |
| 77 | expect(report.finalTruthPositionM[2]).toBe(0) | |
| 78 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 79 | }) | |
| 80 | ||
| 81 | ||
| 82 | test('an empty lifecycle schedule preserves every legacy hover recording and report field', () => { | |
| 83 | const legacy = runVehicleSimulation(createVtolHoverProfile(), { seconds: 60 }) | |
| 84 | const empty = runVehicleSimulation(createVtolHoverProfile(), { seconds: 60, missionActions: [] }) | |
| 85 | expect(empty).toEqual(legacy) | |
| 86 | expect(Object.keys(empty)).toEqual(Object.keys(legacy)) | |
| 87 | expect(empty.trace.rows.every(row => row.action === null || row.action === 'start')).toBe(true) | |
| 88 | }) | |
| 89 | ||
| 90 | ||
| 91 | for (const bankedEntry of [false, true]) test(`a paused wing-borne VTOL flies a bounded clockwise 60 m loiter from ${bankedEntry ? 'banked' : 'straight'} entry before resuming and landing`, () => { | |
| 92 | const profile = createVtolTransitionProfile(), createSession = profile.createSession | |
| 93 | const destination = bankedEntry ? [150, 100] : [180, 0] | |
| 94 | if (bankedEntry) for (const task of profile.mission.tasks) { | |
| 95 | if ((task.id === 'cruise-leg' || task.id === 'land') && 'positionM' in task) { task.positionM[0] = destination[0]!; task.positionM[1] = destination[1]! } | |
| 96 | if (task.id === 'back' && task.kind === 'vtol-mode') task.headingRad = 1 | |
| 97 | } | |
| 98 | const observed: { atMs: number, state: VehiclePlantState }[] = [] | |
| 99 | profile.createSession = () => { | |
| 100 | const session = createSession() | |
| 101 | return { ...session, observeIndependent(state, atMs) { | |
| 102 | if (atMs % 20 === 0 && atMs >= 20000 && atMs < 40000) observed.push({ atMs, state: structuredClone(state) }) | |
| 103 | } } | |
| 104 | } | |
| 105 | const report = runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs: 20000, action: 'pause' }, { atMs: 40000, action: 'resume' }] }) | |
| 106 | const entry = report.trace.rows.find(row => row.observation.nowMs === 20000)!.observation | |
| 107 | const course = Math.atan2(entry.velocityMps[1], entry.velocityMps[0]) | |
| 108 | const center = [entry.positionM[0] - 60 * Math.sin(course), entry.positionM[1] + 60 * Math.cos(course)] | |
| 109 | const settled = observed.filter(row => row.atMs >= 26000) | |
| 110 | const radialError = Math.max(...settled.map(row => Math.abs(Math.hypot(row.state.positionM[0] - center[0]!, row.state.positionM[1] - center[1]!) - 60))) | |
| 111 | const paused = report.path.filter(row => row.mission.state === 'paused') | |
| 112 | console.log('VTOL_PAUSE_LOITER', JSON.stringify({ bankedEntry, status: report.status, fault: report.fault, pausedRows: paused.length, settledRows: settled.length, maximumRadialErrorM: radialError })) | |
| 113 | expect(paused).toHaveLength(1000) | |
| 114 | expect(settled.length).toBeGreaterThan(600) | |
| 115 | expect(radialError).toBeLessThan(8) | |
| 116 | expect(settled.every(row => Math.abs(row.state.positionM[2] + 15) < 2)).toBe(true) | |
| 117 | expect(settled.every(row => (row.state.details.airspeedMps as number) >= 12 && (row.state.details.airspeedMps as number) <= 14)).toBe(true) | |
| 118 | expect(settled.every(row => (row.state.details.rotorThrustN as number) < 1)).toBe(true) | |
| 119 | expect(report.status).toBe('completed') | |
| 120 | expect(report.finalTruthPositionM[2]).toBe(0) | |
| 121 | expect(Math.hypot(report.finalTruthPositionM[0] - destination[0]!, report.finalTruthPositionM[1] - destination[1]!)).toBeLessThan(0.3) | |
| 122 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 123 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 124 | }) | |
| 125 | ||
| 126 | ||
| 127 | test('a qualified post-back landing contact can pause with neutral outputs and explicitly resume', () => { | |
| 128 | const baseline = runVehicleSimulation(createVtolTransitionProfile(), { seconds: 160 }) | |
| 129 | expect(baseline.status).toBe('completed') | |
| 130 | const contact = baseline.path.find(row => row.mission.state === 'running' && row.mission.taskKind === 'land' && row.measurement?.values.onSurface === true) | |
| 131 | expect(contact).toBeDefined() | |
| 132 | const pauseAtMs = contact!.atMs | |
| 133 | const report = runVehicleSimulation(createVtolTransitionProfile(), { seconds: 160, missionActions: [{ atMs: pauseAtMs, action: 'pause' }, { atMs: pauseAtMs + 2000, action: 'resume' }] }) | |
| 134 | const paused = report.path.filter(row => row.mission.state === 'paused') | |
| 135 | console.log('VTOL_CONTACT_PAUSE', JSON.stringify({ pauseAtMs, status: report.status, fault: report.fault, pausedRows: paused.length })) | |
| 136 | expect(paused).toHaveLength(100) | |
| 137 | expect(paused.every(row => row.measurement!.values.onSurface === true && row.truthPositionM[2] === 0 && row.actuators.every(n => n === 0))).toBe(true) | |
| 138 | expect(report.status).toBe('completed') | |
| 139 | expect(report.fault).toBeNull() | |
| 140 | expect(report.mission.reason).toBe('landed') | |
| 141 | expect(report.finalTruthPositionM[2]).toBe(0) | |
| 142 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 143 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 144 | }) | |
| 145 | ||
| 146 | ||
| 147 | test('neutral post-back ground pause cannot bypass lost IMU qualification or resume authority', () => { | |
| 148 | const baseline = runVehicleSimulation(createVtolTransitionProfile(), { seconds: 160 }) | |
| 149 | expect(baseline.status).toBe('completed') | |
| 150 | const pauseAtMs = baseline.path.find(row => row.mission.state === 'running' && row.mission.taskKind === 'land' && row.measurement?.values.onSurface === true)!.atMs | |
| 151 | const failedAtMs = pauseAtMs + 200, profile = createVtolTransitionProfile() | |
| 152 | profile.faults = (_scenario, nowMs) => nowMs >= failedAtMs ? { dropImu: true } : {} | |
| 153 | const report = runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs: pauseAtMs, action: 'pause' }, { atMs: pauseAtMs + 2000, action: 'resume' }] }) | |
| 154 | console.log('VTOL_CONTACT_PAUSE_IMU', JSON.stringify({ failedAtMs, status: report.status, fault: report.fault, finalAtMs: report.path.at(-1)!.atMs })) | |
| 155 | expect(report.path.some(row => row.mission.state === 'paused' && row.measurement?.values.onSurface === true)).toBe(true) | |
| 156 | expect(report.status).toBe('blocked') | |
| 157 | expect(report.path.at(-1)!.atMs).toBeLessThanOrEqual(failedAtMs + 120) | |
| 158 | expect(report.mission.state).toBe('blocked') | |
| 159 | expect(report.trace.rows.some(row => row.action === 'resume')).toBe(false) | |
| 160 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 161 | expect(report.board.expiresAtMs).toBeNull() | |
| 162 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 163 | }) | |
| 164 | ||
| 165 | ||
| 166 | for (const [taskId, atMs] of [['initial-hover', 700], ['front', 13000], ['back', 30000]] as const) { | |
| 167 | for (const action of ['pause', 'recover'] as const) { | |
| 168 | test(`an uncertain ${taskId} refuses ${action} instead of inventing transition recovery`, () => { | |
| 169 | const report = runVehicleSimulation(createVtolTransitionProfile(), { seconds: 60, missionActions: [{ atMs, action }] }) | |
| 170 | expect(report.path.filter(row => row.atMs < atMs).at(-1)!.mission.taskId).toBe(taskId) | |
| 171 | expect(report.status).toBe('blocked') | |
| 172 | expect(report.mission.reason).toBe('vtol-transition-recovery-uncertain') | |
| 173 | expect(report.mission.targetM).toBeNull() | |
| 174 | expect(report.mission.transition).toBeNull() | |
| 175 | expect(report.trace.rows.at(-1)!.observation.nowMs).toBe(atMs) | |
| 176 | expect(report.trace.rows.at(-1)!.action).toBe(action) | |
| 177 | expect(report.trace.rows.filter(row => row.action === 'start')).toHaveLength(1) | |
| 178 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 179 | expect(report.board.expiresAtMs).toBeNull() | |
| 180 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 181 | }) | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 185 | ||
| 186 | test('same-session operator recovery remains paused until explicit resume with continuous authority', () => { | |
| 187 | const report = runVehicleSimulation(createVtolHoverProfile(), { seconds: 60, missionActions: [{ atMs: 4000, action: 'recover' }, { atMs: 6000, action: 'resume' }] }) | |
| 188 | const paused = report.path.filter(row => row.mission.state === 'paused') | |
| 189 | expect(paused).toHaveLength(100) | |
| 190 | expect(paused.every(row => row.mission.reason === 'checkpoint-recovered-awaiting-resume')).toBe(true) | |
| 191 | expect(report.trace.rows.filter(row => row.action === 'recover' || row.action === 'resume').map(row => [row.observation.nowMs, row.action])).toEqual([[4000, 'recover'], [6000, 'resume']]) | |
| 192 | expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1) | |
| 193 | expect(report.status).toBe('completed') | |
| 194 | expect(report.finalTruthPositionM[2]).toBe(0) | |
| 195 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 196 | }) | |
| 197 | ||
| 198 | ||
| 199 | test('a paused hover still consumes the declared energy and global endurance allowance', () => { | |
| 200 | const report = runVehicleSimulation(createVtolHoverProfile(), { seconds: 80, missionActions: [{ atMs: 4000, action: 'pause' }] }) | |
| 201 | const paused = report.trace.rows.filter(row => row.result.state === 'paused') | |
| 202 | expect(paused.length).toBeGreaterThan(2000) | |
| 203 | expect(paused.at(-1)!.observation.batteryPercent).toBeLessThan(paused[0]!.observation.batteryPercent) | |
| 204 | expect(report.status).toBe('blocked') | |
| 205 | expect(report.mission.reason).toBe('mission-endurance-exhausted') | |
| 206 | expect(report.mission.elapsedMs).toBeGreaterThan(report.budget.worstCaseMs) | |
| 207 | expect(report.mission.elapsedMs).toBeLessThanOrEqual(report.budget.worstCaseMs + 20) | |
| 208 | expect(report.mission.taskId).toBe('depart') | |
| 209 | expect(report.path.some(row => row.mission.taskKind === 'land')).toBe(false) | |
| 210 | expect(report.finalTruthPositionM[2]).toBeLessThan(-1) | |
| 211 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 212 | expect(report.board.expiresAtMs).toBeNull() | |
| 213 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 214 | }) | |
| 215 | ||
| 216 | ||
| 217 | for (const failure of ['dropImu', 'dropPosition', 'dropVehicleSensor', 'rcOverride', 'kill', 'hostLost'] as const) { | |
| 218 | test(`paused wing-borne VTOL cannot retain or regain output authority after ${failure}`, () => { | |
| 219 | const profile = createVtolTransitionProfile() | |
| 220 | profile.faults = (_scenario, nowMs) => nowMs >= 20500 ? { [failure]: true } : {} | |
| 221 | const report = runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs: 20000, action: 'pause' }, { atMs: 22000, action: 'resume' }] }) | |
| 222 | expect(report.path.filter(row => row.atMs < 20500).at(-1)!.mission.state).toBe('paused') | |
| 223 | expect(report.status).toBe(failure === 'hostLost' ? 'host-lost' : 'blocked') | |
| 224 | expect(report.trace.rows.some(row => row.action === 'resume')).toBe(false) | |
| 225 | expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1) | |
| 226 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 227 | expect(report.board.expiresAtMs).toBeNull() | |
| 228 | expect(report.finalTruthPositionM[2]).toBeLessThan(-1) | |
| 229 | if (failure === 'hostLost') { | |
| 230 | expect(report.board.fault).toBe('output-lease-expired') | |
| 231 | expect(report.authority.lastCheckedAtMs).toBeLessThan(20500) | |
| 232 | } | |
| 233 | else { | |
| 234 | expect(report.mission.state).toBe('blocked') | |
| 235 | expect(report.mission.targetM).toBeNull() | |
| 236 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 237 | } | |
| 238 | if (failure === 'rcOverride') expect(report.authority.events.some(event => event.to === 'manual')).toBe(true) | |
| 239 | if (failure === 'kill') expect(report.authority.events.some(event => event.to === 'killed')).toBe(true) | |
| 240 | }) | |
| 241 | } | |
| 242 | ||
| 243 | ||
| 244 | for (const action of ['pause', 'recover'] as const) { | |
| 245 | test(`post-back airborne ${action} holds estimated position until explicit resume and vertical landing`, () => { | |
| 246 | const report = runVehicleSimulation(createVtolTransitionProfile(), { seconds: 160, missionActions: [{ atMs: 68000, action }, { atMs: 70000, action: 'resume' }] }) | |
| 247 | const paused = report.path.filter(row => row.mission.state === 'paused') | |
| 248 | expect(paused).toHaveLength(100) | |
| 249 | const held = paused[0]!.mission.targetM! | |
| 250 | expect(Math.max(...paused.map(row => Math.hypot(...row.truthPositionM.map((n, i) => n - held[i]!))))).toBeLessThan(1.5) | |
| 251 | expect(paused.every(row => row.mission.taskKind === 'land' && row.measurement?.values.onSurface === false && row.actuators.slice(0, 4).some(n => n > 0))).toBe(true) | |
| 252 | expect(report.trace.rows.filter(row => row.observation.transition?.outcome === 'confirmed').map(row => row.observation.transition!.phase)).toEqual(['hover', 'cruise', 'hover']) | |
| 253 | expect(report.authority.events.filter(event => event.to === 'autonomous')).toHaveLength(1) | |
| 254 | expect(report.status).toBe('completed') | |
| 255 | expect(report.mission.reason).toBe('landed') | |
| 256 | expect(report.finalTruthPositionM[2]).toBe(0) | |
| 257 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 258 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 259 | }) | |
| 260 | } | |
| 261 | ||
| 262 | ||
| 263 | test('a VTOL pause refuses a holding circle outside the declared 400 m envelope', () => { | |
| 264 | const profile = createVtolTransitionProfile() | |
| 265 | for (const task of profile.mission.tasks) if ((task.id === 'cruise-leg' || task.id === 'land') && 'positionM' in task) task.positionM[0] = 350 | |
| 266 | const report = runVehicleSimulation(profile, { seconds: 160, missionActions: [{ atMs: 40000, action: 'pause' }] }) | |
| 267 | console.log('VTOL_PAUSE_FENCE', JSON.stringify({ status: report.status, fault: report.fault, lastAtMs: report.path.at(-1)!.atMs, finalPositionM: report.finalTruthPositionM })) | |
| 268 | expect(report.path.filter(row => row.atMs < 40000).at(-1)!.mission.taskId).toBe('cruise-leg') | |
| 269 | expect(report.path.some(row => row.atMs === 40000 && row.mission.state === 'paused')).toBe(true) | |
| 270 | expect(report.status).toBe('blocked') | |
| 271 | expect(report.fault).toBe('vtol-pause-loiter-outside-envelope') | |
| 272 | expect(report.path.every(row => Math.hypot(row.truthPositionM[0], row.truthPositionM[1]) <= 400)).toBe(true) | |
| 273 | expect(report.finalTruthPositionM[2]).toBeLessThan(-1) | |
| 274 | expect(report.board.actuators).toEqual([0, 0, 0, 0, 0, 0, 0, 0]) | |
| 275 | expect(report.board.expiresAtMs).toBeNull() | |
| 276 | expect(replayVehicleMission(report.trace).snapshot()).toEqual(report.mission) | |
| 277 | }) | |