diff --git a/nav/slowdown_test.go b/nav/slowdown_test.go new file mode 100644 index 000000000..c5527e041 --- /dev/null +++ b/nav/slowdown_test.go @@ -0,0 +1,95 @@ +// nav/slowdown_test.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package nav + +import ( + "testing" + + av "github.com/mmp/vice/aviation" + "github.com/mmp/vice/math" +) + +func TestAssignedSpeedFloor(t *testing.T) { + mkRange := func(lo, hi float32) *av.SpeedRestriction { + return &av.SpeedRestriction{NavigationRestriction: av.NavigationRestriction{Range: [2]float32{lo, hi}}} + } + + cases := []struct { + name string + speed NavSpeed + wantFloor float32 + wantOK bool + }{ + {"none", NavSpeed{}, 0, false}, + {"exact", NavSpeed{Assigned: mkRange(250, 250)}, 250, true}, + {"range uses floor", NavSpeed{Assigned: mkRange(210, 250)}, 210, true}, + {"mach ignored", NavSpeed{Assigned: &av.SpeedRestriction{ + NavigationRestriction: av.NavigationRestriction{Range: [2]float32{0.74, 0.74}}, IsMach: true}}, 0, false}, + {"max forward", NavSpeed{MaintainMaximumForward: true}, av.MaxRestrictionSpeed, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + n := &Nav{Speed: c.speed} + floor, ok := n.AssignedSpeedFloor() + if ok != c.wantOK || (ok && floor != c.wantFloor) { + t.Errorf("AssignedSpeedFloor() = %v, %v; want %v, %v", floor, ok, c.wantFloor, c.wantOK) + } + }) + } +} + +func TestSlowDownDistanceNM(t *testing.T) { + const nmPerLong = 60 + pt := func(x, y float32) math.Point2LL { return math.NM2LL([2]float32{x, y}, nmPerLong) } + + t.Run("route sums remaining legs", func(t *testing.T) { + n := &Nav{ + FlightState: FlightState{Position: pt(0, 0), NmPerLongitude: nmPerLong}, + Waypoints: av.WaypointArray{ + {Fix: "WP0", Location: pt(0, 5)}, + {Fix: "WP1", Location: pt(0, 15)}, + }, + } + d, ok := n.SlowDownDistanceNM() + if !ok || math.Abs(d-15) > 0.1 { + t.Errorf("SlowDownDistanceNM() = %.2f, %v; want ~15, true", d, ok) + } + }) + + // Being vectored: route branch is disabled regardless of waypoints. + vectored := func(acHeading math.MagneticHeading) *Nav { + h := math.MagneticHeading(123) // any non-nil assigned heading + return &Nav{ + FlightState: FlightState{Position: pt(0, 0), Heading: acHeading, NmPerLongitude: nmPerLong}, + Heading: NavHeading{Assigned: &h}, + Approach: NavApproach{Assigned: &av.Approach{Threshold: pt(0, 10)}}, + } + } + + t.Run("vectored toward threshold", func(t *testing.T) { + d, ok := vectored(0 /* north, toward threshold */).SlowDownDistanceNM() + if !ok || math.Abs(d-10) > 0.1 { + t.Errorf("SlowDownDistanceNM() = %.2f, %v; want ~10, true", d, ok) + } + }) + + t.Run("vectored away from threshold", func(t *testing.T) { + if d, ok := vectored(180 /* south, away */).SlowDownDistanceNM(); ok { + t.Errorf("SlowDownDistanceNM() = %.2f, %v; want not-ok while heading away", d, ok) + } + }) + + t.Run("vectored without assigned approach", func(t *testing.T) { + h := math.MagneticHeading(90) + n := &Nav{ + FlightState: FlightState{Position: pt(0, 0), NmPerLongitude: nmPerLong}, + Heading: NavHeading{Assigned: &h}, + } + if d, ok := n.SlowDownDistanceNM(); ok { + t.Errorf("SlowDownDistanceNM() = %.2f, %v; want not-ok with no runway reference", d, ok) + } + }) +} diff --git a/nav/speed.go b/nav/speed.go index cc1b81b03..ea7dcf5b0 100644 --- a/nav/speed.go +++ b/nav/speed.go @@ -403,6 +403,63 @@ func (nav *Nav) getUpcomingSpeedRestrictionWaypoint() (onSID bool, sr *av.SpeedR return false, nil, "", false } +// AssignedSpeedFloor returns the lowest speed (in knots) the aircraft may fly +// given the current controller speed assignment, along with whether such an +// assignment is in force. It is used to decide whether an arrival should ask +// to slow down (issue #884); Mach assignments are ignored since they only +// apply far from the airport. +func (nav *Nav) AssignedSpeedFloor() (float32, bool) { + if nav.Speed.MaintainMaximumForward { + // "Maintain maximum forward speed" exceeds any distance-based gate. + return av.MaxRestrictionSpeed, true + } + if sr := nav.Speed.Assigned; sr != nil && !sr.IsMach { + return sr.Range[0], true + } + return 0, false +} + +// SlowDownDistanceNM estimates the distance (in nm) the pilot would use when +// judging whether they are "getting close" to the runway for the purpose of +// requesting a speed reduction (issue #884), along with whether that estimate +// is meaningful. +// +// When flying a lateral route (not being vectored) it returns the remaining +// track miles along the route, which correctly accounts for downwind/base +// geometry. When being vectored it falls back to the straight-line distance to +// the assigned approach's runway threshold, but only when the aircraft is +// actually flying toward it (within 90 degrees); otherwise it returns +// ok=false, so no premature request is made while on a downwind or base leg +// where the aircraft can be close to the threshold but heading away from it. +func (nav *Nav) SlowDownDistanceNM() (float32, bool) { + fs := &nav.FlightState + + if nav.Heading.Assigned == nil && len(nav.Waypoints) > 0 { + // Sum the remaining track miles. We do not verify the final waypoint is + // the runway threshold: a not-yet-cleared arrival on a full STAR yields a + // large distance, which arrivalSpeedGate rejects (>20 nm) anyway. + d := math.NMDistance2LLFast(fs.Position, nav.Waypoints[0].Location, fs.NmPerLongitude) + for i := 0; i+1 < len(nav.Waypoints); i++ { + d += math.NMDistance2LLFast(nav.Waypoints[i].Location, nav.Waypoints[i+1].Location, + fs.NmPerLongitude) + } + return d, true + } + + // Being vectored: we can only estimate distance if we know which runway + // we're being taken to. + if nav.Approach.Assigned == nil { + return 0, false + } + threshold := nav.Approach.Assigned.Threshold + bearing := math.Heading2LL(fs.Position, threshold, fs.NmPerLongitude) + heading := math.MagneticToTrue(fs.Heading, fs.MagneticVariation) + if math.HeadingDifference(heading, bearing) > 90 { + return 0, false + } + return math.NMDistance2LLFast(fs.Position, threshold, fs.NmPerLongitude), true +} + // distanceToEndOfApproach returns the remaining distance to the last // waypoint (usually runway threshold) of the currently assigned approach. func (nav *Nav) DistanceToEndOfApproach() (float32, error) { diff --git a/sim/aircraft.go b/sim/aircraft.go index 211b8df44..685094acf 100644 --- a/sim/aircraft.go +++ b/sim/aircraft.go @@ -144,6 +144,17 @@ type Aircraft struct { // field is in sight. Set to zero after the check (requested or given up) to prevent retries. VisualApproachRequestDistance float32 + // SlowDownAskedGate is the speed gate (kts) at which the pilot last asked + // to slow down (issue #884); 0 means no request has been made. The pilot + // only re-asks on crossing into a closer (lower-speed) gate band. + // SlowDownAskedFloor is the assigned speed floor in force when that ask was + // (re-)armed; a change in the assignment re-arms the request. SlowDownLastDist + // is the previous tick's distance, used to detect when the aircraft is + // receding from the field (overflew/missed) so it stays quiet. + SlowDownAskedGate float32 + SlowDownAskedFloor float32 + SlowDownLastDist float32 + TouchAndGosRemaining int // >0 means pattern aircraft; decremented each lap } diff --git a/sim/approach.go b/sim/approach.go index 284e666e5..b6b79e27f 100644 --- a/sim/approach.go +++ b/sim/approach.go @@ -548,3 +548,103 @@ func (s *Sim) checkSpontaneousVisualRequest(ac *Aircraft) { s.enqueuePilotTransmission(ac.ADSBCallsign, ac.ControllerFrequency, PendingTransmissionFieldInSight) } } + +// arrivalSpeedGate returns the maximum speed (in knots) an arrival would +// typically want to be doing at the given distance (in nm) from the airport, +// per the standard TRACON speed-by-distance profile (issue #884). ok is false +// beyond 20 nm, where there's no expectation to have slowed. +func arrivalSpeedGate(distNM float32) (float32, bool) { + switch { + case distNM > 20: + return 0, false + case distNM >= 18: + return 250, true + case distNM >= 14: + return 210, true + case distNM >= 8: + return 190, true + default: + return 180, true + } +} + +// checkSlowDownRequest is a per-tick check for an arrival that is being held +// fast by the controller as it nears the airport. If the controller-assigned +// speed exceeds what the aircraft would typically fly at its current distance +// (see arrivalSpeedGate / issue #884), the pilot asks to slow down. The +// request is made once per speed-gate band and only repeats when the aircraft +// crosses into a closer (lower-speed) band; a new controller speed assignment +// re-arms it. +func (s *Sim) checkSlowDownRequest(ac *Aircraft) { + if !ac.IsArrival() || !ac.IsAssociated() || ac.ControllerFrequency == "" || s.hasPendingCheckIn(ac.ADSBCallsign) { + return + } + + floor, ok := ac.Nav.AssignedSpeedFloor() + if !ok { + // No controller speed assignment in force: nothing to ask about, and + // re-arm so a subsequent assignment is treated fresh. + ac.SlowDownLastDist = 0 + ac.SlowDownAskedGate = 0 + ac.SlowDownAskedFloor = 0 + return + } + + // A new (or changed) controller speed assignment re-arms the request, so the + // pilot will speak up again even within the same speed-gate band. + if floor != ac.SlowDownAskedFloor { + ac.SlowDownAskedGate = 0 + ac.SlowDownAskedFloor = floor + } + + dist, ok := ac.Nav.SlowDownDistanceNM() + if !ok { + ac.SlowDownLastDist = 0 + return + } + + // Only ask while still inbound. Once the aircraft is moving away from the + // field (overflew the airport, on the missed approach, etc.) stay quiet. + movingAway := ac.SlowDownLastDist != 0 && dist > ac.SlowDownLastDist+0.1 + ac.SlowDownLastDist = dist + if movingAway { + return + } + + gate, ok := arrivalSpeedGate(dist) + if !ok || floor <= gate { + return + } + + // Ask once per band, re-asking only on crossing into a closer (i.e. + // lower-speed) band. + if ac.SlowDownAskedGate != 0 && gate >= ac.SlowDownAskedGate { + return + } + // Don't stack a duplicate if an earlier request is still queued (the + // controller hasn't popped it yet); it re-checks the gate at dispatch. + if s.hasPendingTransmission(ac.ADSBCallsign, PendingTransmissionRequestSlowDown) { + return + } + ac.SlowDownAskedGate = gate + s.enqueuePilotTransmission(ac.ADSBCallsign, ac.ControllerFrequency, PendingTransmissionRequestSlowDown) +} + +// stillWantsSlowDown reports whether a queued slow-down request is still worth +// transmitting at dispatch time. The controller may have slowed the aircraft +// between enqueue and dispatch, so it re-checks that a speed assignment is in +// force and, where the distance to the field can be estimated, that the +// assignment still exceeds the speed-by-distance gate (see checkSlowDownRequest +// / issue #884). +func (ac *Aircraft) stillWantsSlowDown() bool { + floor, ok := ac.Nav.AssignedSpeedFloor() + if !ok { + return false + } + if dist, ok := ac.Nav.SlowDownDistanceNM(); ok { + if gate, ok := arrivalSpeedGate(dist); ok && floor <= gate { + return false + } + } + return true +} diff --git a/sim/radio.go b/sim/radio.go index adb6de705..d6deb0ff9 100644 --- a/sim/radio.go +++ b/sim/radio.go @@ -55,6 +55,7 @@ const ( PendingTransmissionRequestVisual // Spontaneous "field in sight, requesting visual" PendingTransmissionRequestVectors // Pilot requesting vectors (overshot localizer) PendingTransmissionRequestAltitude // Pilot requesting altitude after being vectored off STAR + PendingTransmissionRequestSlowDown // Arrival held fast close in, asking to slow down ) // FutureFrequencyChange represents a pilot switching to a new frequency. @@ -92,6 +93,19 @@ func (s *Sim) hasPendingCheckIn(callsign av.ADSBCallsign) bool { return false } +// hasPendingTransmission reports whether a transmission of the given type is +// already queued for the aircraft, used to avoid enqueuing duplicate requests. +func (s *Sim) hasPendingTransmission(callsign av.ADSBCallsign, ty PendingTransmissionType) bool { + for _, pcs := range s.PendingContacts { + for _, pc := range pcs { + if pc.ADSBCallsign == callsign && pc.Type == ty { + return true + } + } + } + return false +} + // addPendingContact adds an aircraft to the pending contacts queue for a controller. func (s *Sim) addPendingContact(pc PendingContact) { if s.PendingContacts == nil { @@ -477,6 +491,24 @@ func (s *Sim) GenerateContactTransmission(pc *PendingContact) (spokenText, writt rt = av.MakeContactTransmission("[what altitude should we maintain|what altitude do you want us at]") rt.Type = av.RadioTransmissionUnexpected + case PendingTransmissionRequestSlowDown: + // The controller may have slowed us between enqueue and dispatch; if the + // request is no longer warranted (e.g. now satisfies the speed gate), + // drop it. + if !ac.stillWantsSlowDown() { + return "", "" + } + // All phrasings share this tail; the speed-naming variants are only + // added when there's a specific assigned IAS to reference. + const tail = "are you still going to need the speed|can we start to slow|we need to start slowing here" + if sr := ac.Nav.Speed.Assigned; sr != nil && !sr.IsMach { + rt = av.MakeContactTransmission("[do you still need {spd}|how much longer do you need {spd}|"+tail+"]", + int(sr.Range[0])) + } else { + rt = av.MakeContactTransmission("[" + tail + "]") + } + rt.Type = av.RadioTransmissionUnexpected + case PendingTransmissionEmergency: if pc.PrebuiltTransmission == nil { return "", "" diff --git a/sim/sim.go b/sim/sim.go index 4c168da5a..52c64d912 100644 --- a/sim/sim.go +++ b/sim/sim.go @@ -1271,6 +1271,10 @@ func (s *Sim) updateState() { // Enqueue a spontaneous "field in sight" transmission if the pilot // wants to report and the field is currently visible. s.checkSpontaneousVisualRequest(ac) + + // Enqueue a "request to slow down" transmission if the arrival is + // being held fast by the controller as it nears the airport. + s.checkSlowDownRequest(ac) } s.possiblyRequestFlightFollowing() diff --git a/sim/slowdown_test.go b/sim/slowdown_test.go new file mode 100644 index 000000000..014607555 --- /dev/null +++ b/sim/slowdown_test.go @@ -0,0 +1,285 @@ +// sim/slowdown_test.go +// Copyright(c) 2022-2026 vice contributors, licensed under the GNU Public License, Version 3. +// SPDX: GPL-3.0-only + +package sim + +import ( + "io" + "log/slog" + "strings" + "testing" + + av "github.com/mmp/vice/aviation" + "github.com/mmp/vice/log" + "github.com/mmp/vice/math" + "github.com/mmp/vice/nav" + vrand "github.com/mmp/vice/rand" +) + +func TestArrivalSpeedGate(t *testing.T) { + cases := []struct { + dist float32 + wantGate float32 + wantOK bool + }{ + {25, 0, false}, + {20, 250, true}, + {19, 250, true}, + {18, 250, true}, + {17, 210, true}, + {14, 210, true}, + {13, 190, true}, + {8, 190, true}, + {7, 180, true}, + {2, 180, true}, + } + for _, c := range cases { + gate, ok := arrivalSpeedGate(c.dist) + if ok != c.wantOK || (ok && gate != c.wantGate) { + t.Errorf("arrivalSpeedGate(%.0f) = %v, %v; want %v, %v", c.dist, gate, ok, c.wantGate, c.wantOK) + } + } +} + +// slowDownTestAircraft builds an associated arrival on a lateral route whose +// single waypoint is distNM ahead, so SlowDownDistanceNM returns ~distNM. +func slowDownTestAircraft(distNM float32, assigned *av.SpeedRestriction) *Aircraft { + const nmPerLong = 60 + ac := &Aircraft{ + ADSBCallsign: "AAL123", + TypeOfFlight: av.FlightTypeArrival, + ControllerFrequency: "BOS_APP", + NASFlightPlan: &NASFlightPlan{}, + } + ac.Nav.FlightState = nav.FlightState{ + Position: math.NM2LL([2]float32{0, 0}, nmPerLong), + NmPerLongitude: nmPerLong, + } + ac.Nav.Waypoints = av.WaypointArray{{Fix: "RWY", Location: math.NM2LL([2]float32{0, distNM}, nmPerLong)}} + ac.Nav.Speed.Assigned = assigned + return ac +} + +func slowDownRequestCount(s *Sim, callsign av.ADSBCallsign) int { + n := 0 + for _, pcs := range s.PendingContacts { + for _, pc := range pcs { + if pc.ADSBCallsign == callsign && pc.Type == PendingTransmissionRequestSlowDown { + n++ + } + } + } + return n +} + +func at(speed float32) *av.SpeedRestriction { + r := av.MakeAtSpeedRestriction(speed) + return &r +} + +func TestCheckSlowDownRequest(t *testing.T) { + t.Run("fast and close asks once per band", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(250)) // 12nm -> 190kt gate, assigned 250 + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("after first check: %d requests, want 1", got) + } + if ac.SlowDownAskedGate != 190 { + t.Fatalf("SlowDownAskedGate = %.0f, want 190", ac.SlowDownAskedGate) + } + // Same band: no repeat. + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("after second check in same band: %d requests, want 1", got) + } + }) + + t.Run("re-asks on crossing into a closer band once the prior request clears", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(250)) + s.checkSlowDownRequest(ac) // 190 band + // The controller pops/answers the first request. + s.PendingContacts = nil + // Move inside 8nm -> 180kt gate; the closer band re-asks. + ac.Nav.Waypoints[0].Location = math.NM2LL([2]float32{0, 6}, 60) + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("after crossing into closer band: %d requests, want 1", got) + } + }) + + t.Run("re-arms on a new speed assignment within the same band", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(250)) // 190 band + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("after first check: %d requests, want 1", got) + } + // Controller acknowledges (the queued request is delivered) and re-assigns + // a still-too-fast speed. Same distance/band, but the changed assignment + // re-arms the request. + s.PendingContacts = nil + ac.Nav.Speed.Assigned = at(240) + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("after new assignment in same band: %d requests, want 1 (re-armed)", got) + } + }) + + t.Run("does not stack a duplicate while a request is still queued", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(14, at(250)) // 210 band + s.checkSlowDownRequest(ac) + // Cross into the 190 band before the controller pops the first request. + ac.Nav.Waypoints[0].Location = math.NM2LL([2]float32{0, 12}, 60) + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 1 { + t.Fatalf("queued duplicate: %d requests, want 1", got) + } + }) + + t.Run("no request when assigned speed satisfies the gate", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(190)) // exactly the 190 gate + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 0 { + t.Fatalf("%d requests, want 0 (not too fast)", got) + } + }) + + t.Run("no request without a speed assignment", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, nil) + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 0 { + t.Fatalf("%d requests, want 0 (no assignment)", got) + } + }) + + t.Run("does not ask while moving away from the field", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(250)) + // Pretend the previous tick was closer in (10nm), so at 12nm the + // aircraft is now receding — e.g. it overflew or went missed. + ac.SlowDownLastDist = 10 + s.checkSlowDownRequest(ac) + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 0 { + t.Fatalf("%d requests, want 0 while moving away", got) + } + }) + + t.Run("departures, unassociated, and off-frequency aircraft never ask", func(t *testing.T) { + s := &Sim{} + ac := slowDownTestAircraft(12, at(250)) + ac.TypeOfFlight = av.FlightTypeDeparture + s.checkSlowDownRequest(ac) + + ac2 := slowDownTestAircraft(12, at(250)) + ac2.NASFlightPlan = nil // unassociated + s.checkSlowDownRequest(ac2) + + ac3 := slowDownTestAircraft(12, at(250)) + ac3.ControllerFrequency = "" // off-frequency (e.g. radar services terminated) + s.checkSlowDownRequest(ac3) + + if got := slowDownRequestCount(s, ac.ADSBCallsign); got != 0 { + t.Fatalf("%d requests, want 0", got) + } + if got := slowDownRequestCount(s, ac3.ADSBCallsign); got != 0 { + t.Fatalf("%d requests, want 0 while off-frequency", got) + } + }) +} + +// slowDownSim wraps ac in a minimal Sim sufficient to drive +// GenerateContactTransmission. With no Controllers configured the controller +// lookup is nil, so the function returns the unprefixed base text — exactly +// what we want to assert on. +func slowDownSim(ac *Aircraft, seed uint64) *Sim { + lg := &log.Logger{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + r := vrand.Make() + r.Seed(seed) + return &Sim{ + lg: lg, + Rand: r, + State: &CommonState{}, + Aircraft: map[av.ADSBCallsign]*Aircraft{ac.ADSBCallsign: ac}, + PendingContacts: make(map[TCP][]PendingContact), + eventStream: NewEventStream(lg), + } +} + +func TestGenerateSlowDownTransmission(t *testing.T) { + // dispatch renders the request once with a fixed seed so the random + // phrasing choice is reproducible. + dispatch := func(ac *Aircraft, seed uint64) (string, string) { + s := slowDownSim(ac, seed) + return s.GenerateContactTransmission(&PendingContact{ + ADSBCallsign: ac.ADSBCallsign, + TCP: ac.ControllerFrequency, + Type: PendingTransmissionRequestSlowDown, + }) + } + + t.Run("references the assigned speed when still too fast", func(t *testing.T) { + // 12nm -> 190kt gate, assigned 250: still too fast, so it always asks. + // Only some of the phrasings name the speed, so sweep seeds: every + // transmission must be non-empty, and the speed-naming branch must + // render 250 (never a different number). + sawSpeed := false + for seed := uint64(0); seed < 50; seed++ { + _, written := dispatch(slowDownTestAircraft(12, at(250)), seed) + if written == "" { + t.Fatalf("seed %d: expected a transmission, got none", seed) + } + if strings.Contains(written, "250") { + sawSpeed = true + } else if strings.ContainsAny(written, "0123456789") { + t.Errorf("seed %d: written = %q references a speed other than 250", seed, written) + } + } + if !sawSpeed { + t.Error("no sampled phrasing referenced the assigned speed 250") + } + }) + + t.Run("maintain maximum forward omits a speed", func(t *testing.T) { + ac := slowDownTestAircraft(12, nil) + ac.Nav.Speed.MaintainMaximumForward = true + for seed := uint64(0); seed < 50; seed++ { + spoken, written := dispatch(ac, seed) + if spoken == "" || written == "" { + t.Fatalf("seed %d: expected a transmission, got spoken=%q written=%q", seed, spoken, written) + } + if strings.ContainsAny(written, "0123456789") { + t.Errorf("seed %d: written = %q should not name a speed", seed, written) + } + } + }) + + t.Run("drops the request when the assignment was removed", func(t *testing.T) { + // Enqueued while fast, but the controller has since cleared the speed. + if spoken, written := dispatch(slowDownTestAircraft(12, nil), 0); spoken != "" || written != "" { + t.Errorf("expected no transmission, got spoken=%q written=%q", spoken, written) + } + }) + + t.Run("drops the request when slowed to satisfy the gate", func(t *testing.T) { + // 12nm -> 190kt gate; controller has since assigned exactly 190. + if spoken, written := dispatch(slowDownTestAircraft(12, at(190)), 0); spoken != "" || written != "" { + t.Errorf("expected no transmission, got spoken=%q written=%q", spoken, written) + } + }) + + t.Run("drops the request for a mach assignment", func(t *testing.T) { + mach := &av.SpeedRestriction{ + NavigationRestriction: av.NavigationRestriction{Range: [2]float32{0.74, 0.74}}, + IsMach: true, + } + if spoken, written := dispatch(slowDownTestAircraft(12, mach), 0); spoken != "" || written != "" { + t.Errorf("expected no transmission, got spoken=%q written=%q", spoken, written) + } + }) +}