Skip to main content

ftui_widgets/
notification_queue.rs

1#![forbid(unsafe_code)]
2
3//! Notification queue manager for handling multiple concurrent toast notifications.
4//!
5//! The queue system provides:
6//! - FIFO ordering with priority support (Urgent notifications jump ahead)
7//! - Maximum visible limit with automatic stacking
8//! - Content-based deduplication within a configurable time window
9//! - Automatic expiry processing via tick-based updates
10//!
11//! # Example
12//!
13//! ```ignore
14//! let mut queue = NotificationQueue::new(QueueConfig::default());
15//!
16//! // Push notifications
17//! queue.push(Toast::new("File saved").icon(ToastIcon::Success), NotificationPriority::Normal);
18//! queue.push(Toast::new("Error!").icon(ToastIcon::Error), NotificationPriority::Urgent);
19//!
20//! // Process in your event loop
21//! let actions = queue.tick(Duration::from_millis(16));
22//! for action in actions {
23//!     match action {
24//!         QueueAction::Show(toast) => { /* render toast */ }
25//!         QueueAction::Hide(id) => { /* remove toast */ }
26//!     }
27//! }
28//! ```
29
30use 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/// Priority level for notifications.
42///
43/// Higher priority notifications are displayed sooner.
44/// `Urgent` notifications jump to the front of the queue.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
46pub enum NotificationPriority {
47    /// Low priority, displayed last.
48    Low = 0,
49    /// Normal priority (default).
50    #[default]
51    Normal = 1,
52    /// High priority, displayed before Normal/Low.
53    High = 2,
54    /// Urgent priority, jumps to front immediately.
55    Urgent = 3,
56}
57
58/// Configuration for the notification queue.
59#[derive(Debug, Clone)]
60pub struct QueueConfig {
61    /// Maximum number of toasts visible at once.
62    pub max_visible: usize,
63    /// Maximum number of notifications waiting in queue.
64    pub max_queued: usize,
65    /// Default auto-dismiss duration.
66    pub default_duration: Duration,
67    /// Anchor position for the toast stack.
68    pub position: ToastPosition,
69    /// Vertical spacing between stacked toasts.
70    pub stagger_offset: u16,
71    /// Time window for deduplication (in ms).
72    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    /// Create a new configuration with default values.
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Set maximum visible toasts.
95    #[must_use]
96    pub fn max_visible(mut self, max: usize) -> Self {
97        self.max_visible = max;
98        self
99    }
100
101    /// Set maximum queued notifications.
102    #[must_use]
103    pub fn max_queued(mut self, max: usize) -> Self {
104        self.max_queued = max;
105        self
106    }
107
108    /// Set default duration for auto-dismiss.
109    #[must_use]
110    pub fn default_duration(mut self, duration: Duration) -> Self {
111        self.default_duration = duration;
112        self
113    }
114
115    /// Set anchor position for the toast stack.
116    #[must_use]
117    pub fn position(mut self, position: ToastPosition) -> Self {
118        self.position = position;
119        self
120    }
121
122    /// Set vertical spacing between stacked toasts.
123    #[must_use]
124    pub fn stagger_offset(mut self, offset: u16) -> Self {
125        self.stagger_offset = offset;
126        self
127    }
128
129    /// Set deduplication time window in milliseconds.
130    #[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/// Internal representation of a queued notification.
138#[derive(Debug)]
139struct QueuedNotification {
140    toast: Toast,
141    priority: NotificationPriority,
142    /// When the notification was queued (for potential time-based priority decay).
143    #[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/// Actions returned by `tick()` to be processed by the application.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum QueueAction {
173    /// Show a new toast at the given position.
174    Show(ToastId),
175    /// Hide an existing toast.
176    Hide(ToastId),
177    /// Reposition a toast (for stacking adjustments).
178    Reposition(ToastId),
179}
180
181/// Queue statistics for monitoring and debugging.
182#[derive(Debug, Clone, Default)]
183pub struct QueueStats {
184    /// Total notifications pushed.
185    pub total_pushed: u64,
186    /// Notifications rejected due to queue overflow.
187    pub overflow_count: u64,
188    /// Notifications rejected due to deduplication.
189    pub dedup_count: u64,
190    /// Notifications dismissed by user.
191    pub user_dismissed: u64,
192    /// Notifications expired automatically.
193    pub auto_expired: u64,
194}
195
196/// Notification queue manager.
197///
198/// Manages multiple toast notifications with priority ordering, deduplication,
199/// and automatic expiry. Use `push` to add notifications and `tick` to process
200/// expiry in your event loop.
201#[derive(Debug)]
202pub struct NotificationQueue {
203    /// Pending notifications waiting to be displayed.
204    queue: VecDeque<QueuedNotification>,
205    /// Currently visible toasts.
206    visible: Vec<Toast>,
207    /// Configuration.
208    config: QueueConfig,
209    /// Deduplication window.
210    dedup_window: Duration,
211    /// Recent content hashes for deduplication.
212    recent_hashes: AHashMap<u64, Instant>,
213    /// Statistics.
214    stats: QueueStats,
215}
216
217/// Widget that renders the visible toasts in a queue.
218///
219/// This is a thin renderer over `NotificationQueue`, keeping stacking logic
220/// centralized in the queue while ensuring the draw path stays deterministic.
221pub struct NotificationStack<'a> {
222    queue: &'a NotificationQueue,
223    margin: u16,
224}
225
226impl<'a> NotificationStack<'a> {
227    /// Create a new notification stack renderer.
228    pub fn new(queue: &'a NotificationQueue) -> Self {
229        Self { queue, margin: 1 }
230    }
231
232    /// Set the margin from the screen edge.
233    #[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    /// Create a new notification queue with the given configuration.
265    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    /// Create a new queue with default configuration.
278    pub fn with_defaults() -> Self {
279        Self::new(QueueConfig::default())
280    }
281
282    /// Push a notification to the queue.
283    ///
284    /// Returns `true` if the notification was accepted, `false` if it was
285    /// rejected due to deduplication or queue overflow.
286    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        // Check deduplication
291        if !self.dedup_check(queued.content_hash) {
292            self.stats.dedup_count += 1;
293            return false;
294        }
295
296        // Check queue overflow
297        if self.queue.len() >= self.config.max_queued {
298            self.stats.overflow_count += 1;
299            // Drop oldest low-priority item if possible
300            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; // New item is lower or equal priority
305                }
306            } else {
307                return false;
308            }
309        }
310
311        // Insert based on priority
312        if priority == NotificationPriority::Urgent {
313            // Urgent jumps to front
314            self.queue.push_front(queued);
315        } else {
316            // Insert in priority order
317            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    /// Push a notification with normal priority.
329    pub fn notify(&mut self, toast: Toast) -> bool {
330        self.push(toast, NotificationPriority::Normal)
331    }
332
333    /// Push an urgent notification.
334    pub fn urgent(&mut self, toast: Toast) -> bool {
335        self.push(toast, NotificationPriority::Urgent)
336    }
337
338    /// Dismiss a specific notification by ID.
339    pub fn dismiss(&mut self, id: ToastId) {
340        // Check visible toasts
341        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        // Check queue
349        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    /// Dismiss all notifications.
356    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    /// Process a time tick, handling expiry and promotion.
369    ///
370    /// Call this regularly in your event loop (e.g., every frame or every 16ms).
371    /// Returns a list of actions to perform.
372    pub fn tick(&mut self, _delta: Duration) -> Vec<QueueAction> {
373        let mut actions = Vec::new();
374
375        // Clean expired dedup hashes
376        let now = Instant::now();
377        self.recent_hashes
378            .retain(|_, t| now.saturating_duration_since(*t) < self.dedup_window);
379
380        // Process visible toasts for expiry and animations
381        let mut i = 0;
382        while i < self.visible.len() {
383            let toast = &mut self.visible[i];
384
385            // Trigger auto-dismiss on expiry
386            if !toast.state.dismissed && toast.is_expired() {
387                toast.dismiss();
388                self.stats.auto_expired += 1;
389            }
390
391            // Advance animation state
392            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        // Promote from queue to visible
404        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    /// Get currently visible toasts.
418    pub fn visible(&self) -> &[Toast] {
419        &self.visible
420    }
421
422    /// Get mutable access to visible toasts.
423    pub fn visible_mut(&mut self) -> &mut [Toast] {
424        &mut self.visible
425    }
426
427    /// Get the number of notifications waiting in the queue.
428    pub fn pending_count(&self) -> usize {
429        self.queue.len()
430    }
431
432    /// Get the number of visible toasts.
433    pub fn visible_count(&self) -> usize {
434        self.visible.len()
435    }
436
437    /// Get the total count (visible + pending).
438    pub fn total_count(&self) -> usize {
439        self.visible.len() + self.queue.len()
440    }
441
442    /// Check if the queue is empty (no visible or pending notifications).
443    #[inline]
444    pub fn is_empty(&self) -> bool {
445        self.visible.is_empty() && self.queue.is_empty()
446    }
447
448    /// Get queue statistics.
449    pub fn stats(&self) -> &QueueStats {
450        &self.stats
451    }
452
453    /// Get the configuration.
454    pub fn config(&self) -> &QueueConfig {
455        &self.config
456    }
457
458    /// Calculate stacking positions for all visible toasts.
459    ///
460    /// Returns a list of (ToastId, x, y) positions.
461    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    // --- Internal methods ---
501
502    /// Check if a content hash is a duplicate within the dedup window.
503    fn dedup_check(&mut self, hash: u64) -> bool {
504        let now = Instant::now();
505
506        // Clean old hashes
507        self.recent_hashes
508            .retain(|_, t| now.saturating_duration_since(*t) < self.dedup_window);
509
510        // Check if duplicate
511        if self.recent_hashes.contains_key(&hash) {
512            return false;
513        }
514
515        self.recent_hashes.insert(hash, now);
516        true
517    }
518
519    /// Find the index of the lowest priority item in the queue.
520    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() // Use persistent and no_animation for testing
554    }
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        // Tick promotes from queue to visible
577        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        // Dismiss first, tick to get second
597        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        // Urgent should jump to front
628        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); // No auto-promote
634        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        // Queue should be ordered High, Normal, Low
641        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        // Third should fail (queue full)
668        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        // High priority should drop a low priority item
681        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        // First toast should be at top, second below
735        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        // First toast should be at bottom, second above
751        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); // Dedup
780        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        // Dismiss via visible_mut
873        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        // After tick: A is visible, B is pending.
896        assert_eq!(queue.visible_count(), 1);
897        assert_eq!(queue.pending_count(), 1);
898
899        queue.dismiss_all();
900        // dismiss_all counts both the visible and pending toast.
901        assert_eq!(queue.stats().user_dismissed, 2);
902        assert_eq!(queue.pending_count(), 0);
903
904        // Next tick removes the dismissed visible toast
905        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        // Should not panic
1022        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}