1use crate::localization as l10n;
4use std::collections::VecDeque;
5use std::sync::Arc;
6
7use chrono::{DateTime, Utc};
8use egui::{Color32, RichText, Ui};
9
10use crate::data::{FeedError, inbox::Inbox};
11use crate::icons::icons;
12use crate::model::{Alert, AlertCondition, AlertId, AlertSource, AlertStatus};
13use crate::services::{AlertEvent, Alerts, Listener};
14use crate::theme::ThemeExt;
15use crate::ui::dialogs::alert_dialog::rule_form;
16use crate::ui_kit::buttons::Button;
17use crate::ui_kit::notifications::{Toast, Toasts};
18use crate::ui_kit::{EmptyState, IconButton, PanelHeader};
19
20#[derive(Clone, Debug)]
22pub struct AlertsPanelConfig {
23 pub max_height: f32,
25}
26
27impl Default for AlertsPanelConfig {
28 fn default() -> Self {
29 Self { max_height: 400.0 }
30 }
31}
32
33#[derive(Clone, Debug, PartialEq, Default)]
35#[non_exhaustive]
36pub enum AlertPanelAction {
37 #[default]
39 None,
40 CreateAlert,
42 EditAlert(Box<Alert>),
44 DuplicateAlert(Box<Alert>),
47 ExportLog(String),
49}
50
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
53enum AlertSortKey {
54 #[default]
56 Arrival,
57 Name,
59 Symbol,
61 Status,
63}
64
65#[derive(Clone, Debug, PartialEq)]
67pub struct AlertTrigger {
68 pub id: AlertId,
70 pub symbol: String,
72 pub time: DateTime<Utc>,
74 pub price: Option<f64>,
76 pub message: String,
78}
79
80const DRAFT_REPLY: u64 = u64::MAX;
82
83pub struct AlertsPanel {
90 config: AlertsPanelConfig,
91 service: Option<Arc<dyn Alerts>>,
92 inbox: Arc<Inbox>,
93 listener: Option<Listener>,
94 generation: u64,
95 alerts: Vec<Alert>,
96 log: VecDeque<AlertTrigger>,
97 error: Option<FeedError>,
98 show_log: bool,
99 notification_seconds: f32,
100 sort: AlertSortKey,
101 descending: bool,
102 sources: Vec<(AlertSource, String)>,
103 symbol: String,
104 draft: Alert,
105 inputs: Option<(crate::model::AlertInputs, Option<crate::model::Timeframe>)>,
106 draft_saving: bool,
107 pending: usize,
108 sound_choice: Option<String>,
109 sound: Option<String>,
110}
111
112impl Default for AlertsPanel {
113 fn default() -> Self {
114 Self::new(AlertsPanelConfig::default())
115 }
116}
117
118impl AlertsPanel {
119 pub fn new(config: AlertsPanelConfig) -> Self {
121 Self {
122 config,
123 service: None,
124 inbox: Arc::default(),
125 listener: None,
126 generation: 0,
127 alerts: Vec::new(),
128 log: VecDeque::new(),
129 error: None,
130 show_log: false,
131 notification_seconds: 5.0,
132 sort: AlertSortKey::default(),
133 descending: false,
134 sources: Vec::new(),
135 symbol: String::new(),
136 draft: Alert::new("", AlertCondition::Crossing(0.0)),
137 inputs: None,
138 draft_saving: false,
139 pending: 0,
140 sound_choice: None,
141 sound: None,
142 }
143 }
144
145 pub fn set_symbol(&mut self, symbol: &str) {
148 if self.symbol != symbol {
149 self.symbol = symbol.to_owned();
150 self.draft.symbol = symbol.to_owned();
151 }
152 }
153
154 pub fn set_sources(&mut self, sources: Vec<(AlertSource, String)>) {
157 self.sources = sources;
158 }
159
160 pub fn set_inputs(
162 &mut self,
163 inputs: crate::model::AlertInputs,
164 timeframe: Option<crate::model::Timeframe>,
165 ) {
166 self.inputs = Some((inputs, timeframe));
167 }
168
169 pub fn set_notification_seconds(&mut self, seconds: f32) {
171 self.notification_seconds = if seconds.is_finite() {
172 seconds.max(0.0)
173 } else {
174 5.0
175 };
176 }
177
178 pub fn set_sound(&mut self, sound: Option<String>) {
181 self.sound_choice = sound;
182 }
183
184 pub fn take_sound(&mut self) -> Option<String> {
188 self.sound.take()
189 }
190
191 pub fn set_service(&mut self, service: Arc<dyn Alerts>) {
193 self.sound = None;
194 self.listener = None;
195 self.service = Some(service);
196 self.alerts.clear();
197 self.log.clear();
198 self.error = None;
199 self.pending = 0;
200 self.draft_saving = false;
201 self.inbox = Arc::default();
202 self.watch();
203 }
204
205 fn watch(&mut self) {
206 self.generation += 1;
207 let (listener, events) = Listener::new(&self.inbox, self.generation);
208 self.listener = Some(listener);
209 if let Some(service) = &self.service {
210 service.watch(events);
211 }
212 }
213
214 pub fn alerts(&self) -> &[Alert] {
216 &self.alerts
217 }
218
219 pub fn log(&self) -> &VecDeque<AlertTrigger> {
221 &self.log
222 }
223
224 pub fn save(&mut self, alert: Alert) {
226 if !alert.is_valid() {
227 self.error = Some(FeedError::InvalidData(
228 "Alert contains invalid conditions".into(),
229 ));
230 return;
231 }
232 self.save_with(alert, 0);
233 }
234
235 fn save_with(&mut self, mut alert: Alert, key: u64) -> bool {
237 let Some(service) = &self.service else {
238 self.error = Some(FeedError::NotSupported);
239 return false;
240 };
241 if let Some((available, timeframe)) = &self.inputs {
242 if let Some(inputs) = &mut alert.inputs {
243 inputs.add_sources_from(available);
244 } else if alert.id.is_none() {
245 let mut inputs = available.clone();
246 alert.timeframe = alert.timeframe.or(*timeframe);
247 if alert.symbol != self.symbol
248 || alert.timeframe != *timeframe
249 || alert.watchlist.is_some()
250 {
251 inputs.start = None;
252 inputs.visible = None;
253 inputs.count = inputs.count.max(1000);
254 }
255 alert.inputs = Some(Box::new(inputs));
256 }
257 }
258 self.pending += 1;
259 service.save(alert, self.inbox.reply(key));
260 true
261 }
262
263 fn sorted(&self) -> Vec<&Alert> {
265 let mut alerts: Vec<&Alert> = self.alerts.iter().filter(|a| a.id.is_some()).collect();
266 let rank = |a: &Alert| match a.status {
267 AlertStatus::Active => 0,
268 AlertStatus::Triggered => 1,
269 AlertStatus::Disabled => 2,
270 AlertStatus::Expired => 3,
271 };
272 alerts.sort_by(|a, b| {
273 let order = match self.sort {
274 AlertSortKey::Arrival => return std::cmp::Ordering::Equal,
275 AlertSortKey::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
276 AlertSortKey::Symbol => a.symbol.to_lowercase().cmp(&b.symbol.to_lowercase()),
277 AlertSortKey::Status => rank(a).cmp(&rank(b)),
278 };
279 if self.descending {
280 order.reverse()
281 } else {
282 order
283 }
284 });
285 alerts
286 }
287
288 pub fn poll(&mut self, ctx: &egui::Context, toasts: &mut Toasts) {
291 let repaint = ctx.clone();
292 self.inbox.set_waker(move || repaint.request_repaint());
293 if self.listener.as_ref().is_some_and(|l| !l.is_active()) {
294 self.listener = None;
295 self.alerts.clear();
296 self.watch();
297 }
298 for (key, answer) in self.inbox.drain().answers {
299 if key == self.generation {
300 if let Ok(event) = answer.downcast::<AlertEvent>() {
301 self.apply_event(ctx, *event, toasts);
302 }
303 } else if (key == 0 || key == DRAFT_REPLY)
304 && let Ok(result) = answer.downcast::<Result<(), FeedError>>()
305 {
306 self.pending = self.pending.saturating_sub(1);
307 self.draft_saving &= key != DRAFT_REPLY;
308 match *result {
309 Ok(()) => {
310 self.error = None;
311 if key == DRAFT_REPLY {
312 self.draft =
313 Alert::new(self.symbol.clone(), AlertCondition::Crossing(0.0));
314 }
315 }
316 Err(error) => self.error = Some(error),
317 }
318 }
319 }
320 }
321
322 fn apply_event(&mut self, ctx: &egui::Context, event: AlertEvent, toasts: &mut Toasts) {
323 match event {
324 AlertEvent::Upsert(alert) => {
325 if alert.id.is_none() {
326 return;
327 }
328 if let Some(existing) = self.alerts.iter_mut().find(|a| a.id == alert.id) {
329 *existing = alert;
330 } else {
331 self.alerts.push(alert);
332 }
333 }
334 AlertEvent::Remove(id) => self.alerts.retain(|a| a.id.as_ref() != Some(&id)),
335 AlertEvent::Clear => self.alerts.clear(),
336 AlertEvent::TriggeredForSymbol {
337 id,
338 symbol,
339 time,
340 price,
341 message,
342 } => {
343 self.record_trigger(
344 ctx,
345 AlertTrigger {
346 id,
347 symbol,
348 time,
349 price,
350 message,
351 },
352 toasts,
353 );
354 }
355 AlertEvent::Triggered { id, time, price } => {
356 let symbol = self
357 .alerts
358 .iter()
359 .find(|a| a.id.as_ref() == Some(&id))
360 .map(|a| a.symbol.clone())
361 .unwrap_or_default();
362 self.record_trigger(
363 ctx,
364 AlertTrigger {
365 id,
366 symbol,
367 time,
368 price,
369 message: String::new(),
370 },
371 toasts,
372 );
373 }
374 }
375 }
376
377 fn record_trigger(
378 &mut self,
379 ctx: &egui::Context,
380 mut trigger: AlertTrigger,
381 toasts: &mut Toasts,
382 ) {
383 let AlertTrigger {
384 id,
385 symbol,
386 message,
387 ..
388 } = &trigger;
389 let title = l10n::format_text(
390 ctx,
391 "Alert triggered: {subject}",
392 &[(
393 "subject",
394 if symbol.is_empty() {
395 id.0.as_str()
396 } else {
397 symbol.as_str()
398 },
399 )],
400 );
401 toasts.add(
402 Toast::info(if message.is_empty() {
403 title
404 } else {
405 format!("{title}\n{message}")
406 })
407 .duration(self.notification_seconds),
408 );
409 self.sound = self.sound_choice.clone();
410 let extra = toasts.len().saturating_sub(5);
411 toasts.toasts_mut().drain(..extra);
412 trigger.price = trigger.price.filter(|p| p.is_finite());
413 self.log.push_back(trigger);
414 if self.log.len() > 10_000 {
415 self.log.pop_front();
416 }
417 }
418
419 pub fn log_csv(&self) -> String {
421 let mut csv = String::from("id,symbol,time,price,message\r\n");
422 for trigger in &self.log {
423 let id = trigger.id.0.replace('"', "\"\"");
424 let symbol = trigger.symbol.replace('"', "\"\"");
425 let price = trigger.price.map(|p| p.to_string()).unwrap_or_default();
426 let message = trigger.message.replace('"', "\"\"");
427 csv.push_str(&format!(
428 "\"{id}\",\"{symbol}\",{},{},\"{message}\"\r\n",
429 trigger.time.to_rfc3339(),
430 price
431 ));
432 }
433 csv
434 }
435
436 fn status_color(&self, ui: &Ui, status: AlertStatus) -> Color32 {
437 let theme = ui.active_theme();
438 match status {
439 AlertStatus::Active => theme.ui.success,
440 AlertStatus::Triggered => theme.ui.warning,
441 AlertStatus::Disabled => theme.ui.text_disabled,
442 AlertStatus::Expired => theme.ui.error,
443 }
444 }
445
446 fn sort_controls(&mut self, ui: &mut Ui) {
447 ui.horizontal_wrapped(|ui| {
448 ui.label(l10n::text(ui.ctx(), "Sort by"));
449 egui::ComboBox::from_id_salt("alert_sort")
450 .selected_text(l10n::text(
451 ui.ctx(),
452 match self.sort {
453 AlertSortKey::Name => "Name",
454 AlertSortKey::Symbol => "Symbol",
455 AlertSortKey::Status => "Status",
456 AlertSortKey::Arrival => "Arrival",
457 },
458 ))
459 .show_ui(ui, |ui| {
460 for (key, label) in [
461 (AlertSortKey::Arrival, "Arrival"),
462 (AlertSortKey::Name, "Name"),
463 (AlertSortKey::Symbol, "Symbol"),
464 (AlertSortKey::Status, "Status"),
465 ] {
466 ui.selectable_value(&mut self.sort, key, l10n::text(ui.ctx(), label));
467 }
468 })
469 .response
470 .accessible_name(l10n::text(ui.ctx(), "Sort by"));
471 ui.add_enabled(
472 self.sort != AlertSortKey::Arrival,
473 egui::Checkbox::new(&mut self.descending, l10n::text(ui.ctx(), "Descending")),
474 );
475 });
476 }
477
478 fn draft_form(&mut self, ui: &mut Ui) {
481 egui::CollapsingHeader::new(l10n::text(ui.ctx(), "Quick alert"))
482 .id_salt("alert_draft")
483 .show(ui, |ui| {
484 ui.horizontal(|ui| {
485 ui.label(l10n::text(ui.ctx(), "Symbol"));
486 ui.text_edit_singleline(&mut self.draft.symbol)
487 .accessible_name(l10n::text(ui.ctx(), "Symbol"));
488 });
489 rule_form(
490 ui,
491 &mut self.draft.source,
492 &mut self.draft.condition,
493 &self.sources,
494 );
495 if self.draft_saving {
496 ui.label(l10n::text(ui.ctx(), "Saving…"));
497 }
498 if ui
499 .add_enabled(
500 self.draft.is_valid() && !self.draft_saving,
501 egui::Button::new(l10n::text(ui.ctx(), "Create alert")),
502 )
503 .clicked()
504 {
505 self.draft_saving = self.save_with(self.draft.clone(), DRAFT_REPLY);
506 }
507 });
508 if self.pending > 0 {
509 ui.label(l10n::text(ui.ctx(), "Waiting for the alerts service…"));
510 }
511 }
512
513 pub fn show(&mut self, ui: &mut Ui) -> AlertPanelAction {
515 let mut action = AlertPanelAction::None;
516 PanelHeader::new("Alerts").show(ui, |ui| {
517 if IconButton::new(&icons::PLUS)
518 .tooltip("New alert")
519 .show(ui)
520 .clicked()
521 {
522 action = AlertPanelAction::CreateAlert;
523 }
524 });
525 ui.horizontal(|ui| {
526 ui.selectable_value(&mut self.show_log, false, l10n::text(ui.ctx(), "Alerts"));
527 ui.selectable_value(&mut self.show_log, true, l10n::text(ui.ctx(), "Log"));
528 });
529 if let Some(error) = &self.error {
530 ui.colored_label(
531 ui.visuals().error_fg_color,
532 l10n::text(ui.ctx(), error.to_string()),
533 );
534 }
535 if self.show_log {
536 if Button::new("Export CSV…")
537 .enabled(!self.log.is_empty())
538 .show(ui)
539 .clicked()
540 {
541 action = AlertPanelAction::ExportLog(self.log_csv());
542 }
543 egui::ScrollArea::vertical()
544 .max_height(self.config.max_height)
545 .show(ui, |ui| {
546 for trigger in self.log.iter().rev() {
547 ui.label(format!(
548 "{} · {}",
549 trigger.symbol,
550 trigger.time.format("%Y-%m-%d %H:%M:%S UTC")
551 ));
552 if let Some(price) = trigger.price {
553 ui.label(l10n::text(ui.ctx(), price.to_string()));
554 }
555 if !trigger.message.is_empty() {
556 ui.label(&trigger.message);
557 }
558 ui.separator();
559 }
560 });
561 if self.log.is_empty() {
562 EmptyState::new("No triggers yet")
563 .icon(&icons::ALERTS)
564 .fill_height()
565 .show(ui);
566 }
567 } else if self.alerts.is_empty() {
568 self.draft_form(ui);
569 if EmptyState::new("No alerts yet")
570 .icon(&icons::ALERTS)
571 .description("Create an alert with the + button or the chart menu.")
572 .action("Create alert")
573 .fill_height()
574 .show(ui)
575 {
576 action = AlertPanelAction::CreateAlert;
577 }
578 } else {
579 self.draft_form(ui);
580 self.sort_controls(ui);
581 let mut remove = None;
582 let mut toggle = None;
583 let mut duplicate = None;
584 egui::ScrollArea::vertical()
585 .max_height(self.config.max_height)
586 .show(ui, |ui| {
587 for alert in self.sorted() {
588 let Some(id) = &alert.id else { continue };
589 ui.push_id(&id.0, |ui| {
590 let subject = alert.watchlist.as_ref().map_or_else(
591 || alert.symbol.clone(),
592 |id| {
593 l10n::format_text(
594 ui.ctx(),
595 "Watchlist: {name}",
596 &[("name", id.as_str())],
597 )
598 },
599 );
600 ui.label(RichText::new(l10n::text(ui.ctx(), subject)).strong());
601 ui.label(l10n::text(ui.ctx(), alert.condition.to_string()));
602 if !alert.additional_conditions.is_empty() {
603 ui.label(l10n::format_text(
604 ui.ctx(),
605 "{0} additional conditions",
606 &[("0", format!("{}", alert.additional_conditions.len()))],
607 ));
608 }
609 if !alert.name.is_empty() {
610 ui.label(l10n::text(ui.ctx(), &alert.name));
611 }
612 let color = self.status_color(ui, alert.status);
613 ui.colored_label(
614 color,
615 l10n::text(
616 ui.ctx(),
617 match alert.status {
618 AlertStatus::Active => "Active",
619 AlertStatus::Triggered => "Triggered",
620 AlertStatus::Disabled => "Disabled",
621 AlertStatus::Expired => "Expired",
622 },
623 ),
624 );
625 if !alert.message.is_empty() {
626 ui.label(l10n::text(ui.ctx(), &alert.message));
627 }
628 ui.horizontal_wrapped(|ui| {
629 if Button::new("Edit").show(ui).clicked() {
630 action = AlertPanelAction::EditAlert(Box::new(alert.clone()));
631 }
632 let active = alert.status == AlertStatus::Active;
633 if Button::new(if active { "Pause" } else { "Enable" })
634 .show(ui)
635 .clicked()
636 {
637 let mut changed = alert.clone();
638 changed.status = if active {
639 AlertStatus::Disabled
640 } else {
641 AlertStatus::Active
642 };
643 toggle = Some(changed);
644 }
645 if Button::new("Duplicate").show(ui).clicked() {
646 let mut copy = alert.clone();
647 copy.id = None;
648 copy.status = AlertStatus::Active;
649 duplicate = Some(copy);
650 }
651 if IconButton::new(&icons::TRASH)
652 .tooltip("Remove alert")
653 .show(ui)
654 .clicked()
655 {
656 remove = Some(id.clone());
657 }
658 });
659 ui.separator();
660 });
661 }
662 });
663 if let Some(alert) = toggle {
664 self.save(alert);
665 }
666 if let Some(id) = remove
667 && let Some(service) = &self.service
668 {
669 self.pending += 1;
670 service.remove(id, self.inbox.reply(0));
671 }
672 if let Some(alert) = duplicate {
673 action = AlertPanelAction::DuplicateAlert(Box::new(alert));
674 }
675 }
676 action
677 }
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683 use crate::data::Reply;
684 use crate::model::AlertCondition;
685 use crate::services::Events;
686 use std::sync::Mutex;
687
688 #[derive(Default)]
689 struct Host {
690 events: Mutex<Option<Events<AlertEvent>>>,
691 reply: Mutex<Option<Reply<()>>>,
692 }
693
694 impl Alerts for Host {
695 fn watch(&self, events: Events<AlertEvent>) {
696 *self.events.lock().unwrap() = Some(events);
697 }
698 fn save(&self, _: Alert, reply: Reply<()>) {
699 *self.reply.lock().unwrap() = Some(reply);
700 }
701 fn remove(&self, _: AlertId, reply: Reply<()>) {
702 reply.send(Ok(()));
703 }
704 }
705
706 #[test]
707 fn only_host_pushes_change_the_list_and_triggers_survive_removal_and_export() {
708 let host = Arc::new(Host::default());
709 let mut panel = AlertsPanel::default();
710 panel.set_service(host.clone());
711 panel.set_notification_seconds(12.0);
712 let events = host.events.lock().unwrap().clone().unwrap();
713 let ctx = egui::Context::default();
714 let mut toasts = Toasts::new();
715 let mut frame = |panel: &mut AlertsPanel| {
716 let output = ctx.run_ui(egui::RawInput::default(), |ui| {
717 panel.poll(ui.ctx(), &mut toasts)
718 });
719 output.drop_without_applying_deltas();
720 };
721 let mut alert = Alert::new("S\"IM", AlertCondition::CrossingUp(12.5));
722 alert.id = Some(AlertId("host,\"1".into()));
723 panel.save(alert.clone());
724 host.reply.lock().unwrap().take().unwrap().send(Ok(()));
725 frame(&mut panel);
726 assert!(panel.alerts().is_empty());
727 events.push(AlertEvent::Upsert(alert.clone()));
728 events.push(AlertEvent::Triggered {
729 id: alert.id.clone().unwrap(),
730 time: "2026-10-23T12:00:00Z".parse().unwrap(),
731 price: Some(12.5),
732 });
733 frame(&mut panel);
734 assert_eq!(panel.alerts(), &[alert.clone()]);
735 assert_eq!(panel.alerts()[0].status, AlertStatus::Active);
736 assert_eq!(panel.log().len(), 1);
737 assert_eq!(
738 panel.log_csv(),
739 "id,symbol,time,price,message\r\n\"host,\"\"1\",\"S\"\"IM\",2026-10-23T12:00:00+00:00,12.5,\"\"\r\n"
740 );
741 alert.status = AlertStatus::Disabled;
742 events.push(AlertEvent::Upsert(alert.clone()));
743 frame(&mut panel);
744 assert_eq!(panel.alerts(), &[alert.clone()]);
745 panel.save(alert.clone());
746 host.reply
747 .lock()
748 .unwrap()
749 .take()
750 .unwrap()
751 .send(Err(FeedError::NotSupported));
752 frame(&mut panel);
753 assert_eq!(panel.error, Some(FeedError::NotSupported));
754 events.push(AlertEvent::Remove(alert.id.clone().unwrap()));
755 frame(&mut panel);
756 assert!(panel.alerts().is_empty());
757 assert_eq!(panel.log().len(), 1);
758 panel.save(alert);
759 for _ in 0..10_001 {
760 events.push(AlertEvent::Clear);
761 }
762 frame(&mut panel);
763 assert!(!events.is_active());
764 let renewed = host.events.lock().unwrap().clone().unwrap();
765 assert!(renewed.is_active());
766 host.reply
767 .lock()
768 .unwrap()
769 .take()
770 .unwrap()
771 .send(Err(FeedError::Unanswered));
772 frame(&mut panel);
773 assert_eq!(panel.error, Some(FeedError::Unanswered));
774 assert_eq!(panel.log().len(), 1);
775 assert_eq!(toasts.toasts()[0].duration, 12.0);
776 assert_eq!(panel.take_sound(), None);
779 panel.set_sound(Some("Bell".into()));
780 panel.apply_event(
781 &egui::Context::default(),
782 AlertEvent::TriggeredForSymbol {
783 id: AlertId("watchlist".into()),
784 symbol: "AAPL".into(),
785 time: Utc::now(),
786 price: Some(123.0),
787 message: "AAPL closed at \"123\"".into(),
788 },
789 &mut toasts,
790 );
791 assert!(toasts.toasts().iter().all(|toast| toast.duration == 12.0));
792 assert_eq!(
793 toasts.toasts().last().unwrap().message,
794 "Alert triggered: AAPL\nAAPL closed at \"123\""
795 );
796 assert_eq!(panel.log().back().unwrap().symbol, "AAPL");
797 assert_eq!(
798 panel.log().back().unwrap().message,
799 "AAPL closed at \"123\""
800 );
801 assert!(
802 panel
803 .log_csv()
804 .ends_with(",123,\"AAPL closed at \"\"123\"\"\"\r\n")
805 );
806 assert_eq!(panel.take_sound(), Some("Bell".into()));
807 assert_eq!(panel.take_sound(), None);
808 drop(panel);
809 assert!(!renewed.is_active());
810 }
811
812 #[test]
813 fn sorting_orders_by_key_in_either_direction_and_keeps_arrival_order_for_equal_values() {
814 let mut panel = AlertsPanel::default();
815 for (id, name, symbol, status) in [
816 ("1", "beta", "MSFT", AlertStatus::Disabled),
817 ("2", "Alpha", "aapl", AlertStatus::Active),
818 ("3", "beta", "AAPL", AlertStatus::Active),
819 ("4", "", "TSLA", AlertStatus::Expired),
820 ] {
821 let mut alert = Alert::new(symbol, AlertCondition::Above(1.0));
822 alert.id = Some(AlertId(id.into()));
823 alert.name = name.into();
824 alert.status = status;
825 panel.alerts.push(alert);
826 }
827 let ids = |panel: &AlertsPanel| {
828 panel
829 .sorted()
830 .iter()
831 .map(|a| a.id.as_ref().unwrap().0.as_str())
832 .collect::<String>()
833 };
834 for (key, descending, expected) in [
835 (AlertSortKey::Arrival, true, "1234"),
836 (AlertSortKey::Name, false, "4213"),
837 (AlertSortKey::Name, true, "1324"),
838 (AlertSortKey::Symbol, false, "2314"),
839 (AlertSortKey::Symbol, true, "4123"),
840 (AlertSortKey::Status, false, "2314"),
841 (AlertSortKey::Status, true, "4123"),
842 ] {
843 (panel.sort, panel.descending) = (key, descending);
844 assert_eq!(ids(&panel), expected, "{key:?} descending={descending}");
845 }
846 }
847
848 #[test]
849 fn inline_draft_stays_pending_until_the_service_answers_and_survives_a_failure() {
850 let host = Arc::new(Host::default());
851 let mut panel = AlertsPanel::default();
852 panel.set_service(host.clone());
853 let ctx = egui::Context::default();
854 let mut toasts = Toasts::new();
855 let mut frame = |panel: &mut AlertsPanel| {
856 ctx.run_ui(egui::RawInput::default(), |ui| {
857 panel.poll(ui.ctx(), &mut toasts)
858 })
859 .drop_without_applying_deltas();
860 };
861 panel.set_symbol("SIM");
862 panel.draft.condition = AlertCondition::CrossingUp(10.0);
863 panel.draft_saving = panel.save_with(panel.draft.clone(), DRAFT_REPLY);
864 assert_eq!((panel.pending, panel.draft_saving), (1, true));
865 host.reply
866 .lock()
867 .unwrap()
868 .take()
869 .unwrap()
870 .send(Err(FeedError::NotSupported));
871 frame(&mut panel);
872 assert_eq!((panel.pending, panel.draft_saving), (0, false));
873 assert_eq!(panel.error, Some(FeedError::NotSupported));
874 assert_eq!(panel.draft.symbol, "SIM");
875 panel.draft_saving = panel.save_with(panel.draft.clone(), DRAFT_REPLY);
876 host.reply.lock().unwrap().take().unwrap().send(Ok(()));
877 frame(&mut panel);
878 assert_eq!(panel.error, None);
879 assert_eq!(panel.draft.symbol, "SIM");
880 assert_eq!(panel.draft.condition, AlertCondition::Crossing(0.0));
881 assert!(panel.alerts().is_empty());
882 }
883
884 #[test]
885 fn default_alert_status_colours_follow_theme_changes() {
886 let panel = AlertsPanel::default();
887 let ctx = egui::Context::default();
888 for (_, theme) in crate::theme::presets() {
889 crate::theme::apply_to_egui(&ctx, &theme);
890 ctx.run_ui(Default::default(), |ui| {
891 for (status, expected) in [
892 (AlertStatus::Active, theme.ui.success),
893 (AlertStatus::Triggered, theme.ui.warning),
894 (AlertStatus::Disabled, theme.ui.text_disabled),
895 (AlertStatus::Expired, theme.ui.error),
896 ] {
897 assert_eq!(panel.status_color(ui, status), expected);
898 }
899 })
900 .drop_without_applying_deltas();
901 }
902 }
903}