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 std::collections::{HashMap, VecDeque};
31use std::hash::{Hash, Hasher};
32use std::time::{Duration, Instant};
33
34use ftui_core::geometry::Rect;
35use ftui_render::frame::Frame;
36
37use crate::Widget;
38use crate::toast::{Toast, ToastId, ToastPosition};
39
40/// Priority level for notifications.
41///
42/// Higher priority notifications are displayed sooner.
43/// `Urgent` notifications jump to the front of the queue.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
45pub enum NotificationPriority {
46    /// Low priority, displayed last.
47    Low = 0,
48    /// Normal priority (default).
49    #[default]
50    Normal = 1,
51    /// High priority, displayed before Normal/Low.
52    High = 2,
53    /// Urgent priority, jumps to front immediately.
54    Urgent = 3,
55}
56
57/// Configuration for the notification queue.
58#[derive(Debug, Clone)]
59pub struct QueueConfig {
60    /// Maximum number of toasts visible at once.
61    pub max_visible: usize,
62    /// Maximum number of notifications waiting in queue.
63    pub max_queued: usize,
64    /// Default auto-dismiss duration.
65    pub default_duration: Duration,
66    /// Anchor position for the toast stack.
67    pub position: ToastPosition,
68    /// Vertical spacing between stacked toasts.
69    pub stagger_offset: u16,
70    /// Time window for deduplication (in ms).
71    pub dedup_window_ms: u64,
72}
73
74impl Default for QueueConfig {
75    fn default() -> Self {
76        Self {
77            max_visible: 3,
78            max_queued: 10,
79            default_duration: Duration::from_secs(5),
80            position: ToastPosition::TopRight,
81            stagger_offset: 1,
82            dedup_window_ms: 1000,
83        }
84    }
85}
86
87impl QueueConfig {
88    /// Create a new configuration with default values.
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Set maximum visible toasts.
94    pub fn max_visible(mut self, max: usize) -> Self {
95        self.max_visible = max;
96        self
97    }
98
99    /// Set maximum queued notifications.
100    pub fn max_queued(mut self, max: usize) -> Self {
101        self.max_queued = max;
102        self
103    }
104
105    /// Set default duration for auto-dismiss.
106    pub fn default_duration(mut self, duration: Duration) -> Self {
107        self.default_duration = duration;
108        self
109    }
110
111    /// Set anchor position for the toast stack.
112    pub fn position(mut self, position: ToastPosition) -> Self {
113        self.position = position;
114        self
115    }
116
117    /// Set vertical spacing between stacked toasts.
118    pub fn stagger_offset(mut self, offset: u16) -> Self {
119        self.stagger_offset = offset;
120        self
121    }
122
123    /// Set deduplication time window in milliseconds.
124    pub fn dedup_window_ms(mut self, ms: u64) -> Self {
125        self.dedup_window_ms = ms;
126        self
127    }
128}
129
130/// Internal representation of a queued notification.
131#[derive(Debug)]
132struct QueuedNotification {
133    toast: Toast,
134    priority: NotificationPriority,
135    /// When the notification was queued (for potential time-based priority decay).
136    #[allow(dead_code)]
137    created_at: Instant,
138    content_hash: u64,
139}
140
141impl QueuedNotification {
142    fn new(toast: Toast, priority: NotificationPriority) -> Self {
143        let content_hash = Self::compute_hash(&toast);
144        Self {
145            toast,
146            priority,
147            created_at: Instant::now(),
148            content_hash,
149        }
150    }
151
152    fn compute_hash(toast: &Toast) -> u64 {
153        use std::collections::hash_map::DefaultHasher;
154        let mut hasher = DefaultHasher::new();
155        toast.content.message.hash(&mut hasher);
156        if let Some(ref title) = toast.content.title {
157            title.hash(&mut hasher);
158        }
159        hasher.finish()
160    }
161}
162
163/// Actions returned by `tick()` to be processed by the application.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum QueueAction {
166    /// Show a new toast at the given position.
167    Show(ToastId),
168    /// Hide an existing toast.
169    Hide(ToastId),
170    /// Reposition a toast (for stacking adjustments).
171    Reposition(ToastId),
172}
173
174/// Queue statistics for monitoring and debugging.
175#[derive(Debug, Clone, Default)]
176pub struct QueueStats {
177    /// Total notifications pushed.
178    pub total_pushed: u64,
179    /// Notifications rejected due to queue overflow.
180    pub overflow_count: u64,
181    /// Notifications rejected due to deduplication.
182    pub dedup_count: u64,
183    /// Notifications dismissed by user.
184    pub user_dismissed: u64,
185    /// Notifications expired automatically.
186    pub auto_expired: u64,
187}
188
189/// Notification queue manager.
190///
191/// Manages multiple toast notifications with priority ordering, deduplication,
192/// and automatic expiry. Use `push` to add notifications and `tick` to process
193/// expiry in your event loop.
194#[derive(Debug)]
195pub struct NotificationQueue {
196    /// Pending notifications waiting to be displayed.
197    queue: VecDeque<QueuedNotification>,
198    /// Currently visible toasts.
199    visible: Vec<Toast>,
200    /// Configuration.
201    config: QueueConfig,
202    /// Deduplication window.
203    dedup_window: Duration,
204    /// Recent content hashes for deduplication.
205    recent_hashes: HashMap<u64, Instant>,
206    /// Statistics.
207    stats: QueueStats,
208}
209
210/// Widget that renders the visible toasts in a queue.
211///
212/// This is a thin renderer over `NotificationQueue`, keeping stacking logic
213/// centralized in the queue while ensuring the draw path stays deterministic.
214pub struct NotificationStack<'a> {
215    queue: &'a NotificationQueue,
216    margin: u16,
217}
218
219impl<'a> NotificationStack<'a> {
220    /// Create a new notification stack renderer.
221    pub fn new(queue: &'a NotificationQueue) -> Self {
222        Self { queue, margin: 1 }
223    }
224
225    /// Set the margin from the screen edge.
226    pub fn margin(mut self, margin: u16) -> Self {
227        self.margin = margin;
228        self
229    }
230}
231
232impl Widget for NotificationStack<'_> {
233    fn render(&self, area: Rect, frame: &mut Frame) {
234        if area.is_empty() || self.queue.visible().is_empty() {
235            return;
236        }
237
238        let positions = self
239            .queue
240            .calculate_positions(area.width, area.height, self.margin);
241
242        for (toast, (_, rel_x, rel_y)) in self.queue.visible().iter().zip(positions.iter()) {
243            let (toast_width, toast_height) = toast.calculate_dimensions();
244            let x = area.x.saturating_add(*rel_x);
245            let y = area.y.saturating_add(*rel_y);
246            let toast_area = Rect::new(x, y, toast_width, toast_height);
247            let render_area = toast_area.intersection(&area);
248            if !render_area.is_empty() {
249                toast.render(render_area, frame);
250            }
251        }
252    }
253}
254
255impl NotificationQueue {
256    /// Create a new notification queue with the given configuration.
257    pub fn new(config: QueueConfig) -> Self {
258        let dedup_window = Duration::from_millis(config.dedup_window_ms);
259        Self {
260            queue: VecDeque::new(),
261            visible: Vec::new(),
262            config,
263            dedup_window,
264            recent_hashes: HashMap::new(),
265            stats: QueueStats::default(),
266        }
267    }
268
269    /// Create a new queue with default configuration.
270    pub fn with_defaults() -> Self {
271        Self::new(QueueConfig::default())
272    }
273
274    /// Push a notification to the queue.
275    ///
276    /// Returns `true` if the notification was accepted, `false` if it was
277    /// rejected due to deduplication or queue overflow.
278    pub fn push(&mut self, toast: Toast, priority: NotificationPriority) -> bool {
279        self.stats.total_pushed += 1;
280
281        let queued = QueuedNotification::new(toast, priority);
282
283        // Check deduplication
284        if !self.dedup_check(queued.content_hash) {
285            self.stats.dedup_count += 1;
286            return false;
287        }
288
289        // Check queue overflow
290        if self.queue.len() >= self.config.max_queued {
291            self.stats.overflow_count += 1;
292            // Drop oldest low-priority item if possible
293            if let Some(idx) = self.find_lowest_priority_index() {
294                if self.queue[idx].priority < priority {
295                    self.queue.remove(idx);
296                } else {
297                    return false; // New item is lower or equal priority
298                }
299            } else {
300                return false;
301            }
302        }
303
304        // Insert based on priority
305        if priority == NotificationPriority::Urgent {
306            // Urgent jumps to front
307            self.queue.push_front(queued);
308        } else {
309            // Insert in priority order
310            let insert_idx = self
311                .queue
312                .iter()
313                .position(|q| q.priority < priority)
314                .unwrap_or(self.queue.len());
315            self.queue.insert(insert_idx, queued);
316        }
317
318        true
319    }
320
321    /// Push a notification with normal priority.
322    pub fn notify(&mut self, toast: Toast) -> bool {
323        self.push(toast, NotificationPriority::Normal)
324    }
325
326    /// Push an urgent notification.
327    pub fn urgent(&mut self, toast: Toast) -> bool {
328        self.push(toast, NotificationPriority::Urgent)
329    }
330
331    /// Dismiss a specific notification by ID.
332    pub fn dismiss(&mut self, id: ToastId) {
333        // Check visible toasts
334        if let Some(idx) = self.visible.iter().position(|t| t.id == id) {
335            self.visible[idx].dismiss();
336            self.stats.user_dismissed += 1;
337        }
338
339        // Check queue
340        if let Some(idx) = self.queue.iter().position(|q| q.toast.id == id) {
341            self.queue.remove(idx);
342            self.stats.user_dismissed += 1;
343        }
344    }
345
346    /// Dismiss all notifications.
347    pub fn dismiss_all(&mut self) {
348        for toast in &mut self.visible {
349            toast.dismiss();
350        }
351        self.stats.user_dismissed += self.queue.len() as u64;
352        self.queue.clear();
353    }
354
355    /// Process a time tick, handling expiry and promotion.
356    ///
357    /// Call this regularly in your event loop (e.g., every frame or every 16ms).
358    /// Returns a list of actions to perform.
359    pub fn tick(&mut self, _delta: Duration) -> Vec<QueueAction> {
360        let mut actions = Vec::new();
361
362        // Clean expired dedup hashes
363        let now = Instant::now();
364        self.recent_hashes
365            .retain(|_, t| now.duration_since(*t) < self.dedup_window);
366
367        // Process visible toasts for expiry
368        let mut i = 0;
369        while i < self.visible.len() {
370            if !self.visible[i].is_visible() {
371                let id = self.visible[i].id;
372                self.visible.remove(i);
373                self.stats.auto_expired += 1;
374                actions.push(QueueAction::Hide(id));
375            } else {
376                i += 1;
377            }
378        }
379
380        // Promote from queue to visible
381        while self.visible.len() < self.config.max_visible {
382            if let Some(queued) = self.queue.pop_front() {
383                let id = queued.toast.id;
384                self.visible.push(queued.toast);
385                actions.push(QueueAction::Show(id));
386            } else {
387                break;
388            }
389        }
390
391        actions
392    }
393
394    /// Get currently visible toasts.
395    pub fn visible(&self) -> &[Toast] {
396        &self.visible
397    }
398
399    /// Get mutable access to visible toasts.
400    pub fn visible_mut(&mut self) -> &mut [Toast] {
401        &mut self.visible
402    }
403
404    /// Get the number of notifications waiting in the queue.
405    pub fn pending_count(&self) -> usize {
406        self.queue.len()
407    }
408
409    /// Get the number of visible toasts.
410    pub fn visible_count(&self) -> usize {
411        self.visible.len()
412    }
413
414    /// Get the total count (visible + pending).
415    pub fn total_count(&self) -> usize {
416        self.visible.len() + self.queue.len()
417    }
418
419    /// Check if the queue is empty (no visible or pending notifications).
420    pub fn is_empty(&self) -> bool {
421        self.visible.is_empty() && self.queue.is_empty()
422    }
423
424    /// Get queue statistics.
425    pub fn stats(&self) -> &QueueStats {
426        &self.stats
427    }
428
429    /// Get the configuration.
430    pub fn config(&self) -> &QueueConfig {
431        &self.config
432    }
433
434    /// Calculate stacking positions for all visible toasts.
435    ///
436    /// Returns a list of (ToastId, x, y) positions.
437    pub fn calculate_positions(
438        &self,
439        terminal_width: u16,
440        terminal_height: u16,
441        margin: u16,
442    ) -> Vec<(ToastId, u16, u16)> {
443        let mut positions = Vec::with_capacity(self.visible.len());
444        let is_top = matches!(
445            self.config.position,
446            ToastPosition::TopLeft | ToastPosition::TopCenter | ToastPosition::TopRight
447        );
448
449        let mut y_offset: u16 = 0;
450
451        for toast in &self.visible {
452            let (toast_width, toast_height) = toast.calculate_dimensions();
453            let (base_x, base_y) = self.config.position.calculate_position(
454                terminal_width,
455                terminal_height,
456                toast_width,
457                toast_height,
458                margin,
459            );
460
461            let y = if is_top {
462                base_y.saturating_add(y_offset)
463            } else {
464                base_y.saturating_sub(y_offset)
465            };
466
467            positions.push((toast.id, base_x, y));
468            y_offset = y_offset
469                .saturating_add(toast_height)
470                .saturating_add(self.config.stagger_offset);
471        }
472
473        positions
474    }
475
476    // --- Internal methods ---
477
478    /// Check if a content hash is a duplicate within the dedup window.
479    fn dedup_check(&mut self, hash: u64) -> bool {
480        let now = Instant::now();
481
482        // Clean old hashes
483        self.recent_hashes
484            .retain(|_, t| now.duration_since(*t) < self.dedup_window);
485
486        // Check if duplicate
487        if self.recent_hashes.contains_key(&hash) {
488            return false;
489        }
490
491        self.recent_hashes.insert(hash, now);
492        true
493    }
494
495    /// Find the index of the lowest priority item in the queue.
496    fn find_lowest_priority_index(&self) -> Option<usize> {
497        self.queue
498            .iter()
499            .enumerate()
500            .min_by_key(|(_, q)| q.priority)
501            .map(|(i, _)| i)
502    }
503}
504
505impl Default for NotificationQueue {
506    fn default() -> Self {
507        Self::with_defaults()
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use ftui_render::frame::Frame;
515    use ftui_render::grapheme_pool::GraphemePool;
516
517    fn make_toast(msg: &str) -> Toast {
518        Toast::with_id(ToastId::new(0), msg).persistent() // Use persistent for testing
519    }
520
521    #[test]
522    fn test_queue_new() {
523        let queue = NotificationQueue::with_defaults();
524        assert!(queue.is_empty());
525        assert_eq!(queue.visible_count(), 0);
526        assert_eq!(queue.pending_count(), 0);
527    }
528
529    #[test]
530    fn test_queue_push_and_tick() {
531        let mut queue = NotificationQueue::with_defaults();
532
533        queue.push(make_toast("Hello"), NotificationPriority::Normal);
534        assert_eq!(queue.pending_count(), 1);
535        assert_eq!(queue.visible_count(), 0);
536
537        // Tick promotes from queue to visible
538        let actions = queue.tick(Duration::from_millis(16));
539        assert_eq!(queue.pending_count(), 0);
540        assert_eq!(queue.visible_count(), 1);
541        assert_eq!(actions.len(), 1);
542        assert!(matches!(actions[0], QueueAction::Show(_)));
543    }
544
545    #[test]
546    fn test_queue_fifo() {
547        let config = QueueConfig::default().max_visible(1);
548        let mut queue = NotificationQueue::new(config);
549
550        queue.push(make_toast("First"), NotificationPriority::Normal);
551        queue.push(make_toast("Second"), NotificationPriority::Normal);
552        queue.push(make_toast("Third"), NotificationPriority::Normal);
553
554        queue.tick(Duration::from_millis(16));
555        assert_eq!(queue.visible()[0].content.message, "First");
556
557        // Dismiss first, tick to get second
558        queue.visible_mut()[0].dismiss();
559        queue.tick(Duration::from_millis(16));
560        assert_eq!(queue.visible()[0].content.message, "Second");
561    }
562
563    #[test]
564    fn test_queue_max_visible() {
565        let config = QueueConfig::default().max_visible(2);
566        let mut queue = NotificationQueue::new(config);
567
568        queue.push(make_toast("A"), NotificationPriority::Normal);
569        queue.push(make_toast("B"), NotificationPriority::Normal);
570        queue.push(make_toast("C"), NotificationPriority::Normal);
571
572        queue.tick(Duration::from_millis(16));
573
574        assert_eq!(queue.visible_count(), 2);
575        assert_eq!(queue.pending_count(), 1);
576    }
577
578    #[test]
579    fn test_queue_priority_urgent() {
580        let config = QueueConfig::default().max_visible(1);
581        let mut queue = NotificationQueue::new(config);
582
583        queue.push(make_toast("Normal1"), NotificationPriority::Normal);
584        queue.push(make_toast("Normal2"), NotificationPriority::Normal);
585        queue.push(make_toast("Urgent"), NotificationPriority::Urgent);
586
587        queue.tick(Duration::from_millis(16));
588        // Urgent should jump to front
589        assert_eq!(queue.visible()[0].content.message, "Urgent");
590    }
591
592    #[test]
593    fn test_queue_priority_ordering() {
594        let config = QueueConfig::default().max_visible(0); // No auto-promote
595        let mut queue = NotificationQueue::new(config);
596
597        queue.push(make_toast("Low"), NotificationPriority::Low);
598        queue.push(make_toast("Normal"), NotificationPriority::Normal);
599        queue.push(make_toast("High"), NotificationPriority::High);
600
601        // Queue should be ordered High, Normal, Low
602        let messages: Vec<_> = queue
603            .queue
604            .iter()
605            .map(|q| q.toast.content.message.as_str())
606            .collect();
607        assert_eq!(messages, vec!["High", "Normal", "Low"]);
608    }
609
610    #[test]
611    fn test_queue_dedup() {
612        let config = QueueConfig::default().dedup_window_ms(1000);
613        let mut queue = NotificationQueue::new(config);
614
615        assert!(queue.push(make_toast("Same message"), NotificationPriority::Normal));
616        assert!(!queue.push(make_toast("Same message"), NotificationPriority::Normal));
617
618        assert_eq!(queue.stats().dedup_count, 1);
619    }
620
621    #[test]
622    fn test_queue_overflow() {
623        let config = QueueConfig::default().max_queued(2);
624        let mut queue = NotificationQueue::new(config);
625
626        assert!(queue.push(make_toast("A"), NotificationPriority::Normal));
627        assert!(queue.push(make_toast("B"), NotificationPriority::Normal));
628        // Third should fail (queue full)
629        assert!(!queue.push(make_toast("C"), NotificationPriority::Normal));
630
631        assert_eq!(queue.stats().overflow_count, 1);
632    }
633
634    #[test]
635    fn test_queue_overflow_drops_lower_priority() {
636        let config = QueueConfig::default().max_queued(2);
637        let mut queue = NotificationQueue::new(config);
638
639        assert!(queue.push(make_toast("Low1"), NotificationPriority::Low));
640        assert!(queue.push(make_toast("Low2"), NotificationPriority::Low));
641        // High priority should drop a low priority item
642        assert!(queue.push(make_toast("High"), NotificationPriority::High));
643
644        assert_eq!(queue.pending_count(), 2);
645        let messages: Vec<_> = queue
646            .queue
647            .iter()
648            .map(|q| q.toast.content.message.as_str())
649            .collect();
650        assert!(messages.contains(&"High"));
651    }
652
653    #[test]
654    fn test_queue_dismiss() {
655        let mut queue = NotificationQueue::with_defaults();
656
657        queue.push(make_toast("Test"), NotificationPriority::Normal);
658        queue.tick(Duration::from_millis(16));
659
660        let id = queue.visible()[0].id;
661        queue.dismiss(id);
662        queue.tick(Duration::from_millis(16));
663
664        assert_eq!(queue.visible_count(), 0);
665        assert_eq!(queue.stats().user_dismissed, 1);
666    }
667
668    #[test]
669    fn test_queue_dismiss_all() {
670        let mut queue = NotificationQueue::with_defaults();
671
672        queue.push(make_toast("A"), NotificationPriority::Normal);
673        queue.push(make_toast("B"), NotificationPriority::Normal);
674        queue.tick(Duration::from_millis(16));
675
676        queue.dismiss_all();
677        queue.tick(Duration::from_millis(16));
678
679        assert!(queue.is_empty());
680    }
681
682    #[test]
683    fn test_queue_calculate_positions_top() {
684        let config = QueueConfig::default().position(ToastPosition::TopRight);
685        let mut queue = NotificationQueue::new(config);
686
687        queue.push(make_toast("A"), NotificationPriority::Normal);
688        queue.push(make_toast("B"), NotificationPriority::Normal);
689        queue.tick(Duration::from_millis(16));
690
691        let positions = queue.calculate_positions(80, 24, 1);
692        assert_eq!(positions.len(), 2);
693
694        // First toast should be at top, second below
695        assert!(positions[0].2 < positions[1].2);
696    }
697
698    #[test]
699    fn test_queue_calculate_positions_bottom() {
700        let config = QueueConfig::default().position(ToastPosition::BottomRight);
701        let mut queue = NotificationQueue::new(config);
702
703        queue.push(make_toast("A"), NotificationPriority::Normal);
704        queue.push(make_toast("B"), NotificationPriority::Normal);
705        queue.tick(Duration::from_millis(16));
706
707        let positions = queue.calculate_positions(80, 24, 1);
708        assert_eq!(positions.len(), 2);
709
710        // First toast should be at bottom, second above
711        assert!(positions[0].2 > positions[1].2);
712    }
713
714    #[test]
715    fn test_queue_notify_helper() {
716        let mut queue = NotificationQueue::with_defaults();
717        assert!(queue.notify(make_toast("Normal")));
718        queue.tick(Duration::from_millis(16));
719        assert_eq!(queue.visible_count(), 1);
720    }
721
722    #[test]
723    fn test_queue_urgent_helper() {
724        let config = QueueConfig::default().max_visible(1);
725        let mut queue = NotificationQueue::new(config);
726
727        queue.notify(make_toast("Normal"));
728        queue.urgent(make_toast("Urgent"));
729        queue.tick(Duration::from_millis(16));
730
731        assert_eq!(queue.visible()[0].content.message, "Urgent");
732    }
733
734    #[test]
735    fn test_queue_stats() {
736        let mut queue = NotificationQueue::with_defaults();
737
738        queue.push(make_toast("A"), NotificationPriority::Normal);
739        queue.push(make_toast("A"), NotificationPriority::Normal); // Dedup
740        queue.tick(Duration::from_millis(16));
741
742        assert_eq!(queue.stats().total_pushed, 2);
743        assert_eq!(queue.stats().dedup_count, 1);
744    }
745
746    #[test]
747    fn test_queue_config_builder() {
748        let config = QueueConfig::new()
749            .max_visible(5)
750            .max_queued(20)
751            .default_duration(Duration::from_secs(10))
752            .position(ToastPosition::BottomLeft)
753            .stagger_offset(2)
754            .dedup_window_ms(500);
755
756        assert_eq!(config.max_visible, 5);
757        assert_eq!(config.max_queued, 20);
758        assert_eq!(config.default_duration, Duration::from_secs(10));
759        assert_eq!(config.position, ToastPosition::BottomLeft);
760        assert_eq!(config.stagger_offset, 2);
761        assert_eq!(config.dedup_window_ms, 500);
762    }
763
764    #[test]
765    fn test_queue_total_count() {
766        let config = QueueConfig::default().max_visible(1);
767        let mut queue = NotificationQueue::new(config);
768
769        queue.push(make_toast("A"), NotificationPriority::Normal);
770        queue.push(make_toast("B"), NotificationPriority::Normal);
771        queue.tick(Duration::from_millis(16));
772
773        assert_eq!(queue.total_count(), 2);
774        assert_eq!(queue.visible_count(), 1);
775        assert_eq!(queue.pending_count(), 1);
776    }
777
778    #[test]
779    fn notification_stack_renders_visible_toast() {
780        let mut queue = NotificationQueue::with_defaults();
781        queue.push(make_toast("Hello"), NotificationPriority::Normal);
782        queue.tick(Duration::from_millis(16));
783
784        let mut pool = GraphemePool::new();
785        let mut frame = Frame::new(40, 10, &mut pool);
786        let area = Rect::new(0, 0, 40, 10);
787
788        NotificationStack::new(&queue)
789            .margin(0)
790            .render(area, &mut frame);
791
792        let (_, x, y) = queue.calculate_positions(40, 10, 0)[0];
793        let cell = frame.buffer.get(x, y).expect("cell should exist");
794        assert!(!cell.is_empty(), "stack should render toast content");
795    }
796}