Skip to main content

kinavis_alerts/
manager.rs

1//! Alert manager: standing alerts and their state transitions.
2
3use core::ops::Deref;
4
5use kinavis_kernel::inline::Inline;
6use kinavis_kernel::time::{Instant, Utc};
7use kinavis_kernel::{KernelError, Result};
8
9use crate::{Alert, AlertId, AlertKind, AlertPolicy, AlertPriority, AlertState, Ended, Reportable};
10
11/// Maximum number of standing alerts.
12///
13/// More than a watch can handle; reaching it indicates a policy that is too
14/// loud. On a full board, a new alert outranking the lowest-priority standing
15/// one replaces it ([`AlertChange::Dropped`]); otherwise it is not raised and
16/// [`AlertChanges::lost`] is set. Either way the overflow is reported and the
17/// most pressing alerts are kept.
18pub const MAX_ALERTS: usize = 32;
19
20/// Maximum number of changes reported per call.
21pub const MAX_CHANGES: usize = 16;
22
23/// Alert state change.
24///
25/// `#[non_exhaustive]`; match with a wildcard arm.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27#[non_exhaustive]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub enum AlertChange {
30    /// New alert, to be annunciated.
31    Raised(AlertId),
32    /// Standing alert acknowledged.
33    Acknowledged(AlertId),
34    /// Condition of an unacknowledged alert ended; awaiting acknowledgement.
35    Rectified(AlertId),
36    /// Alert removed: acknowledged and condition ended, in either order.
37    Cleared(AlertId),
38    /// Alert removed with its condition still present, to make room for a
39    /// higher-priority alert. A later report raises it anew.
40    Dropped(AlertId),
41}
42
43/// Changes made by one call, in order.
44///
45/// Fixed capacity, like [`EventList`](kinavis_kernel::event::EventList);
46/// dereferences to a slice.
47#[must_use = "an unread change is an alert nobody annunciated"]
48#[derive(Debug, Clone, Copy)]
49pub struct AlertChanges {
50    changes: Inline<AlertChange, MAX_CHANGES>,
51    overflowed: bool,
52    lost: bool,
53}
54
55impl AlertChanges {
56    const fn new() -> Self {
57        Self {
58            changes: Inline::new(AlertChange::Raised(AlertId(0))),
59            overflowed: false,
60            lost: false,
61        }
62    }
63
64    fn push(&mut self, change: AlertChange) {
65        if self.changes.push(change).is_err() {
66            self.overflowed = true;
67        }
68    }
69
70    /// Whether a change did not fit in this list. Alert state is correct; only
71    /// the report is incomplete.
72    #[must_use]
73    pub const fn overflowed(&self) -> bool {
74        self.overflowed
75    }
76
77    /// Whether an alert could not be raised because [`MAX_ALERTS`] were
78    /// standing. Requires action in itself.
79    #[must_use]
80    pub const fn lost(&self) -> bool {
81        self.lost
82    }
83}
84
85impl Deref for AlertChanges {
86    type Target = [AlertChange];
87
88    fn deref(&self) -> &[AlertChange] {
89        &self.changes
90    }
91}
92
93/// Standing alerts under a policy.
94///
95/// Stores up to [`MAX_ALERTS`] alerts inline, ordered by priority then by time
96/// raised, so [`AlertManager::alerts`] starts with the most pressing. Several
97/// kilobytes: keep it behind a reference or in a `static`. Deliberately not
98/// `Copy`, so the board cannot be duplicated by accident:
99///
100/// ```compile_fail
101/// fn is_copy<T: Copy>() {}
102/// is_copy::<kinavis_alerts::AlertManager<kinavis_alerts::StandardPolicy>>();
103/// ```
104#[derive(Debug, Clone)]
105pub struct AlertManager<P: AlertPolicy> {
106    policy: P,
107    alerts: Inline<Alert, MAX_ALERTS>,
108    next: u32,
109}
110
111impl<P: AlertPolicy> AlertManager<P> {
112    /// Empty board under `policy`.
113    pub fn new(policy: P) -> Self {
114        Self {
115            policy,
116            alerts: Inline::new(Alert {
117                id: AlertId(0),
118                kind: AlertKind::ObservationRejected,
119                priority: AlertPriority::Caution,
120                state: AlertState::Active,
121                raised_at: Instant::UNIX_EPOCH,
122                last_reported_at: Instant::UNIX_EPOCH,
123                occurrences: 0,
124            }),
125            next: 1,
126        }
127    }
128
129    /// Policy.
130    pub const fn policy(&self) -> &P {
131        &self.policy
132    }
133
134    /// Standing alerts, most pressing first (priority, then time raised).
135    #[must_use]
136    pub fn alerts(&self) -> &[Alert] {
137        &self.alerts
138    }
139
140    /// Number of standing alerts.
141    #[must_use]
142    pub const fn len(&self) -> usize {
143        self.alerts.len()
144    }
145
146    /// Whether no alert is standing.
147    #[must_use]
148    pub const fn is_empty(&self) -> bool {
149        self.alerts.is_empty()
150    }
151
152    /// Alert by identifier, if standing.
153    #[must_use]
154    pub fn alert(&self, id: AlertId) -> Option<&Alert> {
155        self.alerts.iter().find(|alert| alert.id == id)
156    }
157
158    /// Unacknowledged alerts, most pressing first.
159    pub fn unacknowledged(&self) -> impl Iterator<Item = &Alert> + '_ {
160        self.alerts
161            .iter()
162            .filter(|alert| alert.state != AlertState::Acknowledged)
163    }
164
165    /// Most pressing standing alert, if any.
166    #[must_use]
167    pub fn highest(&self) -> Option<&Alert> {
168        self.alerts.first()
169    }
170
171    /// Processes the events of one operation at `now`.
172    ///
173    /// Each event first ends the conditions it ends, then raises or repeats its
174    /// own alert; conditions not reported within `rectify_after` are then
175    /// rectified as in [`AlertManager::tick`]. Accepts a slice of any
176    /// [`Reportable`] type — any
177    /// [`EventList`](kinavis_kernel::event::EventList) dereferences to one.
178    /// Typically one call per source list: estimator, guidance, traffic.
179    pub fn ingest<E: Reportable>(&mut self, events: &[E], now: Instant<Utc>) -> AlertChanges {
180        let mut changes = AlertChanges::new();
181        for event in events {
182            self.end(event.ends(), &mut changes);
183            if let Some(kind) = event.condition() {
184                if let Some(priority) = self.policy.classify(&kind) {
185                    self.raise_or_repeat(kind, priority, now, &mut changes);
186                }
187            }
188        }
189        self.rectify_silent(now, &mut changes);
190        changes
191    }
192
193    /// Advances time: conditions not reported within `rectify_after` are
194    /// rectified — removed if acknowledged, otherwise left as
195    /// [`AlertState::Rectified`].
196    pub fn tick(&mut self, now: Instant<Utc>) -> AlertChanges {
197        let mut changes = AlertChanges::new();
198        self.rectify_silent(now, &mut changes);
199        changes
200    }
201
202    /// Acknowledges an alert: active becomes acknowledged, rectified is
203    /// removed.
204    ///
205    /// # Errors
206    ///
207    /// [`KernelError::OutOfRange`] if no alert has that identifier.
208    pub fn acknowledge(&mut self, id: AlertId, at: Instant<Utc>) -> Result<AlertChanges> {
209        let mut changes = AlertChanges::new();
210        let index =
211            self.alerts
212                .iter()
213                .position(|alert| alert.id == id)
214                .ok_or(KernelError::OutOfRange {
215                    parameter: "alert id",
216                    value: f64::from(id.0),
217                    min: 1.0,
218                    max: f64::from(self.next.saturating_sub(1)),
219                })?;
220        self.acknowledge_at(index, at, &mut changes);
221        Ok(changes)
222    }
223
224    /// Acknowledges every standing alert.
225    pub fn acknowledge_all(&mut self, at: Instant<Utc>) -> AlertChanges {
226        let mut changes = AlertChanges::new();
227        // Removal shifts later entries down; iterate from the end.
228        let mut index = self.alerts.len();
229        while index > 0 {
230            index -= 1;
231            self.acknowledge_at(index, at, &mut changes);
232        }
233        changes
234    }
235
236    fn acknowledge_at(&mut self, index: usize, _at: Instant<Utc>, changes: &mut AlertChanges) {
237        let Some(alert) = self.alerts.get(index).copied() else {
238            return;
239        };
240        match alert.state {
241            AlertState::Active => {
242                if let Some(slot) = self.alerts.as_mut_slice().get_mut(index) {
243                    slot.state = AlertState::Acknowledged;
244                }
245                changes.push(AlertChange::Acknowledged(alert.id));
246            }
247            AlertState::Rectified => {
248                self.remove(index);
249                changes.push(AlertChange::Cleared(alert.id));
250            }
251            AlertState::Acknowledged => {}
252        }
253    }
254
255    /// Raises an alert of `kind`, or repeats the standing one.
256    fn raise_or_repeat(
257        &mut self,
258        kind: AlertKind,
259        priority: AlertPriority,
260        now: Instant<Utc>,
261        changes: &mut AlertChanges,
262    ) {
263        if let Some(standing) = self
264            .alerts
265            .as_mut_slice()
266            .iter_mut()
267            .find(|alert| alert.kind == kind)
268        {
269            standing.occurrences = standing.occurrences.saturating_add(1);
270            standing.last_reported_at = now;
271            // A rectified condition that recurs becomes active; an acknowledged
272            // one stays acknowledged.
273            if standing.state == AlertState::Rectified {
274                standing.state = AlertState::Active;
275                changes.push(AlertChange::Raised(standing.id));
276            }
277            return;
278        }
279
280        let alert = Alert {
281            id: AlertId(self.next),
282            kind,
283            priority,
284            state: AlertState::Active,
285            raised_at: now,
286            last_reported_at: now,
287            occurrences: 1,
288        };
289        // Insert after every alert of higher or equal priority.
290        let index = self
291            .alerts
292            .iter()
293            .position(|standing| standing.priority < priority)
294            .unwrap_or(self.alerts.len());
295        if self.alerts.len() >= MAX_ALERTS {
296            // Board full. The last entry is the least pressing; a newcomer that
297            // outranks it replaces it, otherwise the newcomer is lost.
298            if index >= self.alerts.len() {
299                changes.lost = true;
300                return;
301            }
302            if let Some(dropped) = self.alerts.remove(self.alerts.len().saturating_sub(1)) {
303                changes.push(AlertChange::Dropped(dropped.id));
304            }
305        }
306        if self.alerts.insert(index, alert).is_err() {
307            changes.lost = true;
308            return;
309        }
310        self.next = self.next.wrapping_add(1).max(1);
311        changes.push(AlertChange::Raised(alert.id));
312    }
313
314    /// Applies the endings of an event.
315    fn end(&mut self, ended: Ended, changes: &mut AlertChanges) {
316        let mut index = self.alerts.len();
317        while index > 0 {
318            index -= 1;
319            let Some(alert) = self.alerts.get(index).copied() else {
320                continue;
321            };
322            let ends = match ended {
323                Ended::One(kind) => alert.kind == kind,
324                Ended::EveryIntegrityDegradation => {
325                    matches!(alert.kind, AlertKind::IntegrityDegraded { .. })
326                }
327                Ended::Nothing => false,
328            };
329            if ends {
330                self.rectify_at(index, changes);
331            }
332        }
333    }
334
335    /// Rectifies every alert whose condition has gone silent.
336    fn rectify_silent(&mut self, now: Instant<Utc>, changes: &mut AlertChanges) {
337        let limit = self.policy.rectify_after();
338        let mut index = self.alerts.len();
339        while index > 0 {
340            index -= 1;
341            let Some(alert) = self.alerts.get(index).copied() else {
342                continue;
343            };
344            if alert.state == AlertState::Rectified {
345                continue;
346            }
347            let silent = now
348                .checked_duration_since(alert.last_reported_at)
349                .is_some_and(|silence| silence > limit);
350            if silent {
351                self.rectify_at(index, changes);
352            }
353        }
354    }
355
356    /// Rectifies one alert: removed if acknowledged, otherwise left as
357    /// rectified.
358    fn rectify_at(&mut self, index: usize, changes: &mut AlertChanges) {
359        let Some(alert) = self.alerts.get(index).copied() else {
360            return;
361        };
362        match alert.state {
363            AlertState::Acknowledged => {
364                self.remove(index);
365                changes.push(AlertChange::Cleared(alert.id));
366            }
367            AlertState::Active => {
368                if let Some(slot) = self.alerts.as_mut_slice().get_mut(index) {
369                    slot.state = AlertState::Rectified;
370                }
371                changes.push(AlertChange::Rectified(alert.id));
372            }
373            AlertState::Rectified => {}
374        }
375    }
376
377    /// Removes the alert at `index`, preserving order.
378    fn remove(&mut self, index: usize) {
379        let Some(first) = self.alerts.first().copied() else {
380            return;
381        };
382        let mut kept = Inline::<Alert, MAX_ALERTS>::new(first);
383        for (position, alert) in self.alerts.iter().enumerate() {
384            if position != index {
385                // The new store has the capacity of the old one.
386                let _ = kept.push(*alert);
387            }
388        }
389        self.alerts = kept;
390    }
391}
392
393#[cfg(test)]
394#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
395mod tests {
396    use super::*;
397    use crate::StandardPolicy;
398    use core::time::Duration;
399    use kinavis::event::GuidanceEvent;
400    use kinavis_kernel::event::{
401        Event, EventList, NavigationEvent, NavigationIntegrity, PositionSource, SensorHealth,
402        SensorId, TargetId,
403    };
404    use kinavis_kernel::units::Distance;
405    use kinavis_traffic::TrafficEvent;
406
407    fn start() -> Instant<Utc> {
408        Instant::from_unix_seconds(1_789_000_000)
409    }
410
411    fn after(seconds: u64) -> Instant<Utc> {
412        start().checked_add(Duration::from_secs(seconds)).unwrap()
413    }
414
415    fn manager() -> AlertManager<StandardPolicy> {
416        AlertManager::new(StandardPolicy::default())
417    }
418
419    fn one<E: Event>(event: E) -> EventList<E> {
420        let mut events = EventList::new();
421        events.push(event);
422        events
423    }
424
425    fn off_track(at: Instant<Utc>) -> GuidanceEvent {
426        GuidanceEvent::CrossTrackExceeded {
427            error: Distance::from_cables(7.0).unwrap(),
428            limit: Distance::from_cables(5.0).unwrap(),
429            at,
430        }
431    }
432
433    fn cpa(target: u32, at: Instant<Utc>) -> TrafficEvent {
434        TrafficEvent::CpaAlarm {
435            target: TargetId::new(target),
436            cpa: Distance::from_cables(3.0).unwrap(),
437            tcpa: Duration::from_secs(600),
438            at,
439        }
440    }
441
442    fn fix_lost(at: Instant<Utc>) -> NavigationEvent {
443        NavigationEvent::FixLost {
444            source: PositionSource::Gnss,
445            at,
446            last_good: at,
447        }
448    }
449
450    #[test]
451    fn a_condition_reported_many_times_is_one_alert() {
452        let mut alerts = manager();
453        let first = alerts.ingest(&one(off_track(start())), start());
454        assert_eq!(first.len(), 1);
455        assert!(matches!(first[0], AlertChange::Raised(id) if id.number() == 1));
456
457        for second in 1..10 {
458            let changes = alerts.ingest(&one(off_track(after(second))), after(second));
459            assert!(changes.is_empty());
460        }
461        assert_eq!(alerts.len(), 1);
462        let alert = alerts.alerts()[0];
463        assert_eq!(alert.occurrences(), 10);
464        assert_eq!(alert.raised_at(), start());
465        assert_eq!(alert.last_reported_at(), after(9));
466        assert_eq!(alert.kind(), AlertKind::CrossTrackExceeded);
467        assert_eq!(alert.priority(), AlertPriority::Warning);
468        assert_eq!(alert.state(), AlertState::Active);
469        assert_eq!(alloc::format!("{}", alert.id()), "A1");
470        assert_eq!(alloc::format!("{}", alert.priority()), "warning");
471    }
472
473    #[test]
474    fn the_alerts_stand_in_order_of_priority_then_of_raising() {
475        let mut alerts = manager();
476        let _ = alerts.ingest(&one(off_track(start())), start());
477        let _ = alerts.ingest(
478            &one(TrafficEvent::TargetLost {
479                target: TargetId::new(3),
480                last_seen: start(),
481            }),
482            after(1),
483        );
484        let _ = alerts.ingest(&one(cpa(7, after(2))), after(2));
485        let _ = alerts.ingest(&one(cpa(8, after(3))), after(3));
486        let _ = alerts.ingest(&one(fix_lost(after(4))), after(4));
487
488        let order = alerts
489            .alerts()
490            .iter()
491            .map(|alert| (alert.priority(), alert.id().number()))
492            .collect::<alloc::vec::Vec<_>>();
493        assert_eq!(
494            order,
495            [
496                (AlertPriority::Alarm, 3),
497                (AlertPriority::Alarm, 4),
498                (AlertPriority::Warning, 1),
499                (AlertPriority::Warning, 5),
500                (AlertPriority::Caution, 2),
501            ]
502        );
503        assert_eq!(alerts.highest().unwrap().id().number(), 3);
504        assert_eq!(alerts.unacknowledged().count(), 5);
505        assert!(alerts.alert(AlertId(4)).is_some());
506        assert!(alerts.alert(AlertId(9)).is_none());
507    }
508
509    #[test]
510    fn acknowledged_it_stands_quietly_and_goes_when_the_condition_does() {
511        let mut alerts = manager();
512        let _ = alerts.ingest(&one(off_track(start())), start());
513        let id = alerts.alerts()[0].id();
514
515        let changes = alerts.acknowledge(id, after(1)).unwrap();
516        assert!(matches!(changes[0], AlertChange::Acknowledged(acked) if acked == id));
517        assert_eq!(alerts.alerts()[0].state(), AlertState::Acknowledged);
518        assert_eq!(alerts.unacknowledged().count(), 0);
519
520        // Still reported: stays acknowledged, no new annunciation.
521        let changes = alerts.ingest(&one(off_track(after(5))), after(5));
522        assert!(changes.is_empty());
523        assert_eq!(alerts.alerts()[0].state(), AlertState::Acknowledged);
524
525        // Silent beyond the policy limit: rectified and removed.
526        let changes = alerts.tick(after(5 + 31));
527        assert!(matches!(changes[0], AlertChange::Cleared(cleared) if cleared == id));
528        assert!(alerts.is_empty());
529    }
530
531    #[test]
532    fn unacknowledged_it_is_rectified_and_waits_and_comes_back_if_reported_again() {
533        let mut alerts = manager();
534        let _ = alerts.ingest(&one(off_track(start())), start());
535        let id = alerts.alerts()[0].id();
536
537        // Exactly at the 30 s limit: not yet rectified.
538        assert!(alerts.tick(after(30)).is_empty());
539        let changes = alerts.tick(after(31));
540        assert!(matches!(changes[0], AlertChange::Rectified(rectified) if rectified == id));
541        assert_eq!(alerts.alerts()[0].state(), AlertState::Rectified);
542        assert_eq!(alerts.unacknowledged().count(), 1);
543
544        // Reported again: active again, same identifier.
545        let changes = alerts.ingest(&one(off_track(after(40))), after(40));
546        assert!(matches!(changes[0], AlertChange::Raised(raised) if raised == id));
547        assert_eq!(alerts.alerts()[0].state(), AlertState::Active);
548
549        // Rectified again, then acknowledged: removed.
550        let _ = alerts.tick(after(100));
551        let changes = alerts.acknowledge(id, after(101)).unwrap();
552        assert!(matches!(changes[0], AlertChange::Cleared(cleared) if cleared == id));
553        assert!(alerts.is_empty());
554    }
555
556    #[test]
557    // Four conditions raised and ended, each checked.
558    #[allow(clippy::too_many_lines)]
559    fn an_event_ends_the_condition_it_is_the_end_of() {
560        let mut alerts = manager();
561        let _ = alerts.ingest(&one(fix_lost(start())), start());
562        let _ = alerts.ingest(&one(cpa(7, start())), start());
563        let _ = alerts.ingest(
564            &one(NavigationEvent::SensorHealthChanged {
565                sensor: SensorId::named("GNSS 1"),
566                from: SensorHealth::Healthy,
567                to: SensorHealth::Suspect,
568                at: start(),
569            }),
570            start(),
571        );
572        let _ = alerts.ingest(
573            &one(NavigationEvent::IntegrityChanged {
574                from: NavigationIntegrity::Nominal,
575                to: NavigationIntegrity::DeadReckoning,
576                at: start(),
577            }),
578            start(),
579        );
580        assert_eq!(alerts.len(), 4);
581
582        let mut ends = EventList::new();
583        ends.push(NavigationEvent::FixAcquired {
584            source: PositionSource::Gnss,
585            at: after(1),
586        });
587        ends.push(NavigationEvent::SensorHealthChanged {
588            sensor: SensorId::named("GNSS 1"),
589            from: SensorHealth::Suspect,
590            to: SensorHealth::Healthy,
591            at: after(1),
592        });
593        ends.push(NavigationEvent::IntegrityChanged {
594            from: NavigationIntegrity::DeadReckoning,
595            to: NavigationIntegrity::Nominal,
596            at: after(1),
597        });
598        let mut changes = alerts.ingest(&ends, after(1));
599        let lost = alerts.ingest(
600            &one(TrafficEvent::TargetLost {
601                target: TargetId::new(7),
602                last_seen: after(1),
603            }),
604            after(1),
605        );
606        for change in lost.iter() {
607            changes.push(*change);
608        }
609        assert_eq!(
610            changes
611                .iter()
612                .filter(|change| matches!(change, AlertChange::Rectified(_)))
613                .count(),
614            4
615        );
616        // Target lost is its own caution; the others stand rectified.
617        assert!(changes
618            .iter()
619            .any(|change| matches!(change, AlertChange::Raised(_))));
620        assert_eq!(alerts.len(), 5);
621        assert_eq!(
622            alerts
623                .alerts()
624                .iter()
625                .filter(|alert| alert.state() == AlertState::Rectified)
626                .count(),
627            4
628        );
629
630        // An integrity degradation supersedes the previous one.
631        let _ = alerts.acknowledge_all(after(2));
632        assert_eq!(alerts.len(), 1);
633        let _ = alerts.ingest(
634            &one(NavigationEvent::IntegrityChanged {
635                from: NavigationIntegrity::Nominal,
636                to: NavigationIntegrity::DeadReckoning,
637                at: after(3),
638            }),
639            after(3),
640        );
641        let _ = alerts.ingest(
642            &one(NavigationEvent::IntegrityChanged {
643                from: NavigationIntegrity::DeadReckoning,
644                to: NavigationIntegrity::Exceeded,
645                at: after(4),
646            }),
647            after(4),
648        );
649        let degradations = alerts
650            .alerts()
651            .iter()
652            .filter(|alert| matches!(alert.kind(), AlertKind::IntegrityDegraded { .. }))
653            .collect::<alloc::vec::Vec<_>>();
654        assert_eq!(degradations.len(), 2);
655        assert!(degradations
656            .iter()
657            .any(|alert| alert.state() == AlertState::Rectified
658                && alert.kind()
659                    == AlertKind::IntegrityDegraded {
660                        to: NavigationIntegrity::DeadReckoning
661                    }));
662        assert!(degradations
663            .iter()
664            .any(|alert| alert.state() == AlertState::Active
665                && alert.priority() == AlertPriority::Warning));
666    }
667
668    #[test]
669    fn news_is_not_an_alert() {
670        let mut alerts = manager();
671        let reached = GuidanceEvent::WaypointReached {
672            index: 2,
673            at: start(),
674        };
675        let mut events = EventList::new();
676        events.push(NavigationEvent::FixAcquired {
677            source: PositionSource::Gnss,
678            at: start(),
679        });
680        events.push(NavigationEvent::ObservationRejected {
681            reason: kinavis_kernel::event::RejectionReason::Stale,
682            at: start(),
683        });
684        let acquired = TrafficEvent::TargetAcquired {
685            target: TargetId::new(1),
686            at: start(),
687        };
688        assert!(alerts.ingest(&one(reached), start()).is_empty());
689        assert!(alerts.ingest(&events, start()).is_empty());
690        assert!(alerts.ingest(&one(acquired), start()).is_empty());
691        assert!(alerts.is_empty());
692        assert_eq!(reached.condition(), None);
693        assert_eq!(acquired.condition(), None);
694        assert_eq!(events[1].condition(), Some(AlertKind::ObservationRejected));
695    }
696
697    #[test]
698    fn acknowledging_what_does_not_stand_is_refused() {
699        let mut alerts = manager();
700        assert!(matches!(
701            alerts.acknowledge(AlertId(1), start()).unwrap_err(),
702            KernelError::OutOfRange {
703                parameter: "alert id",
704                ..
705            }
706        ));
707        let _ = alerts.ingest(&one(off_track(start())), start());
708        assert!(alerts.acknowledge(AlertId(2), start()).is_err());
709        assert!(alerts.acknowledge(AlertId(1), start()).is_ok());
710        // Repeated acknowledgement is a no-op.
711        assert!(alerts.acknowledge(AlertId(1), start()).unwrap().is_empty());
712    }
713
714    #[test]
715    fn a_full_board_loses_nothing_in_silence() {
716        let mut alerts = manager();
717        for target in 0..MAX_ALERTS {
718            let changes =
719                alerts.ingest(&one(cpa(u32::try_from(target).unwrap(), start())), start());
720            assert!(!changes.lost());
721        }
722        assert_eq!(alerts.len(), MAX_ALERTS);
723        // Board full of alarms: an equal-priority alarm is lost and reported.
724        let changes = alerts.ingest(&one(cpa(u32::MAX, start())), start());
725        assert!(changes.lost());
726        assert!(changes.is_empty());
727        assert_eq!(alerts.len(), MAX_ALERTS);
728        // A known condition still repeats.
729        let changes = alerts.ingest(&one(cpa(3, after(1))), after(1));
730        assert!(!changes.lost());
731
732        // Clearing all produces more changes than the list holds; overflow is
733        // reported.
734        let changes = alerts.acknowledge_all(after(2));
735        assert!(changes.overflowed());
736        assert_eq!(changes.len(), MAX_CHANGES);
737        assert_eq!(alerts.unacknowledged().count(), 0);
738    }
739
740    #[test]
741    fn a_full_board_makes_room_for_an_alert_that_outranks_its_least() {
742        let mut alerts = manager();
743        // 31 alarms and one caution: board full.
744        for target in 0..MAX_ALERTS - 1 {
745            let _ = alerts.ingest(&one(cpa(u32::try_from(target).unwrap(), start())), start());
746        }
747        let lost = TrafficEvent::TargetLost {
748            target: TargetId::new(500),
749            last_seen: start(),
750        };
751        let caution = alerts.ingest(&one(lost), start());
752        let caution_id = match caution[0] {
753            AlertChange::Raised(id) => id,
754            other => unreachable!("the caution was raised, not {other:?}"),
755        };
756        assert_eq!(alerts.len(), MAX_ALERTS);
757        // A warning outranks the caution and replaces it.
758        let changes = alerts.ingest(&one(fix_lost(after(1))), after(1));
759        assert!(!changes.lost());
760        assert_eq!(changes[0], AlertChange::Dropped(caution_id));
761        assert!(matches!(changes[1], AlertChange::Raised(_)));
762        assert_eq!(alerts.len(), MAX_ALERTS);
763        assert!(alerts
764            .alerts()
765            .iter()
766            .all(|alert| alert.priority() >= AlertPriority::Warning));
767        // Another caution has nothing to outrank and is lost.
768        let changes = alerts.ingest(&one(lost), after(2));
769        assert!(changes.lost());
770    }
771}