1#![forbid(unsafe_code)]
2
3use ahash::AHashMap;
31use std::collections::VecDeque;
32use std::hash::{Hash, Hasher};
33use web_time::{Duration, Instant};
34
35use ftui_core::geometry::Rect;
36use ftui_render::frame::Frame;
37
38use crate::Widget;
39use crate::toast::{Toast, ToastId, ToastPosition};
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
46pub enum NotificationPriority {
47 Low = 0,
49 #[default]
51 Normal = 1,
52 High = 2,
54 Urgent = 3,
56}
57
58#[derive(Debug, Clone)]
60pub struct QueueConfig {
61 pub max_visible: usize,
63 pub max_queued: usize,
65 pub default_duration: Duration,
67 pub position: ToastPosition,
69 pub stagger_offset: u16,
71 pub dedup_window_ms: u64,
73}
74
75impl Default for QueueConfig {
76 fn default() -> Self {
77 Self {
78 max_visible: 3,
79 max_queued: 10,
80 default_duration: Duration::from_secs(5),
81 position: ToastPosition::TopRight,
82 stagger_offset: 1,
83 dedup_window_ms: 1000,
84 }
85 }
86}
87
88impl QueueConfig {
89 pub fn new() -> Self {
91 Self::default()
92 }
93
94 #[must_use]
96 pub fn max_visible(mut self, max: usize) -> Self {
97 self.max_visible = max;
98 self
99 }
100
101 #[must_use]
103 pub fn max_queued(mut self, max: usize) -> Self {
104 self.max_queued = max;
105 self
106 }
107
108 #[must_use]
110 pub fn default_duration(mut self, duration: Duration) -> Self {
111 self.default_duration = duration;
112 self
113 }
114
115 #[must_use]
117 pub fn position(mut self, position: ToastPosition) -> Self {
118 self.position = position;
119 self
120 }
121
122 #[must_use]
124 pub fn stagger_offset(mut self, offset: u16) -> Self {
125 self.stagger_offset = offset;
126 self
127 }
128
129 #[must_use]
131 pub fn dedup_window_ms(mut self, ms: u64) -> Self {
132 self.dedup_window_ms = ms;
133 self
134 }
135}
136
137#[derive(Debug)]
139struct QueuedNotification {
140 toast: Toast,
141 priority: NotificationPriority,
142 #[allow(dead_code)]
144 created_at: Instant,
145 content_hash: u64,
146}
147
148impl QueuedNotification {
149 fn new(toast: Toast, priority: NotificationPriority) -> Self {
150 let content_hash = Self::compute_hash(&toast);
151 Self {
152 toast,
153 priority,
154 created_at: Instant::now(),
155 content_hash,
156 }
157 }
158
159 fn compute_hash(toast: &Toast) -> u64 {
160 use std::collections::hash_map::DefaultHasher;
161 let mut hasher = DefaultHasher::new();
162 toast.content.message.hash(&mut hasher);
163 if let Some(ref title) = toast.content.title {
164 title.hash(&mut hasher);
165 }
166 hasher.finish()
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum QueueAction {
173 Show(ToastId),
175 Hide(ToastId),
177 Reposition(ToastId),
179}
180
181#[derive(Debug, Clone, Default)]
183pub struct QueueStats {
184 pub total_pushed: u64,
186 pub overflow_count: u64,
188 pub dedup_count: u64,
190 pub user_dismissed: u64,
192 pub auto_expired: u64,
194}
195
196#[derive(Debug)]
202pub struct NotificationQueue {
203 queue: VecDeque<QueuedNotification>,
205 visible: Vec<Toast>,
207 config: QueueConfig,
209 dedup_window: Duration,
211 recent_hashes: AHashMap<u64, Instant>,
213 stats: QueueStats,
215}
216
217pub struct NotificationStack<'a> {
222 queue: &'a NotificationQueue,
223 margin: u16,
224}
225
226impl<'a> NotificationStack<'a> {
227 pub fn new(queue: &'a NotificationQueue) -> Self {
229 Self { queue, margin: 1 }
230 }
231
232 #[must_use]
234 pub fn margin(mut self, margin: u16) -> Self {
235 self.margin = margin;
236 self
237 }
238}
239
240impl Widget for NotificationStack<'_> {
241 fn render(&self, area: Rect, frame: &mut Frame) {
242 if area.is_empty() || self.queue.visible().is_empty() {
243 return;
244 }
245
246 let positions = self
247 .queue
248 .calculate_positions(area.width, area.height, self.margin);
249
250 for (toast, (_, rel_x, rel_y)) in self.queue.visible().iter().zip(positions.iter()) {
251 let (toast_width, toast_height) = toast.calculate_dimensions();
252 let x = area.x.saturating_add(*rel_x);
253 let y = area.y.saturating_add(*rel_y);
254 let toast_area = Rect::new(x, y, toast_width, toast_height);
255 let render_area = toast_area.intersection(&area);
256 if !render_area.is_empty() {
257 toast.render(render_area, frame);
258 }
259 }
260 }
261}
262
263impl NotificationQueue {
264 pub fn new(config: QueueConfig) -> Self {
266 let dedup_window = Duration::from_millis(config.dedup_window_ms);
267 Self {
268 queue: VecDeque::new(),
269 visible: Vec::new(),
270 config,
271 dedup_window,
272 recent_hashes: AHashMap::new(),
273 stats: QueueStats::default(),
274 }
275 }
276
277 pub fn with_defaults() -> Self {
279 Self::new(QueueConfig::default())
280 }
281
282 pub fn push(&mut self, toast: Toast, priority: NotificationPriority) -> bool {
287 self.stats.total_pushed += 1;
288 let queued = QueuedNotification::new(self.apply_default_duration(toast), priority);
289
290 if !self.dedup_check(queued.content_hash) {
292 self.stats.dedup_count += 1;
293 return false;
294 }
295
296 if self.queue.len() >= self.config.max_queued {
298 self.stats.overflow_count += 1;
299 if let Some(idx) = self.find_lowest_priority_index() {
301 if self.queue[idx].priority < priority {
302 self.queue.remove(idx);
303 } else {
304 return false; }
306 } else {
307 return false;
308 }
309 }
310
311 if priority == NotificationPriority::Urgent {
313 self.queue.push_front(queued);
315 } else {
316 let insert_idx = self
318 .queue
319 .iter()
320 .position(|q| q.priority < priority)
321 .unwrap_or(self.queue.len());
322 self.queue.insert(insert_idx, queued);
323 }
324
325 true
326 }
327
328 pub fn notify(&mut self, toast: Toast) -> bool {
330 self.push(toast, NotificationPriority::Normal)
331 }
332
333 pub fn urgent(&mut self, toast: Toast) -> bool {
335 self.push(toast, NotificationPriority::Urgent)
336 }
337
338 pub fn dismiss(&mut self, id: ToastId) {
340 if let Some(idx) = self.visible.iter().position(|t| t.id == id)
342 && !self.visible[idx].state.dismissed
343 {
344 self.visible[idx].dismiss();
345 self.stats.user_dismissed += 1;
346 }
347
348 if let Some(idx) = self.queue.iter().position(|q| q.toast.id == id) {
350 self.queue.remove(idx);
351 self.stats.user_dismissed += 1;
352 }
353 }
354
355 pub fn dismiss_all(&mut self) {
357 let mut dismissed_visible = 0u64;
358 for toast in &mut self.visible {
359 if !toast.state.dismissed {
360 toast.dismiss();
361 dismissed_visible += 1;
362 }
363 }
364 self.stats.user_dismissed += dismissed_visible + self.queue.len() as u64;
365 self.queue.clear();
366 }
367
368 pub fn tick(&mut self, _delta: Duration) -> Vec<QueueAction> {
373 let mut actions = Vec::new();
374
375 let now = Instant::now();
377 self.recent_hashes
378 .retain(|_, t| now.saturating_duration_since(*t) < self.dedup_window);
379
380 let mut i = 0;
382 while i < self.visible.len() {
383 let toast = &mut self.visible[i];
384
385 if !toast.state.dismissed && toast.is_expired() {
387 toast.dismiss();
388 self.stats.auto_expired += 1;
389 }
390
391 toast.tick_animation();
393
394 if !self.visible[i].is_visible() {
395 let id = self.visible[i].id;
396 self.visible.remove(i);
397 actions.push(QueueAction::Hide(id));
398 } else {
399 i += 1;
400 }
401 }
402
403 while self.visible.len() < self.config.max_visible {
405 if let Some(queued) = self.queue.pop_front() {
406 let id = queued.toast.id;
407 self.visible.push(queued.toast);
408 actions.push(QueueAction::Show(id));
409 } else {
410 break;
411 }
412 }
413
414 actions
415 }
416
417 pub fn visible(&self) -> &[Toast] {
419 &self.visible
420 }
421
422 pub fn visible_mut(&mut self) -> &mut [Toast] {
424 &mut self.visible
425 }
426
427 pub fn pending_count(&self) -> usize {
429 self.queue.len()
430 }
431
432 pub fn visible_count(&self) -> usize {
434 self.visible.len()
435 }
436
437 pub fn total_count(&self) -> usize {
439 self.visible.len() + self.queue.len()
440 }
441
442 #[inline]
444 pub fn is_empty(&self) -> bool {
445 self.visible.is_empty() && self.queue.is_empty()
446 }
447
448 pub fn stats(&self) -> &QueueStats {
450 &self.stats
451 }
452
453 pub fn config(&self) -> &QueueConfig {
455 &self.config
456 }
457
458 pub fn calculate_positions(
462 &self,
463 terminal_width: u16,
464 terminal_height: u16,
465 margin: u16,
466 ) -> Vec<(ToastId, u16, u16)> {
467 let mut positions = Vec::with_capacity(self.visible.len());
468 let is_top = matches!(
469 self.config.position,
470 ToastPosition::TopLeft | ToastPosition::TopCenter | ToastPosition::TopRight
471 );
472
473 let mut y_offset: u16 = 0;
474
475 for toast in &self.visible {
476 let (toast_width, toast_height) = toast.calculate_dimensions();
477 let (base_x, base_y) = self.config.position.calculate_position(
478 terminal_width,
479 terminal_height,
480 toast_width,
481 toast_height,
482 margin,
483 );
484
485 let y = if is_top {
486 base_y.saturating_add(y_offset)
487 } else {
488 base_y.saturating_sub(y_offset)
489 };
490
491 positions.push((toast.id, base_x, y));
492 y_offset = y_offset
493 .saturating_add(toast_height)
494 .saturating_add(self.config.stagger_offset);
495 }
496
497 positions
498 }
499
500 fn dedup_check(&mut self, hash: u64) -> bool {
504 let now = Instant::now();
505
506 self.recent_hashes
508 .retain(|_, t| now.saturating_duration_since(*t) < self.dedup_window);
509
510 if self.recent_hashes.contains_key(&hash) {
512 return false;
513 }
514
515 self.recent_hashes.insert(hash, now);
516 true
517 }
518
519 fn find_lowest_priority_index(&self) -> Option<usize> {
521 self.queue
522 .iter()
523 .enumerate()
524 .min_by_key(|(_, q)| q.priority)
525 .map(|(i, _)| i)
526 }
527
528 fn apply_default_duration(&self, mut toast: Toast) -> Toast {
529 if !toast.config.duration_explicit {
530 toast.config.duration = Some(self.config.default_duration);
531 toast.config.duration_explicit = true;
532 }
533 toast
534 }
535}
536
537impl Default for NotificationQueue {
538 fn default() -> Self {
539 Self::with_defaults()
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use ftui_render::frame::Frame;
547 use ftui_render::grapheme_pool::GraphemePool;
548 use web_time::Duration;
549
550 fn make_toast(msg: &str) -> Toast {
551 Toast::with_id(ToastId::new(0), msg)
552 .persistent()
553 .no_animation() }
555
556 fn make_ephemeral_toast(msg: &str) -> Toast {
557 Toast::new(msg).no_animation()
558 }
559
560 #[test]
561 fn test_queue_new() {
562 let queue = NotificationQueue::with_defaults();
563 assert!(queue.is_empty());
564 assert_eq!(queue.visible_count(), 0);
565 assert_eq!(queue.pending_count(), 0);
566 }
567
568 #[test]
569 fn test_queue_push_and_tick() {
570 let mut queue = NotificationQueue::with_defaults();
571
572 queue.push(make_toast("Hello"), NotificationPriority::Normal);
573 assert_eq!(queue.pending_count(), 1);
574 assert_eq!(queue.visible_count(), 0);
575
576 let actions = queue.tick(Duration::from_millis(16));
578 assert_eq!(queue.pending_count(), 0);
579 assert_eq!(queue.visible_count(), 1);
580 assert_eq!(actions.len(), 1);
581 assert!(matches!(actions[0], QueueAction::Show(_)));
582 }
583
584 #[test]
585 fn test_queue_fifo() {
586 let config = QueueConfig::default().max_visible(1);
587 let mut queue = NotificationQueue::new(config);
588
589 queue.push(make_toast("First"), NotificationPriority::Normal);
590 queue.push(make_toast("Second"), NotificationPriority::Normal);
591 queue.push(make_toast("Third"), NotificationPriority::Normal);
592
593 queue.tick(Duration::from_millis(16));
594 assert_eq!(queue.visible()[0].content.message, "First");
595
596 queue.visible_mut()[0].dismiss();
598 queue.tick(Duration::from_millis(16));
599 assert_eq!(queue.visible()[0].content.message, "Second");
600 }
601
602 #[test]
603 fn test_queue_max_visible() {
604 let config = QueueConfig::default().max_visible(2);
605 let mut queue = NotificationQueue::new(config);
606
607 queue.push(make_toast("A"), NotificationPriority::Normal);
608 queue.push(make_toast("B"), NotificationPriority::Normal);
609 queue.push(make_toast("C"), NotificationPriority::Normal);
610
611 queue.tick(Duration::from_millis(16));
612
613 assert_eq!(queue.visible_count(), 2);
614 assert_eq!(queue.pending_count(), 1);
615 }
616
617 #[test]
618 fn test_queue_priority_urgent() {
619 let config = QueueConfig::default().max_visible(1);
620 let mut queue = NotificationQueue::new(config);
621
622 queue.push(make_toast("Normal1"), NotificationPriority::Normal);
623 queue.push(make_toast("Normal2"), NotificationPriority::Normal);
624 queue.push(make_toast("Urgent"), NotificationPriority::Urgent);
625
626 queue.tick(Duration::from_millis(16));
627 assert_eq!(queue.visible()[0].content.message, "Urgent");
629 }
630
631 #[test]
632 fn test_queue_priority_ordering() {
633 let config = QueueConfig::default().max_visible(0); let mut queue = NotificationQueue::new(config);
635
636 queue.push(make_toast("Low"), NotificationPriority::Low);
637 queue.push(make_toast("Normal"), NotificationPriority::Normal);
638 queue.push(make_toast("High"), NotificationPriority::High);
639
640 let messages: Vec<_> = queue
642 .queue
643 .iter()
644 .map(|q| q.toast.content.message.as_str())
645 .collect();
646 assert_eq!(messages, vec!["High", "Normal", "Low"]);
647 }
648
649 #[test]
650 fn test_queue_dedup() {
651 let config = QueueConfig::default().dedup_window_ms(1000);
652 let mut queue = NotificationQueue::new(config);
653
654 assert!(queue.push(make_toast("Same message"), NotificationPriority::Normal));
655 assert!(!queue.push(make_toast("Same message"), NotificationPriority::Normal));
656
657 assert_eq!(queue.stats().dedup_count, 1);
658 }
659
660 #[test]
661 fn test_queue_overflow() {
662 let config = QueueConfig::default().max_queued(2);
663 let mut queue = NotificationQueue::new(config);
664
665 assert!(queue.push(make_toast("A"), NotificationPriority::Normal));
666 assert!(queue.push(make_toast("B"), NotificationPriority::Normal));
667 assert!(!queue.push(make_toast("C"), NotificationPriority::Normal));
669
670 assert_eq!(queue.stats().overflow_count, 1);
671 }
672
673 #[test]
674 fn test_queue_overflow_drops_lower_priority() {
675 let config = QueueConfig::default().max_queued(2);
676 let mut queue = NotificationQueue::new(config);
677
678 assert!(queue.push(make_toast("Low1"), NotificationPriority::Low));
679 assert!(queue.push(make_toast("Low2"), NotificationPriority::Low));
680 assert!(queue.push(make_toast("High"), NotificationPriority::High));
682
683 assert_eq!(queue.pending_count(), 2);
684 let messages: Vec<_> = queue
685 .queue
686 .iter()
687 .map(|q| q.toast.content.message.as_str())
688 .collect();
689 assert!(messages.contains(&"High"));
690 }
691
692 #[test]
693 fn test_queue_dismiss() {
694 let mut queue = NotificationQueue::with_defaults();
695
696 queue.push(make_toast("Test"), NotificationPriority::Normal);
697 queue.tick(Duration::from_millis(16));
698
699 let id = queue.visible()[0].id;
700 queue.dismiss(id);
701 queue.tick(Duration::from_millis(16));
702
703 assert_eq!(queue.visible_count(), 0);
704 assert_eq!(queue.stats().user_dismissed, 1);
705 }
706
707 #[test]
708 fn test_queue_dismiss_all() {
709 let mut queue = NotificationQueue::with_defaults();
710
711 queue.push(make_toast("A"), NotificationPriority::Normal);
712 queue.push(make_toast("B"), NotificationPriority::Normal);
713 queue.tick(Duration::from_millis(16));
714
715 queue.dismiss_all();
716 queue.tick(Duration::from_millis(16));
717
718 assert!(queue.is_empty());
719 assert_eq!(queue.stats().user_dismissed, 2);
720 }
721
722 #[test]
723 fn test_queue_calculate_positions_top() {
724 let config = QueueConfig::default().position(ToastPosition::TopRight);
725 let mut queue = NotificationQueue::new(config);
726
727 queue.push(make_toast("A"), NotificationPriority::Normal);
728 queue.push(make_toast("B"), NotificationPriority::Normal);
729 queue.tick(Duration::from_millis(16));
730
731 let positions = queue.calculate_positions(80, 24, 1);
732 assert_eq!(positions.len(), 2);
733
734 assert!(positions[0].2 < positions[1].2);
736 }
737
738 #[test]
739 fn test_queue_calculate_positions_bottom() {
740 let config = QueueConfig::default().position(ToastPosition::BottomRight);
741 let mut queue = NotificationQueue::new(config);
742
743 queue.push(make_toast("A"), NotificationPriority::Normal);
744 queue.push(make_toast("B"), NotificationPriority::Normal);
745 queue.tick(Duration::from_millis(16));
746
747 let positions = queue.calculate_positions(80, 24, 1);
748 assert_eq!(positions.len(), 2);
749
750 assert!(positions[0].2 > positions[1].2);
752 }
753
754 #[test]
755 fn test_queue_notify_helper() {
756 let mut queue = NotificationQueue::with_defaults();
757 assert!(queue.notify(make_toast("Normal")));
758 queue.tick(Duration::from_millis(16));
759 assert_eq!(queue.visible_count(), 1);
760 }
761
762 #[test]
763 fn test_queue_urgent_helper() {
764 let config = QueueConfig::default().max_visible(1);
765 let mut queue = NotificationQueue::new(config);
766
767 queue.notify(make_toast("Normal"));
768 queue.urgent(make_toast("Urgent"));
769 queue.tick(Duration::from_millis(16));
770
771 assert_eq!(queue.visible()[0].content.message, "Urgent");
772 }
773
774 #[test]
775 fn test_queue_stats() {
776 let mut queue = NotificationQueue::with_defaults();
777
778 queue.push(make_toast("A"), NotificationPriority::Normal);
779 queue.push(make_toast("A"), NotificationPriority::Normal); queue.tick(Duration::from_millis(16));
781
782 assert_eq!(queue.stats().total_pushed, 2);
783 assert_eq!(queue.stats().dedup_count, 1);
784 }
785
786 #[test]
787 fn test_queue_config_builder() {
788 let config = QueueConfig::new()
789 .max_visible(5)
790 .max_queued(20)
791 .default_duration(Duration::from_secs(10))
792 .position(ToastPosition::BottomLeft)
793 .stagger_offset(2)
794 .dedup_window_ms(500);
795
796 assert_eq!(config.max_visible, 5);
797 assert_eq!(config.max_queued, 20);
798 assert_eq!(config.default_duration, Duration::from_secs(10));
799 assert_eq!(config.position, ToastPosition::BottomLeft);
800 assert_eq!(config.stagger_offset, 2);
801 assert_eq!(config.dedup_window_ms, 500);
802 }
803
804 #[test]
805 fn test_queue_total_count() {
806 let config = QueueConfig::default().max_visible(1);
807 let mut queue = NotificationQueue::new(config);
808
809 queue.push(make_toast("A"), NotificationPriority::Normal);
810 queue.push(make_toast("B"), NotificationPriority::Normal);
811 queue.tick(Duration::from_millis(16));
812
813 assert_eq!(queue.total_count(), 2);
814 assert_eq!(queue.visible_count(), 1);
815 assert_eq!(queue.pending_count(), 1);
816 }
817
818 #[test]
819 fn queue_config_default_values() {
820 let config = QueueConfig::default();
821 assert_eq!(config.max_visible, 3);
822 assert_eq!(config.max_queued, 10);
823 assert_eq!(config.default_duration, Duration::from_secs(5));
824 assert_eq!(config.position, ToastPosition::TopRight);
825 assert_eq!(config.stagger_offset, 1);
826 assert_eq!(config.dedup_window_ms, 1000);
827 }
828
829 #[test]
830 fn notification_priority_default_is_normal() {
831 assert_eq!(
832 NotificationPriority::default(),
833 NotificationPriority::Normal
834 );
835 }
836
837 #[test]
838 fn notification_priority_ordering() {
839 assert!(NotificationPriority::Low < NotificationPriority::Normal);
840 assert!(NotificationPriority::Normal < NotificationPriority::High);
841 assert!(NotificationPriority::High < NotificationPriority::Urgent);
842 }
843
844 #[test]
845 fn queue_default_trait_delegates_to_with_defaults() {
846 let queue = NotificationQueue::default();
847 assert!(queue.is_empty());
848 assert_eq!(queue.config().max_visible, 3);
849 }
850
851 #[test]
852 fn is_empty_false_when_pending() {
853 let mut queue = NotificationQueue::with_defaults();
854 queue.push(make_toast("X"), NotificationPriority::Normal);
855 assert!(!queue.is_empty());
856 }
857
858 #[test]
859 fn is_empty_false_when_visible() {
860 let mut queue = NotificationQueue::with_defaults();
861 queue.push(make_toast("X"), NotificationPriority::Normal);
862 queue.tick(Duration::from_millis(16));
863 assert!(!queue.is_empty());
864 }
865
866 #[test]
867 fn visible_mut_allows_modification() {
868 let mut queue = NotificationQueue::with_defaults();
869 queue.push(make_toast("Original"), NotificationPriority::Normal);
870 queue.tick(Duration::from_millis(16));
871
872 queue.visible_mut()[0].dismiss();
874 queue.tick(Duration::from_millis(16));
875 assert_eq!(queue.visible_count(), 0);
876 }
877
878 #[test]
879 fn config_accessor_returns_config() {
880 let config = QueueConfig::default().max_visible(7).stagger_offset(3);
881 let queue = NotificationQueue::new(config);
882 assert_eq!(queue.config().max_visible, 7);
883 assert_eq!(queue.config().stagger_offset, 3);
884 }
885
886 #[test]
887 fn dismiss_all_clears_queue_and_visible() {
888 let config = QueueConfig::default().max_visible(1);
889 let mut queue = NotificationQueue::new(config);
890
891 queue.push(make_toast("A"), NotificationPriority::Normal);
892 queue.push(make_toast("B"), NotificationPriority::Normal);
893 queue.tick(Duration::from_millis(16));
894
895 assert_eq!(queue.visible_count(), 1);
897 assert_eq!(queue.pending_count(), 1);
898
899 queue.dismiss_all();
900 assert_eq!(queue.stats().user_dismissed, 2);
902 assert_eq!(queue.pending_count(), 0);
903
904 queue.tick(Duration::from_millis(16));
906 assert!(queue.is_empty());
907 }
908
909 #[test]
910 fn dismiss_does_not_double_count_already_dismissed_visible_toast() {
911 let mut queue = NotificationQueue::with_defaults();
912 queue.push(make_toast("A"), NotificationPriority::Normal);
913 queue.tick(Duration::from_millis(16));
914
915 let id = queue.visible()[0].id;
916 queue.dismiss(id);
917 queue.dismiss(id);
918
919 assert_eq!(queue.stats().user_dismissed, 1);
920 }
921
922 #[test]
923 fn queue_applies_config_default_duration_to_default_toasts() {
924 let config = QueueConfig::default().default_duration(Duration::from_secs(12));
925 let mut queue = NotificationQueue::new(config);
926
927 queue.push(make_ephemeral_toast("A"), NotificationPriority::Normal);
928 queue.tick(Duration::from_millis(16));
929
930 assert_eq!(
931 queue.visible()[0].config.duration,
932 Some(Duration::from_secs(12))
933 );
934 }
935
936 #[test]
937 fn queue_preserves_persistent_toasts_when_applying_default_duration() {
938 let config = QueueConfig::default().default_duration(Duration::from_secs(12));
939 let mut queue = NotificationQueue::new(config);
940
941 queue.push(make_toast("A"), NotificationPriority::Normal);
942 queue.tick(Duration::from_millis(16));
943
944 assert_eq!(queue.visible()[0].config.duration, None);
945 }
946
947 #[test]
948 fn queue_preserves_explicit_custom_duration() {
949 let config = QueueConfig::default().default_duration(Duration::from_secs(12));
950 let mut queue = NotificationQueue::new(config);
951
952 queue.push(
953 Toast::new("A")
954 .duration(Duration::from_secs(2))
955 .no_animation(),
956 NotificationPriority::Normal,
957 );
958 queue.tick(Duration::from_millis(16));
959
960 assert_eq!(
961 queue.visible()[0].config.duration,
962 Some(Duration::from_secs(2))
963 );
964 }
965
966 #[test]
967 fn queue_preserves_explicit_duration_even_when_equal_to_toast_default() {
968 let config = QueueConfig::default().default_duration(Duration::from_secs(12));
969 let mut queue = NotificationQueue::new(config);
970
971 queue.push(
972 Toast::new("A")
973 .duration(Duration::from_secs(5))
974 .no_animation(),
975 NotificationPriority::Normal,
976 );
977 queue.tick(Duration::from_millis(16));
978
979 assert_eq!(
980 queue.visible()[0].config.duration,
981 Some(Duration::from_secs(5))
982 );
983 }
984
985 #[test]
986 fn queue_action_equality() {
987 let id = ToastId::new(42);
988 assert_eq!(QueueAction::Show(id), QueueAction::Show(id));
989 assert_eq!(QueueAction::Hide(id), QueueAction::Hide(id));
990 assert_eq!(QueueAction::Reposition(id), QueueAction::Reposition(id));
991 assert_ne!(QueueAction::Show(id), QueueAction::Hide(id));
992 }
993
994 #[test]
995 fn queue_stats_default_all_zero() {
996 let stats = QueueStats::default();
997 assert_eq!(stats.total_pushed, 0);
998 assert_eq!(stats.overflow_count, 0);
999 assert_eq!(stats.dedup_count, 0);
1000 assert_eq!(stats.user_dismissed, 0);
1001 assert_eq!(stats.auto_expired, 0);
1002 }
1003
1004 #[test]
1005 fn calculate_positions_empty_returns_empty() {
1006 let queue = NotificationQueue::with_defaults();
1007 let positions = queue.calculate_positions(80, 24, 1);
1008 assert!(positions.is_empty());
1009 }
1010
1011 #[test]
1012 fn notification_stack_empty_area_renders_nothing() {
1013 let mut queue = NotificationQueue::with_defaults();
1014 queue.push(make_toast("Hello"), NotificationPriority::Normal);
1015 queue.tick(Duration::from_millis(16));
1016
1017 let mut pool = GraphemePool::new();
1018 let mut frame = Frame::new(40, 10, &mut pool);
1019 let empty_area = Rect::new(0, 0, 0, 0);
1020
1021 NotificationStack::new(&queue).render(empty_area, &mut frame);
1023 }
1024
1025 #[test]
1026 fn notification_stack_margin_builder() {
1027 let queue = NotificationQueue::with_defaults();
1028 let stack = NotificationStack::new(&queue).margin(5);
1029 assert_eq!(stack.margin, 5);
1030 }
1031
1032 #[test]
1033 fn notification_stack_renders_visible_toast() {
1034 let mut queue = NotificationQueue::with_defaults();
1035 queue.push(make_toast("Hello"), NotificationPriority::Normal);
1036 queue.tick(Duration::from_millis(16));
1037
1038 let mut pool = GraphemePool::new();
1039 let mut frame = Frame::new(40, 10, &mut pool);
1040 let area = Rect::new(0, 0, 40, 10);
1041
1042 NotificationStack::new(&queue)
1043 .margin(0)
1044 .render(area, &mut frame);
1045
1046 let (_, x, y) = queue.calculate_positions(40, 10, 0)[0];
1047 let cell = frame.buffer.get(x, y).expect("cell should exist");
1048 assert!(!cell.is_empty(), "stack should render toast content");
1049 }
1050}