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
289        let queued = QueuedNotification::new(toast, priority);
290
291        // Check deduplication
292        if !self.dedup_check(queued.content_hash) {
293            self.stats.dedup_count += 1;
294            return false;
295        }
296
297        // Check queue overflow
298        if self.queue.len() >= self.config.max_queued {
299            self.stats.overflow_count += 1;
300            // Drop oldest low-priority item if possible
301            if let Some(idx) = self.find_lowest_priority_index() {
302                if self.queue[idx].priority < priority {
303                    self.queue.remove(idx);
304                } else {
305                    return false; // New item is lower or equal priority
306                }
307            } else {
308                return false;
309            }
310        }
311
312        // Insert based on priority
313        if priority == NotificationPriority::Urgent {
314            // Urgent jumps to front
315            self.queue.push_front(queued);
316        } else {
317            // Insert in priority order
318            let insert_idx = self
319                .queue
320                .iter()
321                .position(|q| q.priority < priority)
322                .unwrap_or(self.queue.len());
323            self.queue.insert(insert_idx, queued);
324        }
325
326        true
327    }
328
329    /// Push a notification with normal priority.
330    pub fn notify(&mut self, toast: Toast) -> bool {
331        self.push(toast, NotificationPriority::Normal)
332    }
333
334    /// Push an urgent notification.
335    pub fn urgent(&mut self, toast: Toast) -> bool {
336        self.push(toast, NotificationPriority::Urgent)
337    }
338
339    /// Dismiss a specific notification by ID.
340    pub fn dismiss(&mut self, id: ToastId) {
341        // Check visible toasts
342        if let Some(idx) = self.visible.iter().position(|t| t.id == id) {
343            self.visible[idx].dismiss();
344            self.stats.user_dismissed += 1;
345        }
346
347        // Check queue
348        if let Some(idx) = self.queue.iter().position(|q| q.toast.id == id) {
349            self.queue.remove(idx);
350            self.stats.user_dismissed += 1;
351        }
352    }
353
354    /// Dismiss all notifications.
355    pub fn dismiss_all(&mut self) {
356        for toast in &mut self.visible {
357            toast.dismiss();
358        }
359        self.stats.user_dismissed += self.queue.len() as u64;
360        self.queue.clear();
361    }
362
363    /// Process a time tick, handling expiry and promotion.
364    ///
365    /// Call this regularly in your event loop (e.g., every frame or every 16ms).
366    /// Returns a list of actions to perform.
367    pub fn tick(&mut self, _delta: Duration) -> Vec<QueueAction> {
368        let mut actions = Vec::new();
369
370        // Clean expired dedup hashes
371        let now = Instant::now();
372        self.recent_hashes
373            .retain(|_, t| now.duration_since(*t) < self.dedup_window);
374
375        // Process visible toasts for expiry
376        let mut i = 0;
377        while i < self.visible.len() {
378            if !self.visible[i].is_visible() {
379                let id = self.visible[i].id;
380                self.visible.remove(i);
381                self.stats.auto_expired += 1;
382                actions.push(QueueAction::Hide(id));
383            } else {
384                i += 1;
385            }
386        }
387
388        // Promote from queue to visible
389        while self.visible.len() < self.config.max_visible {
390            if let Some(queued) = self.queue.pop_front() {
391                let id = queued.toast.id;
392                self.visible.push(queued.toast);
393                actions.push(QueueAction::Show(id));
394            } else {
395                break;
396            }
397        }
398
399        actions
400    }
401
402    /// Get currently visible toasts.
403    pub fn visible(&self) -> &[Toast] {
404        &self.visible
405    }
406
407    /// Get mutable access to visible toasts.
408    pub fn visible_mut(&mut self) -> &mut [Toast] {
409        &mut self.visible
410    }
411
412    /// Get the number of notifications waiting in the queue.
413    pub fn pending_count(&self) -> usize {
414        self.queue.len()
415    }
416
417    /// Get the number of visible toasts.
418    pub fn visible_count(&self) -> usize {
419        self.visible.len()
420    }
421
422    /// Get the total count (visible + pending).
423    pub fn total_count(&self) -> usize {
424        self.visible.len() + self.queue.len()
425    }
426
427    /// Check if the queue is empty (no visible or pending notifications).
428    #[inline]
429    pub fn is_empty(&self) -> bool {
430        self.visible.is_empty() && self.queue.is_empty()
431    }
432
433    /// Get queue statistics.
434    pub fn stats(&self) -> &QueueStats {
435        &self.stats
436    }
437
438    /// Get the configuration.
439    pub fn config(&self) -> &QueueConfig {
440        &self.config
441    }
442
443    /// Calculate stacking positions for all visible toasts.
444    ///
445    /// Returns a list of (ToastId, x, y) positions.
446    pub fn calculate_positions(
447        &self,
448        terminal_width: u16,
449        terminal_height: u16,
450        margin: u16,
451    ) -> Vec<(ToastId, u16, u16)> {
452        let mut positions = Vec::with_capacity(self.visible.len());
453        let is_top = matches!(
454            self.config.position,
455            ToastPosition::TopLeft | ToastPosition::TopCenter | ToastPosition::TopRight
456        );
457
458        let mut y_offset: u16 = 0;
459
460        for toast in &self.visible {
461            let (toast_width, toast_height) = toast.calculate_dimensions();
462            let (base_x, base_y) = self.config.position.calculate_position(
463                terminal_width,
464                terminal_height,
465                toast_width,
466                toast_height,
467                margin,
468            );
469
470            let y = if is_top {
471                base_y.saturating_add(y_offset)
472            } else {
473                base_y.saturating_sub(y_offset)
474            };
475
476            positions.push((toast.id, base_x, y));
477            y_offset = y_offset
478                .saturating_add(toast_height)
479                .saturating_add(self.config.stagger_offset);
480        }
481
482        positions
483    }
484
485    // --- Internal methods ---
486
487    /// Check if a content hash is a duplicate within the dedup window.
488    fn dedup_check(&mut self, hash: u64) -> bool {
489        let now = Instant::now();
490
491        // Clean old hashes
492        self.recent_hashes
493            .retain(|_, t| now.duration_since(*t) < self.dedup_window);
494
495        // Check if duplicate
496        if self.recent_hashes.contains_key(&hash) {
497            return false;
498        }
499
500        self.recent_hashes.insert(hash, now);
501        true
502    }
503
504    /// Find the index of the lowest priority item in the queue.
505    fn find_lowest_priority_index(&self) -> Option<usize> {
506        self.queue
507            .iter()
508            .enumerate()
509            .min_by_key(|(_, q)| q.priority)
510            .map(|(i, _)| i)
511    }
512}
513
514impl Default for NotificationQueue {
515    fn default() -> Self {
516        Self::with_defaults()
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use ftui_render::frame::Frame;
524    use ftui_render::grapheme_pool::GraphemePool;
525
526    fn make_toast(msg: &str) -> Toast {
527        Toast::with_id(ToastId::new(0), msg).persistent() // Use persistent for testing
528    }
529
530    #[test]
531    fn test_queue_new() {
532        let queue = NotificationQueue::with_defaults();
533        assert!(queue.is_empty());
534        assert_eq!(queue.visible_count(), 0);
535        assert_eq!(queue.pending_count(), 0);
536    }
537
538    #[test]
539    fn test_queue_push_and_tick() {
540        let mut queue = NotificationQueue::with_defaults();
541
542        queue.push(make_toast("Hello"), NotificationPriority::Normal);
543        assert_eq!(queue.pending_count(), 1);
544        assert_eq!(queue.visible_count(), 0);
545
546        // Tick promotes from queue to visible
547        let actions = queue.tick(Duration::from_millis(16));
548        assert_eq!(queue.pending_count(), 0);
549        assert_eq!(queue.visible_count(), 1);
550        assert_eq!(actions.len(), 1);
551        assert!(matches!(actions[0], QueueAction::Show(_)));
552    }
553
554    #[test]
555    fn test_queue_fifo() {
556        let config = QueueConfig::default().max_visible(1);
557        let mut queue = NotificationQueue::new(config);
558
559        queue.push(make_toast("First"), NotificationPriority::Normal);
560        queue.push(make_toast("Second"), NotificationPriority::Normal);
561        queue.push(make_toast("Third"), NotificationPriority::Normal);
562
563        queue.tick(Duration::from_millis(16));
564        assert_eq!(queue.visible()[0].content.message, "First");
565
566        // Dismiss first, tick to get second
567        queue.visible_mut()[0].dismiss();
568        queue.tick(Duration::from_millis(16));
569        assert_eq!(queue.visible()[0].content.message, "Second");
570    }
571
572    #[test]
573    fn test_queue_max_visible() {
574        let config = QueueConfig::default().max_visible(2);
575        let mut queue = NotificationQueue::new(config);
576
577        queue.push(make_toast("A"), NotificationPriority::Normal);
578        queue.push(make_toast("B"), NotificationPriority::Normal);
579        queue.push(make_toast("C"), NotificationPriority::Normal);
580
581        queue.tick(Duration::from_millis(16));
582
583        assert_eq!(queue.visible_count(), 2);
584        assert_eq!(queue.pending_count(), 1);
585    }
586
587    #[test]
588    fn test_queue_priority_urgent() {
589        let config = QueueConfig::default().max_visible(1);
590        let mut queue = NotificationQueue::new(config);
591
592        queue.push(make_toast("Normal1"), NotificationPriority::Normal);
593        queue.push(make_toast("Normal2"), NotificationPriority::Normal);
594        queue.push(make_toast("Urgent"), NotificationPriority::Urgent);
595
596        queue.tick(Duration::from_millis(16));
597        // Urgent should jump to front
598        assert_eq!(queue.visible()[0].content.message, "Urgent");
599    }
600
601    #[test]
602    fn test_queue_priority_ordering() {
603        let config = QueueConfig::default().max_visible(0); // No auto-promote
604        let mut queue = NotificationQueue::new(config);
605
606        queue.push(make_toast("Low"), NotificationPriority::Low);
607        queue.push(make_toast("Normal"), NotificationPriority::Normal);
608        queue.push(make_toast("High"), NotificationPriority::High);
609
610        // Queue should be ordered High, Normal, Low
611        let messages: Vec<_> = queue
612            .queue
613            .iter()
614            .map(|q| q.toast.content.message.as_str())
615            .collect();
616        assert_eq!(messages, vec!["High", "Normal", "Low"]);
617    }
618
619    #[test]
620    fn test_queue_dedup() {
621        let config = QueueConfig::default().dedup_window_ms(1000);
622        let mut queue = NotificationQueue::new(config);
623
624        assert!(queue.push(make_toast("Same message"), NotificationPriority::Normal));
625        assert!(!queue.push(make_toast("Same message"), NotificationPriority::Normal));
626
627        assert_eq!(queue.stats().dedup_count, 1);
628    }
629
630    #[test]
631    fn test_queue_overflow() {
632        let config = QueueConfig::default().max_queued(2);
633        let mut queue = NotificationQueue::new(config);
634
635        assert!(queue.push(make_toast("A"), NotificationPriority::Normal));
636        assert!(queue.push(make_toast("B"), NotificationPriority::Normal));
637        // Third should fail (queue full)
638        assert!(!queue.push(make_toast("C"), NotificationPriority::Normal));
639
640        assert_eq!(queue.stats().overflow_count, 1);
641    }
642
643    #[test]
644    fn test_queue_overflow_drops_lower_priority() {
645        let config = QueueConfig::default().max_queued(2);
646        let mut queue = NotificationQueue::new(config);
647
648        assert!(queue.push(make_toast("Low1"), NotificationPriority::Low));
649        assert!(queue.push(make_toast("Low2"), NotificationPriority::Low));
650        // High priority should drop a low priority item
651        assert!(queue.push(make_toast("High"), NotificationPriority::High));
652
653        assert_eq!(queue.pending_count(), 2);
654        let messages: Vec<_> = queue
655            .queue
656            .iter()
657            .map(|q| q.toast.content.message.as_str())
658            .collect();
659        assert!(messages.contains(&"High"));
660    }
661
662    #[test]
663    fn test_queue_dismiss() {
664        let mut queue = NotificationQueue::with_defaults();
665
666        queue.push(make_toast("Test"), NotificationPriority::Normal);
667        queue.tick(Duration::from_millis(16));
668
669        let id = queue.visible()[0].id;
670        queue.dismiss(id);
671        queue.tick(Duration::from_millis(16));
672
673        assert_eq!(queue.visible_count(), 0);
674        assert_eq!(queue.stats().user_dismissed, 1);
675    }
676
677    #[test]
678    fn test_queue_dismiss_all() {
679        let mut queue = NotificationQueue::with_defaults();
680
681        queue.push(make_toast("A"), NotificationPriority::Normal);
682        queue.push(make_toast("B"), NotificationPriority::Normal);
683        queue.tick(Duration::from_millis(16));
684
685        queue.dismiss_all();
686        queue.tick(Duration::from_millis(16));
687
688        assert!(queue.is_empty());
689    }
690
691    #[test]
692    fn test_queue_calculate_positions_top() {
693        let config = QueueConfig::default().position(ToastPosition::TopRight);
694        let mut queue = NotificationQueue::new(config);
695
696        queue.push(make_toast("A"), NotificationPriority::Normal);
697        queue.push(make_toast("B"), NotificationPriority::Normal);
698        queue.tick(Duration::from_millis(16));
699
700        let positions = queue.calculate_positions(80, 24, 1);
701        assert_eq!(positions.len(), 2);
702
703        // First toast should be at top, second below
704        assert!(positions[0].2 < positions[1].2);
705    }
706
707    #[test]
708    fn test_queue_calculate_positions_bottom() {
709        let config = QueueConfig::default().position(ToastPosition::BottomRight);
710        let mut queue = NotificationQueue::new(config);
711
712        queue.push(make_toast("A"), NotificationPriority::Normal);
713        queue.push(make_toast("B"), NotificationPriority::Normal);
714        queue.tick(Duration::from_millis(16));
715
716        let positions = queue.calculate_positions(80, 24, 1);
717        assert_eq!(positions.len(), 2);
718
719        // First toast should be at bottom, second above
720        assert!(positions[0].2 > positions[1].2);
721    }
722
723    #[test]
724    fn test_queue_notify_helper() {
725        let mut queue = NotificationQueue::with_defaults();
726        assert!(queue.notify(make_toast("Normal")));
727        queue.tick(Duration::from_millis(16));
728        assert_eq!(queue.visible_count(), 1);
729    }
730
731    #[test]
732    fn test_queue_urgent_helper() {
733        let config = QueueConfig::default().max_visible(1);
734        let mut queue = NotificationQueue::new(config);
735
736        queue.notify(make_toast("Normal"));
737        queue.urgent(make_toast("Urgent"));
738        queue.tick(Duration::from_millis(16));
739
740        assert_eq!(queue.visible()[0].content.message, "Urgent");
741    }
742
743    #[test]
744    fn test_queue_stats() {
745        let mut queue = NotificationQueue::with_defaults();
746
747        queue.push(make_toast("A"), NotificationPriority::Normal);
748        queue.push(make_toast("A"), NotificationPriority::Normal); // Dedup
749        queue.tick(Duration::from_millis(16));
750
751        assert_eq!(queue.stats().total_pushed, 2);
752        assert_eq!(queue.stats().dedup_count, 1);
753    }
754
755    #[test]
756    fn test_queue_config_builder() {
757        let config = QueueConfig::new()
758            .max_visible(5)
759            .max_queued(20)
760            .default_duration(Duration::from_secs(10))
761            .position(ToastPosition::BottomLeft)
762            .stagger_offset(2)
763            .dedup_window_ms(500);
764
765        assert_eq!(config.max_visible, 5);
766        assert_eq!(config.max_queued, 20);
767        assert_eq!(config.default_duration, Duration::from_secs(10));
768        assert_eq!(config.position, ToastPosition::BottomLeft);
769        assert_eq!(config.stagger_offset, 2);
770        assert_eq!(config.dedup_window_ms, 500);
771    }
772
773    #[test]
774    fn test_queue_total_count() {
775        let config = QueueConfig::default().max_visible(1);
776        let mut queue = NotificationQueue::new(config);
777
778        queue.push(make_toast("A"), NotificationPriority::Normal);
779        queue.push(make_toast("B"), NotificationPriority::Normal);
780        queue.tick(Duration::from_millis(16));
781
782        assert_eq!(queue.total_count(), 2);
783        assert_eq!(queue.visible_count(), 1);
784        assert_eq!(queue.pending_count(), 1);
785    }
786
787    #[test]
788    fn queue_config_default_values() {
789        let config = QueueConfig::default();
790        assert_eq!(config.max_visible, 3);
791        assert_eq!(config.max_queued, 10);
792        assert_eq!(config.default_duration, Duration::from_secs(5));
793        assert_eq!(config.position, ToastPosition::TopRight);
794        assert_eq!(config.stagger_offset, 1);
795        assert_eq!(config.dedup_window_ms, 1000);
796    }
797
798    #[test]
799    fn notification_priority_default_is_normal() {
800        assert_eq!(
801            NotificationPriority::default(),
802            NotificationPriority::Normal
803        );
804    }
805
806    #[test]
807    fn notification_priority_ordering() {
808        assert!(NotificationPriority::Low < NotificationPriority::Normal);
809        assert!(NotificationPriority::Normal < NotificationPriority::High);
810        assert!(NotificationPriority::High < NotificationPriority::Urgent);
811    }
812
813    #[test]
814    fn queue_default_trait_delegates_to_with_defaults() {
815        let queue = NotificationQueue::default();
816        assert!(queue.is_empty());
817        assert_eq!(queue.config().max_visible, 3);
818    }
819
820    #[test]
821    fn is_empty_false_when_pending() {
822        let mut queue = NotificationQueue::with_defaults();
823        queue.push(make_toast("X"), NotificationPriority::Normal);
824        assert!(!queue.is_empty());
825    }
826
827    #[test]
828    fn is_empty_false_when_visible() {
829        let mut queue = NotificationQueue::with_defaults();
830        queue.push(make_toast("X"), NotificationPriority::Normal);
831        queue.tick(Duration::from_millis(16));
832        assert!(!queue.is_empty());
833    }
834
835    #[test]
836    fn visible_mut_allows_modification() {
837        let mut queue = NotificationQueue::with_defaults();
838        queue.push(make_toast("Original"), NotificationPriority::Normal);
839        queue.tick(Duration::from_millis(16));
840
841        // Dismiss via visible_mut
842        queue.visible_mut()[0].dismiss();
843        queue.tick(Duration::from_millis(16));
844        assert_eq!(queue.visible_count(), 0);
845    }
846
847    #[test]
848    fn config_accessor_returns_config() {
849        let config = QueueConfig::default().max_visible(7).stagger_offset(3);
850        let queue = NotificationQueue::new(config);
851        assert_eq!(queue.config().max_visible, 7);
852        assert_eq!(queue.config().stagger_offset, 3);
853    }
854
855    #[test]
856    fn dismiss_all_clears_queue_and_visible() {
857        let config = QueueConfig::default().max_visible(1);
858        let mut queue = NotificationQueue::new(config);
859
860        queue.push(make_toast("A"), NotificationPriority::Normal);
861        queue.push(make_toast("B"), NotificationPriority::Normal);
862        queue.tick(Duration::from_millis(16));
863
864        // After tick: A is visible, B is pending.
865        assert_eq!(queue.visible_count(), 1);
866        assert_eq!(queue.pending_count(), 1);
867
868        queue.dismiss_all();
869        // dismiss_all: marks visible toasts dismissed, clears queue,
870        // increments user_dismissed by queue.len() (1 for B)
871        assert_eq!(queue.stats().user_dismissed, 1);
872        assert_eq!(queue.pending_count(), 0);
873
874        // Next tick removes the dismissed visible toast
875        queue.tick(Duration::from_millis(16));
876        assert!(queue.is_empty());
877    }
878
879    #[test]
880    fn queue_action_equality() {
881        let id = ToastId::new(42);
882        assert_eq!(QueueAction::Show(id), QueueAction::Show(id));
883        assert_eq!(QueueAction::Hide(id), QueueAction::Hide(id));
884        assert_eq!(QueueAction::Reposition(id), QueueAction::Reposition(id));
885        assert_ne!(QueueAction::Show(id), QueueAction::Hide(id));
886    }
887
888    #[test]
889    fn queue_stats_default_all_zero() {
890        let stats = QueueStats::default();
891        assert_eq!(stats.total_pushed, 0);
892        assert_eq!(stats.overflow_count, 0);
893        assert_eq!(stats.dedup_count, 0);
894        assert_eq!(stats.user_dismissed, 0);
895        assert_eq!(stats.auto_expired, 0);
896    }
897
898    #[test]
899    fn calculate_positions_empty_returns_empty() {
900        let queue = NotificationQueue::with_defaults();
901        let positions = queue.calculate_positions(80, 24, 1);
902        assert!(positions.is_empty());
903    }
904
905    #[test]
906    fn notification_stack_empty_area_renders_nothing() {
907        let mut queue = NotificationQueue::with_defaults();
908        queue.push(make_toast("Hello"), NotificationPriority::Normal);
909        queue.tick(Duration::from_millis(16));
910
911        let mut pool = GraphemePool::new();
912        let mut frame = Frame::new(40, 10, &mut pool);
913        let empty_area = Rect::new(0, 0, 0, 0);
914
915        // Should not panic
916        NotificationStack::new(&queue).render(empty_area, &mut frame);
917    }
918
919    #[test]
920    fn notification_stack_margin_builder() {
921        let queue = NotificationQueue::with_defaults();
922        let stack = NotificationStack::new(&queue).margin(5);
923        assert_eq!(stack.margin, 5);
924    }
925
926    #[test]
927    fn notification_stack_renders_visible_toast() {
928        let mut queue = NotificationQueue::with_defaults();
929        queue.push(make_toast("Hello"), NotificationPriority::Normal);
930        queue.tick(Duration::from_millis(16));
931
932        let mut pool = GraphemePool::new();
933        let mut frame = Frame::new(40, 10, &mut pool);
934        let area = Rect::new(0, 0, 40, 10);
935
936        NotificationStack::new(&queue)
937            .margin(0)
938            .render(area, &mut frame);
939
940        let (_, x, y) = queue.calculate_positions(40, 10, 0)[0];
941        let cell = frame.buffer.get(x, y).expect("cell should exist");
942        assert!(!cell.is_empty(), "stack should render toast content");
943    }
944}