1pub mod body;
38pub mod ext;
39pub mod host;
40pub mod registry;
41pub mod surface;
42
43#[cfg(test)]
44mod reader_tests;
45
46use std::cell::Cell;
47use std::rc::Rc;
48use std::time::Duration;
49
50use teksilo_core::widget::{EventContext, Widget};
51use teksilo_core::window::TeksiloWindowId;
52
53pub use body::TOAST_BODY_COLLAPSED_LINES;
54pub use ext::EventContextToastExt;
55pub use host::{ToastHost, ToastInstallOptions};
56pub use registry::ToastRegistry;
57pub use surface::ToastSurface;
58pub use teksilo_core::styles::{ToastPriority, ToastStyleConfig};
59
60pub use teksilo_core::styles::BannerSeverity as ToastSeverity;
64use teksilo_i18n::LocalizedString;
65
66pub const DEFAULT_TOAST_AUTO_DISMISS: Duration = Duration::from_secs(10);
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum ToastDismissCause {
77 Timeout,
79 ActionInvoked,
81 CloseClicked,
83 EscapePressed,
85 Programmatic,
87 HostShutdown,
89 SlotPoolFull,
95 SwipeDismissed,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
113pub struct ToastAudience(u64);
114
115impl ToastAudience {
116 pub fn new(id: u64) -> Self {
120 Self(id)
121 }
122
123 pub fn raw(&self) -> u64 {
125 self.0
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
146pub enum ToastRoute {
147 Window(TeksiloWindowId),
153 Audience(ToastAudience),
155 Broadcast,
157}
158
159#[derive(Debug, Clone, Default)]
165pub enum ToastActionStyle {
166 #[default]
169 Link,
170 Button {
174 variant: crate::button::ButtonVariant,
177 },
178}
179
180pub type ToastActionCallback = Rc<dyn Fn(&mut EventContext)>;
184
185pub struct ToastAction {
188 label: LocalizedString,
189 on_invoke: ToastActionCallback,
190 style: ToastActionStyle,
191 closes_toast: bool,
192 shortcut_id: Option<String>,
193 tooltip: Option<LocalizedString>,
194}
195
196impl ToastAction {
197 pub fn new(
200 label: impl Into<LocalizedString>,
201 on_invoke: impl Fn(&mut EventContext) + 'static,
202 ) -> Self {
203 let ls: LocalizedString = label.into();
204 Self {
205 label: ls,
206 on_invoke: Rc::new(on_invoke),
207 style: ToastActionStyle::default(),
208 closes_toast: true,
209 shortcut_id: None,
210 tooltip: None,
211 }
212 }
213
214 pub fn primary(
217 label: impl Into<LocalizedString>,
218 on_invoke: impl Fn(&mut EventContext) + 'static,
219 ) -> Self {
220 Self::new(label, on_invoke).style(ToastActionStyle::Button {
221 variant: crate::button::ButtonVariant::Filled,
222 })
223 }
224
225 pub fn destructive(
228 label: impl Into<LocalizedString>,
229 on_invoke: impl Fn(&mut EventContext) + 'static,
230 ) -> Self {
231 Self::new(label, on_invoke).style(ToastActionStyle::Button {
232 variant: crate::button::ButtonVariant::Destructive,
233 })
234 }
235
236 pub fn style(mut self, style: ToastActionStyle) -> Self {
238 self.style = style;
239 self
240 }
241
242 pub fn closes_toast(mut self, closes: bool) -> Self {
247 self.closes_toast = closes;
248 self
249 }
250
251 pub fn shortcut_id(mut self, id: impl Into<String>) -> Self {
259 self.shortcut_id = Some(id.into());
260 self
261 }
262
263 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
265 self.tooltip = Some(text.into());
266 self
267 }
268
269 pub fn label(&self) -> String {
271 self.label.resolve_now()
272 }
273 pub fn style_ref(&self) -> &ToastActionStyle {
275 &self.style
276 }
277 pub fn closes_toast_flag(&self) -> bool {
279 self.closes_toast
280 }
281 pub fn shortcut_id_ref(&self) -> Option<&str> {
283 self.shortcut_id.as_deref()
284 }
285 pub(crate) fn label_ls(&self) -> LocalizedString {
288 self.label.clone()
289 }
290
291 pub fn tooltip_ref(&self) -> Option<&LocalizedString> {
293 self.tooltip.as_ref()
294 }
295 pub fn callback(&self) -> ToastActionCallback {
297 self.on_invoke.clone()
298 }
299}
300
301impl std::fmt::Debug for ToastAction {
302 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303 f.debug_struct("ToastAction")
304 .field("label", &self.label)
305 .field("style", &self.style)
306 .field("closes_toast", &self.closes_toast)
307 .field("shortcut_id", &self.shortcut_id)
308 .finish()
309 }
310}
311
312#[derive(Clone)]
324pub struct ToastHandle {
325 inner: Rc<ToastHandleInner>,
326}
327
328pub(crate) struct ToastHandleInner {
329 pub(crate) entry_id: u64,
330 pub(crate) dismissed: Cell<bool>,
337 pub(crate) registry: registry::ToastRegistry,
340}
341
342impl ToastHandle {
343 pub(crate) fn new(inner: ToastHandleInner) -> Self {
344 Self {
345 inner: Rc::new(inner),
346 }
347 }
348
349 pub fn entry_id(&self) -> u64 {
354 self.inner.entry_id
355 }
356
357 pub fn is_alive(&self) -> bool {
361 if self.inner.dismissed.get() {
362 return false;
363 }
364 self.inner.registry.is_entry_alive(self.inner.entry_id)
365 }
366
367 pub fn dismiss(&self, ctx: &mut EventContext) {
371 if self.inner.dismissed.get() {
372 return;
373 }
374 self.inner.registry.dismiss_entry(
375 self.inner.entry_id,
376 ToastDismissCause::Programmatic,
377 ctx,
378 );
379 }
380}
381
382impl std::fmt::Debug for ToastHandle {
383 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384 f.debug_struct("ToastHandle")
385 .field("entry_id", &self.inner.entry_id)
386 .field("dismissed", &self.inner.dismissed.get())
387 .finish()
388 }
389}
390
391pub type ToastDismissCallback = Rc<dyn Fn(ToastDismissCause, &mut EventContext)>;
397
398pub struct Toast {
406 pub(crate) severity: ToastSeverity,
407 pub(crate) title: LocalizedString,
408 pub(crate) body: Option<LocalizedString>,
409 pub(crate) leading: Option<Box<dyn Widget>>,
410 pub(crate) actions: Vec<ToastAction>,
411 pub(crate) auto_dismiss_after: Option<Duration>,
412 pub(crate) priority: ToastPriority,
413 pub(crate) id: Option<String>,
414 pub(crate) on_click: Option<Rc<dyn Fn(&mut EventContext)>>,
415 pub(crate) on_dismiss: Option<ToastDismissCallback>,
416 pub(crate) announcement: Option<LocalizedString>,
417 pub(crate) show_close_button: bool,
418 pub(crate) closable_on_escape: bool,
419 pub(crate) archive: bool,
420 pub(crate) style_override: Option<teksilo_core::styles::SharedToastStyle>,
421 pub(crate) target: Option<ToastRoute>,
427}
428
429impl std::fmt::Debug for Toast {
430 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 f.debug_struct("Toast")
432 .field("severity", &self.severity)
433 .field("title", &self.title)
434 .field("body", &self.body)
435 .field("priority", &self.priority)
436 .field("id", &self.id)
437 .field("auto_dismiss_after", &self.auto_dismiss_after)
438 .field("actions_count", &self.actions.len())
439 .field("target", &self.target)
440 .finish()
441 }
442}
443
444impl Toast {
445 fn build_with_severity(severity: ToastSeverity, title: impl Into<LocalizedString>) -> Self {
446 let ls: LocalizedString = title.into();
447 Self {
448 severity,
449 title: ls,
450 body: None,
451 leading: None,
452 actions: Vec::new(),
453 auto_dismiss_after: Some(DEFAULT_TOAST_AUTO_DISMISS),
454 priority: ToastPriority::Normal,
455 id: None,
456 on_click: None,
457 on_dismiss: None,
458 announcement: None,
459 show_close_button: true,
460 closable_on_escape: true,
461 archive: true,
462 style_override: None,
463 target: None,
464 }
465 }
466
467 pub fn info(title: impl Into<LocalizedString>) -> Self {
471 Self::build_with_severity(ToastSeverity::Info, title)
472 }
473 pub fn success(title: impl Into<LocalizedString>) -> Self {
475 Self::build_with_severity(ToastSeverity::Success, title)
476 }
477 pub fn warning(title: impl Into<LocalizedString>) -> Self {
479 Self::build_with_severity(ToastSeverity::Warning, title)
480 }
481 pub fn error(title: impl Into<LocalizedString>) -> Self {
483 Self::build_with_severity(ToastSeverity::Error, title)
484 }
485 pub fn loading(title: impl Into<LocalizedString>) -> Self {
491 Self::build_with_severity(ToastSeverity::Info, title)
492 .persistent()
493 .leading(crate::spinner::Spinner::new(16.0))
494 }
495
496 pub fn body(mut self, text: impl Into<LocalizedString>) -> Self {
500 let ls: LocalizedString = text.into();
501 self.body = Some(ls);
502 self
503 }
504
505 pub fn leading(mut self, widget: impl Widget + 'static) -> Self {
509 self.leading = Some(Box::new(widget));
510 self
511 }
512
513 pub fn action(mut self, action: ToastAction) -> Self {
517 self.actions.push(action);
518 self
519 }
520
521 pub fn actions(self, actions: impl IntoIterator<Item = ToastAction>) -> Self {
526 actions.into_iter().fold(self, Self::action)
527 }
528 pub fn primary_action(
531 self,
532 label: impl Into<LocalizedString>,
533 on_invoke: impl Fn(&mut EventContext) + 'static,
534 ) -> Self {
535 self.action(ToastAction::primary(label, on_invoke))
536 }
537
538 pub fn auto_dismiss_after(mut self, duration: Duration) -> Self {
543 self.auto_dismiss_after = Some(duration);
544 self
545 }
546 pub fn persistent(mut self) -> Self {
550 self.auto_dismiss_after = None;
551 self
552 }
553 pub fn priority(mut self, priority: ToastPriority) -> Self {
556 self.priority = priority;
557 self
558 }
559
560 pub fn id(mut self, id: impl Into<String>) -> Self {
605 self.id = Some(id.into());
606 self
607 }
608
609 pub fn on_click(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
614 self.on_click = Some(Rc::new(f));
615 self
616 }
617 pub fn on_dismiss(
621 mut self,
622 f: impl Fn(ToastDismissCause, &mut EventContext) + 'static,
623 ) -> Self {
624 self.on_dismiss = Some(Rc::new(f));
625 self
626 }
627 pub fn show_close_button(mut self, show: bool) -> Self {
629 self.show_close_button = show;
630 self
631 }
632 pub fn closable_on_escape(mut self, allow: bool) -> Self {
636 self.closable_on_escape = allow;
637 self
638 }
639
640 pub fn announcement(mut self, text: impl Into<LocalizedString>) -> Self {
646 let ls: LocalizedString = text.into();
647 self.announcement = Some(ls);
648 self
649 }
650
651 pub fn archive(mut self, archive: bool) -> Self {
658 self.archive = archive;
659 self
660 }
661
662 pub fn style(mut self, style: impl teksilo_core::styles::ToastStyle) -> Self {
667 self.style_override = Some(Rc::new(style));
668 self
669 }
670
671 pub fn target(mut self, audience: ToastAudience) -> Self {
678 self.target = Some(ToastRoute::Audience(audience));
679 self
680 }
681
682 pub fn broadcast(mut self) -> Self {
687 self.target = Some(ToastRoute::Broadcast);
688 self
689 }
690
691 pub fn present(self, ctx: &mut EventContext) -> ToastHandle {
700 EventContextToastExt::show_toast(ctx, self)
701 }
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707 use teksilo_i18n::lit;
708
709 #[test]
714 fn actions_plural_matches_the_singular_chain() {
715 fn labels(t: &Toast) -> Vec<String> {
716 t.actions.iter().map(|a| a.label.resolve_now()).collect()
717 }
718 let singular = Toast::info(lit!("x"))
719 .action(ToastAction::new(lit!("One"), |_| {}))
720 .action(ToastAction::new(lit!("Two"), |_| {}))
721 .action(ToastAction::new(lit!("Three"), |_| {}));
722 let plural = Toast::info(lit!("x")).actions([
723 ToastAction::new(lit!("One"), |_| {}),
724 ToastAction::new(lit!("Two"), |_| {}),
725 ToastAction::new(lit!("Three"), |_| {}),
726 ]);
727 assert_eq!(labels(&singular), ["One", "Two", "Three"]);
728 assert_eq!(labels(&singular), labels(&plural));
729 }
730
731 #[test]
732 fn severity_constructors_round_trip() {
733 assert_eq!(Toast::info(lit!("x")).severity, ToastSeverity::Info);
734 assert_eq!(Toast::success(lit!("x")).severity, ToastSeverity::Success);
735 assert_eq!(Toast::warning(lit!("x")).severity, ToastSeverity::Warning);
736 assert_eq!(Toast::error(lit!("x")).severity, ToastSeverity::Error);
737 }
738
739 #[test]
740 fn defaults_match_documented_values() {
741 let t = Toast::info(lit!("hello"));
742 assert_eq!(t.auto_dismiss_after, Some(DEFAULT_TOAST_AUTO_DISMISS));
743 assert_eq!(t.priority, ToastPriority::Normal);
744 assert!(t.show_close_button);
745 assert!(t.closable_on_escape);
746 assert!(t.archive);
747 assert!(t.body.is_none());
748 assert!(t.actions.is_empty());
749 assert!(t.on_dismiss.is_none());
750 assert!(t.on_click.is_none());
751 }
752
753 #[test]
754 fn persistent_clears_auto_dismiss() {
755 let t = Toast::error(lit!("boom")).persistent();
756 assert!(t.auto_dismiss_after.is_none());
757 }
758
759 #[test]
760 fn loading_is_info_persistent() {
761 let t = Toast::loading(lit!("Uploading"));
762 assert_eq!(t.severity, ToastSeverity::Info);
763 assert!(
764 t.auto_dismiss_after.is_none(),
765 "loading is persistent by default"
766 );
767 assert!(t.leading.is_some(), "loading sets a Spinner leading widget");
768 }
769
770 #[test]
771 fn action_primary_uses_filled_button() {
772 let a = ToastAction::primary(lit!("Retry"), |_| {}).style(ToastActionStyle::Button {
773 variant: crate::button::ButtonVariant::Filled,
774 });
775 match a.style_ref() {
776 ToastActionStyle::Button {
777 variant: crate::button::ButtonVariant::Filled,
778 } => {}
779 other => panic!("expected Button {{ Filled }}, got {other:?}"),
780 }
781 assert!(a.closes_toast_flag(), "actions close the toast by default");
782 }
783
784 #[test]
785 fn action_closes_toast_can_be_disabled() {
786 let a = ToastAction::new(lit!("Toggle"), |_| {}).closes_toast(false);
787 assert!(!a.closes_toast_flag());
788 }
789
790 fn fresh_registry() -> ToastRegistry {
795 ToastRegistry::new(host::ToastInstallOptions::default())
796 }
797
798 fn small_registry(max_visible: usize) -> ToastRegistry {
799 ToastRegistry::new(host::ToastInstallOptions {
800 max_visible,
801 ..host::ToastInstallOptions::default()
802 })
803 }
804
805 #[test]
806 fn enqueue_creates_live_entry() {
807 let r = fresh_registry();
808 let (h, _overflow) = r.enqueue(Toast::info(lit!("Saved")));
809 assert!(h.entry_id() > 0);
810 assert_eq!(r.live_count(), 1);
811 }
812
813 #[test]
814 fn enqueue_returns_distinct_ids() {
815 let r = fresh_registry();
816 let (h1, _) = r.enqueue(Toast::info(lit!("a")));
817 let (h2, _) = r.enqueue(Toast::info(lit!("b")));
818 let (h3, _) = r.enqueue(Toast::info(lit!("c")));
819 assert!(h1.entry_id() != h2.entry_id());
820 assert!(h2.entry_id() != h3.entry_id());
821 assert_eq!(r.live_count(), 3);
822 }
823
824 #[test]
825 fn slot_pool_overflow_drops_normal_priority() {
826 let r = small_registry(2);
827 let (_h1, _) = r.enqueue(Toast::info(lit!("a")));
828 let (_h2, _) = r.enqueue(Toast::info(lit!("b")));
829 assert_eq!(r.live_count(), 2);
830 let (h3, overflow) = r.enqueue(Toast::info(lit!("c")));
831 assert_eq!(
832 r.live_count(),
833 2,
834 "third Normal-priority toast must be dropped when pool is full"
835 );
836 assert!(!h3.is_alive(), "overflow handle should not be alive");
839 assert!(overflow.is_none());
841 }
842
843 #[test]
844 fn slot_pool_overflow_fires_on_dismiss_for_normal_drop() {
845 use std::cell::Cell;
846 let r = small_registry(2);
847 let (_h1, _) = r.enqueue(Toast::info(lit!("a")));
848 let (_h2, _) = r.enqueue(Toast::info(lit!("b")));
849 let fired = Rc::new(Cell::new(false));
850 let fired_clone = fired.clone();
851 let (_h3, overflow) = r.enqueue(Toast::info(lit!("c")).on_dismiss(move |cause, _ctx| {
852 assert_eq!(cause, ToastDismissCause::SlotPoolFull);
853 fired_clone.set(true);
854 }));
855 let (_cause, cb) = overflow.expect("overflow callback present");
859 let _ = cb;
864 assert!(!fired.get(), "callback fires only when ext invokes it");
867 }
868
869 #[test]
870 fn high_priority_evicts_oldest_normal_when_full() {
871 let r = small_registry(2);
872 let (h_a, _) = r.enqueue(Toast::info(lit!("a")));
873 let (h_b, _) = r.enqueue(Toast::info(lit!("b")));
874 let oldest_normal_id = h_a.entry_id();
875 let newer_normal_id = h_b.entry_id();
876 let (h_high, _) = r.enqueue(Toast::info(lit!("urgent")).priority(ToastPriority::High));
877 let live_ids = r.live_entry_ids();
878 assert!(
879 !live_ids.contains(&oldest_normal_id),
880 "oldest Normal must be evicted to make room for High"
881 );
882 assert!(live_ids.contains(&newer_normal_id));
883 assert!(live_ids.contains(&h_high.entry_id()));
884 assert_eq!(r.live_count(), 2);
885 }
886
887 #[test]
888 fn tick_timers_decrements_and_dismisses_on_expiry() {
889 let r = fresh_registry();
890 let (h, _) =
891 r.enqueue(Toast::info(lit!("fast")).auto_dismiss_after(Duration::from_millis(500)));
892 let id = h.entry_id();
893
894 let any_expired = r.tick_timers(Duration::from_millis(200), false);
896 assert!(!any_expired);
897 assert!(r.live_entry_ids().contains(&id));
898
899 let any_expired = r.tick_timers(Duration::from_millis(350), false);
901 assert!(any_expired);
902 assert!(!r.live_entry_ids().contains(&id));
903 }
904
905 #[test]
906 fn paused_tick_does_not_decrement() {
907 let r = fresh_registry();
908 let (h, _) =
909 r.enqueue(Toast::info(lit!("slow")).auto_dismiss_after(Duration::from_millis(300)));
910 for _ in 0..10 {
912 let any_expired = r.tick_timers(Duration::from_millis(100), true);
913 assert!(!any_expired);
914 }
915 assert!(r.live_entry_ids().contains(&h.entry_id()));
916 }
917
918 #[test]
919 fn persistent_toast_never_expires() {
920 let r = fresh_registry();
921 let (h, _) = r.enqueue(Toast::error(lit!("sticky")).persistent());
922 for _ in 0..50 {
923 let any_expired = r.tick_timers(Duration::from_secs(1), false);
924 assert!(!any_expired);
925 }
926 assert!(r.live_entry_ids().contains(&h.entry_id()));
927 }
928
929 #[test]
930 fn has_running_timers_gates_the_idle_frame_loop() {
931 let r = fresh_registry();
937
938 assert!(!r.has_running_timers());
940
941 let (sticky, _) = r.enqueue(Toast::error(lit!("sticky")).persistent());
943 assert!(!r.has_running_timers());
944
945 let (timed, _) =
947 r.enqueue(Toast::info(lit!("timed")).auto_dismiss_after(Duration::from_millis(500)));
948 assert!(r.has_running_timers());
949
950 let expired = r.tick_timers(Duration::from_millis(600), false);
953 assert!(expired);
954 assert!(!r.live_entry_ids().contains(&timed.entry_id()));
955 assert!(r.live_entry_ids().contains(&sticky.entry_id()));
956 assert!(!r.has_running_timers());
957 }
958
959 #[test]
960 fn loading_constructor_is_persistent_with_spinner_leading() {
961 let r = fresh_registry();
962 let (h, _) = r.enqueue(Toast::loading(lit!("Uploading")));
963 r.with_entry(h.entry_id(), |e| {
964 assert!(e.time_left.is_none(), "loading toasts are persistent");
965 assert!(
966 e.leading.is_some(),
967 "loading toasts carry a Spinner leading"
968 );
969 assert_eq!(e.severity, ToastSeverity::Info);
970 })
971 .unwrap();
972 }
973
974 #[test]
975 fn version_signal_bumps_on_enqueue_and_dismiss() {
976 let r = fresh_registry();
977 let initial = r.version_signal().get();
978 let (_h1, _) = r.enqueue(Toast::info(lit!("a")));
979 let after_show = r.version_signal().get();
980 assert_ne!(initial, after_show, "version bumps on enqueue");
981
982 r.tick_timers(Duration::ZERO, false); let (h2, _) =
985 r.enqueue(Toast::info(lit!("b")).auto_dismiss_after(Duration::from_millis(1)));
986 let _ = h2;
987 let pre_dismiss = r.version_signal().get();
988 r.tick_timers(Duration::from_millis(10), false);
989 let post_dismiss = r.version_signal().get();
990 assert_ne!(pre_dismiss, post_dismiss, "version bumps on timer dismiss");
991 }
992
993 #[test]
994 fn registry_with_archive_mirrors_pushes() {
995 use crate::notification::NotificationArchiveModel;
996 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
997 let registry =
998 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
999 let (_h1, _) = registry.enqueue(Toast::error(lit!("Build failed")));
1000 let (_h2, _) = registry.enqueue(Toast::success(lit!("Deploy ok")));
1001 assert_eq!(archive.entries().len(), 2);
1003 assert_eq!(
1005 archive.entries().with_item(0, |e| e.title.clone()),
1006 Some("Deploy ok".into())
1007 );
1008 assert_eq!(archive.unread_count().get(), 2);
1009 }
1010
1011 #[test]
1012 fn registry_archive_false_skips_mirroring() {
1013 use crate::notification::NotificationArchiveModel;
1014 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1015 let registry =
1016 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1017 let (_archived, _) = registry.enqueue(Toast::info(lit!("logged")));
1019 let (_silent, _) = registry.enqueue(Toast::info(lit!("Copied!")).archive(false));
1021 assert_eq!(archive.entries().len(), 1);
1022 assert_eq!(
1023 archive.entries().with_item(0, |e| e.title.clone()),
1024 Some("logged".into())
1025 );
1026 }
1027
1028 #[test]
1029 fn registry_with_id_updates_live_entry_in_place_keeping_entry_id() {
1030 let r = fresh_registry();
1031 let (first, _) = r.enqueue(Toast::loading(lit!("Uploading 1 of 7…")).id("upload"));
1032 assert_eq!(r.live_count(), 1);
1033 let first_entry_id = first.entry_id();
1034
1035 let (second, _) = r.enqueue(Toast::loading(lit!("Uploading 4 of 7…")).id("upload"));
1036 assert_eq!(r.live_count(), 1, "live entry count stays at 1");
1038 assert_eq!(
1039 second.entry_id(),
1040 first_entry_id,
1041 "update returns the same entry_id — the original handle stays valid"
1042 );
1043
1044 assert!(first.is_alive());
1047 assert!(second.is_alive());
1048 }
1049
1050 #[test]
1051 fn registry_in_place_update_reflects_new_title_body() {
1052 let r = fresh_registry();
1053 let (h, _) = r.enqueue(Toast::info(lit!("Saving")).id("save"));
1054 let _ = r.enqueue(
1055 Toast::success(lit!("Saved!"))
1056 .id("save")
1057 .body(lit!("Written 1.2 MB to disk.")),
1058 );
1059 r.with_entry(h.entry_id(), |e| {
1060 assert_eq!(e.title.resolve_now(), "Saved!", "title updated in place");
1061 assert_eq!(
1062 e.body.as_ref().map(|b| b.resolve_now()).as_deref(),
1063 Some("Written 1.2 MB to disk."),
1064 "body updated in place"
1065 );
1066 assert_eq!(e.severity, ToastSeverity::Success, "severity updated");
1067 })
1068 .unwrap();
1069 }
1070
1071 #[test]
1072 fn registry_in_place_update_resets_auto_dismiss_timer() {
1073 let r = fresh_registry();
1074 let (h, _) = r.enqueue(
1075 Toast::info(lit!("slow"))
1076 .id("ticker")
1077 .auto_dismiss_after(Duration::from_millis(500)),
1078 );
1079 r.tick_timers(Duration::from_millis(450), false);
1081 let _ = r.enqueue(
1083 Toast::info(lit!("slow #2"))
1084 .id("ticker")
1085 .auto_dismiss_after(Duration::from_millis(500)),
1086 );
1087 let any_expired = r.tick_timers(Duration::from_millis(100), false);
1089 assert!(
1090 !any_expired,
1091 "timer reset on update — entry must survive a tick that would have expired the original"
1092 );
1093 assert!(h.is_alive());
1094 }
1095
1096 #[test]
1097 fn registry_in_place_update_preserves_leading_when_not_provided() {
1098 let r = fresh_registry();
1103 let (h, _) = r.enqueue(Toast::loading(lit!("step 1")).id("upload"));
1104 let has_spinner_initially = r.with_entry(h.entry_id(), |e| e.leading.is_some()).unwrap();
1108 assert!(has_spinner_initially, "loading toast carries a Spinner");
1109
1110 let _ = r.enqueue(Toast::info(lit!("step 2")).id("upload"));
1112 let still_has_spinner = r.with_entry(h.entry_id(), |e| e.leading.is_some()).unwrap();
1113 assert!(
1114 still_has_spinner,
1115 "in-place update with no .leading(...) preserves the existing leading widget"
1116 );
1117 }
1118
1119 #[test]
1120 fn registry_in_place_update_preserves_on_dismiss_when_not_provided() {
1121 let r = fresh_registry();
1128 let (h, _) = r.enqueue(
1129 Toast::info(lit!("step 1"))
1130 .id("preserve-on-dismiss")
1131 .on_dismiss(|_cause, _ctx| {}),
1132 );
1133 assert!(
1135 r.with_entry(h.entry_id(), |e| e.on_dismiss.is_some())
1136 .unwrap(),
1137 "original entry has on_dismiss attached"
1138 );
1139
1140 let _ = r.enqueue(Toast::success(lit!("step 2")).id("preserve-on-dismiss"));
1142 assert!(
1143 r.with_entry(h.entry_id(), |e| e.on_dismiss.is_some())
1144 .unwrap(),
1145 "in-place update with no .on_dismiss(...) preserves the existing callback"
1146 );
1147
1148 let _ = r.enqueue(
1152 Toast::info(lit!("step 3"))
1153 .id("preserve-on-dismiss")
1154 .on_dismiss(|_cause, _ctx| {}),
1155 );
1156 assert!(
1157 r.with_entry(h.entry_id(), |e| e.on_dismiss.is_some())
1158 .unwrap(),
1159 "in-place update WITH new on_dismiss installs the replacement"
1160 );
1161 }
1162
1163 #[test]
1164 fn registry_in_place_update_without_id_appends_normally() {
1165 let r = fresh_registry();
1166 let _ = r.enqueue(Toast::info(lit!("a")));
1167 let _ = r.enqueue(Toast::info(lit!("b")));
1168 assert_eq!(r.live_count(), 2);
1170 }
1171
1172 #[test]
1173 fn registry_in_place_update_distinct_ids_do_not_collide() {
1174 let r = fresh_registry();
1175 let _ = r.enqueue(Toast::info(lit!("upload")).id("upload"));
1176 let _ = r.enqueue(Toast::info(lit!("download")).id("download"));
1177 assert_eq!(r.live_count(), 2);
1179 let _ = r.enqueue(Toast::success(lit!("Uploaded!")).id("upload"));
1181 assert_eq!(
1182 r.live_count(),
1183 2,
1184 "still two entries after upload-only update"
1185 );
1186 }
1187
1188 #[test]
1189 fn registry_with_id_merges_into_archive_in_place() {
1190 use crate::notification::NotificationArchiveModel;
1191 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1192 let registry =
1193 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1194 let (_a, _) = registry.enqueue(Toast::info(lit!("Uploading 1 of 7")).id("upload"));
1195 assert_eq!(archive.entries().len(), 1);
1196 let (_b, _) = registry.enqueue(Toast::info(lit!("Uploading 4 of 7")).id("upload"));
1197 assert_eq!(archive.entries().len(), 1);
1199 let merged = archive.entries().with_item(0, |e| e.clone()).unwrap();
1200 assert_eq!(merged.title, "Uploading 4 of 7");
1201 assert_eq!(merged.updates.len(), 1);
1202 }
1203
1204 #[test]
1209 fn registry_archives_the_actions_a_notice_was_updated_to() {
1210 use crate::notification::{ArchivedActionStyle, NotificationArchiveModel};
1211 use teksilo_core::styles::BannerSeverity;
1212 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1213 let registry =
1214 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1215 let (_progress, _) = registry.enqueue(
1216 Toast::loading(lit!("Importing"))
1217 .id("import")
1218 .action(ToastAction::destructive(lit!("Cancel"), |_| {})),
1219 );
1220 let (_result, _) = registry.enqueue(
1221 Toast::success(lit!("Imported"))
1222 .id("import")
1223 .action(ToastAction::primary(lit!("Open now"), |_| {}))
1224 .action(
1225 ToastAction::new(lit!("See report"), |_| {})
1226 .closes_toast(false)
1227 .shortcut_id("app.import.report"),
1228 ),
1229 );
1230 assert_eq!(archive.entries().len(), 1);
1231 let row = archive.entries().with_item(0, |e| e.clone()).unwrap();
1232 assert_eq!(row.severity, BannerSeverity::Success);
1233 let offered: Vec<_> = row
1234 .actions
1235 .iter()
1236 .map(|a| {
1237 (
1238 a.label.as_str(),
1239 a.intent_name.as_deref(),
1240 a.style,
1241 a.closes_on_invoke,
1242 )
1243 })
1244 .collect();
1245 assert_eq!(
1246 offered,
1247 vec![
1248 ("Open now", None, ArchivedActionStyle::PrimaryButton, true),
1249 (
1250 "See report",
1251 Some("app.import.report"),
1252 ArchivedActionStyle::Link,
1253 false,
1254 ),
1255 ]
1256 );
1257
1258 let (_cancelled, _) = registry.enqueue(Toast::info(lit!("Import cancelled")).id("import"));
1260 let row = archive.entries().with_item(0, |e| e.clone()).unwrap();
1261 assert!(row.actions.is_empty(), "kept {:?}", row.actions);
1262 assert_eq!(row.severity, BannerSeverity::Info);
1263 }
1264
1265 #[test]
1271 fn registry_archives_a_notice_raised_again_after_it_was_read() {
1272 use crate::notification::NotificationArchiveModel;
1273 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1274 let registry =
1275 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1276 let (first, _) = registry.enqueue(
1277 Toast::error(lit!("Sync failed"))
1278 .id("sync")
1279 .body(lit!("The server did not answer.")),
1280 );
1281 let (_other, _) = registry.enqueue(Toast::info(lit!("Saved")));
1282 let raised = archive.entries().with_item(1, |e| e.clone()).unwrap();
1283 assert_eq!(raised.dedup_id.as_deref(), Some("sync"));
1284 archive.mark_all_read();
1285 registry.dismiss_entry_deferred(first.entry_id(), ToastDismissCause::Timeout);
1286 assert_eq!(registry.live_count(), 1, "the sync notice has left");
1287
1288 let (_again, _) = registry.enqueue(
1289 Toast::error(lit!("Sync failed"))
1290 .id("sync")
1291 .body(lit!("The server did not answer.")),
1292 );
1293 assert_eq!(archive.entries().len(), 2, "still one row per id");
1294 let row = archive.entries().with_item(0, |e| e.clone()).unwrap();
1295 assert_eq!(row.dedup_id.as_deref(), Some("sync"), "back at the top");
1296 assert_eq!(row.id, raised.id, "the same row");
1297 assert!(!row.read, "unread again");
1298 assert_eq!(archive.unread_count().get(), 1);
1299 assert_eq!(row.updates.len(), 1, "the second occurrence is recorded");
1300 assert_eq!(
1301 row.timestamp, row.updates[0].timestamp,
1302 "dated when it came back"
1303 );
1304
1305 let (_tick, _) = registry.enqueue(
1307 Toast::error(lit!("Sync failed"))
1308 .id("sync")
1309 .body(lit!("The server did not answer.")),
1310 );
1311 let row = archive.entries().with_item(0, |e| e.clone()).unwrap();
1312 assert_eq!(row.updates.len(), 1, "a live repeat records nothing");
1313 }
1314
1315 #[test]
1316 fn registry_without_archive_does_not_panic() {
1317 let registry = ToastRegistry::new(host::ToastInstallOptions::default());
1320 let (_h, _) = registry.enqueue(Toast::info(lit!("no archive here")));
1321 assert!(registry.archive().is_none());
1322 }
1323
1324 #[test]
1325 fn registry_archive_intent_name_survives_on_action() {
1326 use crate::notification::{ArchivedActionStyle, NotificationArchiveModel};
1327 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1328 let registry =
1329 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1330 let (_h, _) = registry
1331 .enqueue(Toast::error(lit!("Build failed")).action(
1332 ToastAction::primary(lit!("Retry"), |_| {}).shortcut_id("app.build.retry"),
1333 ));
1334 let entry = archive.entries().with_item(0, |e| e.clone()).unwrap();
1335 assert_eq!(entry.actions.len(), 1);
1336 assert_eq!(entry.actions[0].label, "Retry");
1337 assert_eq!(
1338 entry.actions[0].intent_name.as_deref(),
1339 Some("app.build.retry")
1340 );
1341 assert_eq!(entry.actions[0].style, ArchivedActionStyle::PrimaryButton);
1342 assert!(entry.actions[0].closes_on_invoke);
1343 }
1344
1345 #[test]
1346 fn archive_flag_is_captured_on_entry() {
1347 let r = fresh_registry();
1348 let (h_noarchive, _) = r.enqueue(Toast::info(lit!("Copied!")).archive(false));
1349 let archived = r.with_entry(h_noarchive.entry_id(), |e| e.archive).unwrap();
1350 assert!(!archived);
1351
1352 let (h_archived, _) = r.enqueue(Toast::error(lit!("Build failed")));
1353 let archived = r.with_entry(h_archived.entry_id(), |e| e.archive).unwrap();
1354 assert!(archived, "archive defaults to true");
1355 }
1356
1357 #[test]
1358 fn registry_mirrors_the_resolved_route_onto_the_archived_entry() {
1359 use crate::notification::NotificationArchiveModel;
1373 let archive = std::rc::Rc::new(NotificationArchiveModel::in_memory());
1374 let registry =
1375 ToastRegistry::with_archive(host::ToastInstallOptions::default(), archive.clone());
1376
1377 let audience = ToastAudience::new(99);
1378 let (h, _) = registry.enqueue(Toast::info(lit!("scoped")).target(audience));
1379 let live_route = registry.with_entry(h.entry_id(), |e| e.route).unwrap();
1380 assert_eq!(live_route, ToastRoute::Audience(audience));
1381
1382 let archived_route = archive.entries().with_item(0, |e| e.route).unwrap();
1383 assert_eq!(
1384 archived_route,
1385 ToastRoute::Audience(audience),
1386 "the archived NotificationEntry must carry the SAME route as the live \
1387 entry it was mirrored from"
1388 );
1389 }
1390
1391 fn surface_node(
1399 severity: ToastSeverity,
1400 priority: ToastPriority,
1401 ) -> teksilo_core::accessibility::AccessNodeBuilder {
1402 use crate::toast::surface::{ToastSurface, ToastSurfaceData};
1403 use teksilo_core::accessibility::AccessNodeBuilder;
1404 use teksilo_core::widget::Widget;
1405 let data = ToastSurfaceData {
1406 entry_id: 1,
1407 severity,
1408 priority,
1409 title: teksilo_i18n::lit!("x"),
1410 body: None,
1411 announcement: None,
1412 actions: Rc::new(Vec::new()),
1413 show_close_button: false,
1414 on_click: None,
1415 style_override: None,
1416 body_state: teksilo_core::signal::Signal::new(0),
1417 };
1418 let surface = ToastSurface::new(data, None, fresh_registry(), false);
1419 let mut builder = AccessNodeBuilder::new();
1420 surface.accessibility(&mut builder);
1421 builder
1422 }
1423
1424 fn surface_role_for(
1425 severity: ToastSeverity,
1426 priority: ToastPriority,
1427 ) -> teksilo_core::accesskit::Role {
1428 surface_node(severity, priority).role()
1429 }
1430
1431 fn surface_live_for(
1432 severity: ToastSeverity,
1433 priority: ToastPriority,
1434 ) -> teksilo_core::accesskit::Live {
1435 let mut node = surface_node(severity, priority);
1436 node.inner_mut()
1437 .live()
1438 .unwrap_or(teksilo_core::accesskit::Live::Off)
1439 }
1440
1441 #[test]
1442 fn at_role_status_for_info_success_warning_normal() {
1443 use teksilo_core::accesskit::Role;
1444 assert_eq!(
1445 surface_role_for(ToastSeverity::Info, ToastPriority::Normal),
1446 Role::Status
1447 );
1448 assert_eq!(
1449 surface_role_for(ToastSeverity::Success, ToastPriority::Normal),
1450 Role::Status
1451 );
1452 assert_eq!(
1453 surface_role_for(ToastSeverity::Warning, ToastPriority::Normal),
1454 Role::Status
1455 );
1456 }
1457
1458 #[test]
1459 fn at_role_alert_for_error_and_warning_high() {
1460 use teksilo_core::accesskit::Role;
1461 assert_eq!(
1462 surface_role_for(ToastSeverity::Error, ToastPriority::Normal),
1463 Role::Alert
1464 );
1465 assert_eq!(
1466 surface_role_for(ToastSeverity::Error, ToastPriority::High),
1467 Role::Alert
1468 );
1469 assert_eq!(
1470 surface_role_for(ToastSeverity::Warning, ToastPriority::High),
1471 Role::Alert
1472 );
1473 assert_eq!(
1474 surface_role_for(ToastSeverity::Warning, ToastPriority::Urgent),
1475 Role::Alert
1476 );
1477 }
1478
1479 #[test]
1480 fn at_live_polite_for_status_assertive_for_alert() {
1481 use teksilo_core::accesskit::Live;
1482 assert_eq!(
1483 surface_live_for(ToastSeverity::Info, ToastPriority::Normal),
1484 Live::Polite
1485 );
1486 assert_eq!(
1487 surface_live_for(ToastSeverity::Error, ToastPriority::Normal),
1488 Live::Assertive
1489 );
1490 assert_eq!(
1491 surface_live_for(ToastSeverity::Warning, ToastPriority::High),
1492 Live::Assertive
1493 );
1494 }
1495
1496 #[test]
1497 fn urgent_priority_forces_assertive_regardless_of_severity() {
1498 use teksilo_core::accesskit::Live;
1499 assert_eq!(
1501 surface_live_for(ToastSeverity::Info, ToastPriority::Urgent),
1502 Live::Assertive
1503 );
1504 assert_eq!(
1505 surface_live_for(ToastSeverity::Success, ToastPriority::Urgent),
1506 Live::Assertive
1507 );
1508 }
1509
1510 use crate::primitives::TextWidget as TestLeaf; fn setup_host_tree(
1517 opts: host::ToastInstallOptions,
1518 ) -> (teksilo_core::widget_tree::WidgetTree, ToastRegistry) {
1519 use std::any::{Any, TypeId};
1520 use std::collections::HashMap;
1521
1522 let registry = ToastRegistry::new(opts.clone());
1523 let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1524 app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1525
1526 let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1527 .with_theme(teksilo_core::presets::intui::light());
1528 tree.set_app_context(Rc::new(
1529 teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1530 ));
1531 let user_root = tree.add(TestLeaf::new(lit!("user content")));
1532 let host = ToastHost::wrapping(user_root, registry.clone(), opts);
1533 tree.add(host);
1534 tree.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
1535 (tree, registry)
1536 }
1537
1538 #[test]
1539 fn host_renders_a_toast_surface_for_each_live_entry() {
1540 use std::any::{Any, TypeId};
1541 use std::collections::HashMap;
1542
1543 let opts = host::ToastInstallOptions::default();
1548 let registry = ToastRegistry::new(opts.clone());
1549 let _h = registry.enqueue(Toast::success(lit!("Saved")));
1550 assert_eq!(registry.live_count(), 1);
1551
1552 let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1553 app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1554
1555 let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1556 .with_theme(teksilo_core::presets::intui::light());
1557 tree.set_app_context(Rc::new(
1558 teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1559 ));
1560 let user_root = tree.add(TestLeaf::new(lit!("user content")));
1561 tree.add(ToastHost::wrapping(user_root, registry.clone(), opts));
1562 tree.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
1563
1564 assert!(
1565 tree.find_by_role(teksilo_core::accesskit::Role::Status)
1566 .is_some(),
1567 "Success toast renders a Role::Status surface in the host"
1568 );
1569 }
1570
1571 #[test]
1572 fn host_promotes_error_toast_to_role_alert() {
1573 use std::any::{Any, TypeId};
1574 use std::collections::HashMap;
1575 let opts = host::ToastInstallOptions::default();
1576 let registry = ToastRegistry::new(opts.clone());
1577 let _h = registry.enqueue(Toast::error(lit!("Build failed")).persistent());
1578
1579 let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1580 app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1581 let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1582 .with_theme(teksilo_core::presets::intui::light());
1583 tree.set_app_context(Rc::new(
1584 teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1585 ));
1586 let user_root = tree.add(TestLeaf::new(lit!("root")));
1587 tree.add(ToastHost::wrapping(user_root, registry.clone(), opts));
1588 tree.layout(teksilo_canvas::SizeProposal::exact(800.0, 600.0));
1589 assert!(
1590 tree.find_by_role(teksilo_core::accesskit::Role::Alert)
1591 .is_some(),
1592 "Error toast emits Role::Alert via the surface widget"
1593 );
1594 }
1595
1596 #[test]
1597 fn hover_count_flips_paused_state_observed_by_host_tick() {
1598 let r = fresh_registry();
1603 let (h, _) =
1604 r.enqueue(Toast::info(lit!("hover me")).auto_dismiss_after(Duration::from_millis(200)));
1605 r.hover_count_signal().set(1);
1607 for _ in 0..10 {
1610 let hover = r.hover_count_signal().get() > 0;
1611 r.tick_timers(Duration::from_millis(100), hover);
1612 }
1613 assert!(
1614 r.live_entry_ids().contains(&h.entry_id()),
1615 "hover-paused entry must survive past its auto-dismiss window"
1616 );
1617 r.hover_count_signal().set(0);
1619 let hover = r.hover_count_signal().get() > 0;
1620 r.tick_timers(Duration::from_millis(250), hover);
1621 assert!(
1622 !r.live_entry_ids().contains(&h.entry_id()),
1623 "after un-hover, entry expires"
1624 );
1625 }
1626
1627 #[test]
1638 fn show_toast_default_targets_the_originating_window() {
1639 use crate::button::Button;
1640 use std::any::{Any, TypeId};
1641 use std::collections::HashMap;
1642
1643 use teksilo_core::window::state::WindowStateInit;
1644 use teksilo_core::window::{TeksiloWindowId, WindowPlacement, WindowState};
1645
1646 let registry = fresh_registry();
1647 let mut app_state: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1648 app_state.insert(TypeId::of::<ToastRegistry>(), Box::new(registry.clone()));
1649
1650 let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1651 .with_theme(teksilo_core::presets::intui::light());
1652 tree.set_app_context(Rc::new(
1653 teksilo_core::event_source::TreeAppContext::empty().with_app_state(app_state),
1654 ));
1655 tree.set_window_state(WindowState::new(WindowStateInit {
1656 id: TeksiloWindowId::new(1),
1657 string_id: Some("test".to_string()),
1658 placement: WindowPlacement::Floating,
1659 title: "Test".to_string(),
1660 size: (800, 600),
1661 position: (0, 0),
1662 focused: false,
1663 resizable: true,
1664 always_on_top: false,
1665 }));
1666
1667 let btn = tree.add(Button::new(lit!("Save")).on_activate_fn(|ctx| {
1668 let _ = Toast::info(lit!("Saved")).present(ctx);
1669 }));
1670 tree.layout(teksilo_canvas::SizeProposal::exact(400.0, 300.0));
1671
1672 tree.click(btn);
1673
1674 assert_eq!(
1675 registry.live_count(),
1676 1,
1677 "the click must have enqueued a toast"
1678 );
1679 let eid = registry.live_entry_ids()[0];
1680 registry
1681 .with_entry(eid, |e| {
1682 assert_eq!(
1683 e.route,
1684 ToastRoute::Window(TeksiloWindowId::new(1)),
1685 "no explicit target + a real window at present time → origin-window route"
1686 );
1687 })
1688 .unwrap();
1689 }
1690
1691 #[test]
1692 fn target_routes_to_the_matching_audience_only() {
1693 let r = fresh_registry();
1694 let audience = ToastAudience::new(42);
1695 let (h, _) = r.enqueue(Toast::info(lit!("scoped")).target(audience));
1696 r.with_entry(h.entry_id(), |e| {
1697 assert_eq!(e.route, ToastRoute::Audience(audience));
1698 })
1699 .unwrap();
1700 }
1701
1702 #[test]
1703 fn broadcast_routes_regardless_of_window_or_audience() {
1704 let r = fresh_registry();
1705 let (h, _) = r.enqueue(Toast::warning(lit!("everyone")).broadcast());
1706 r.with_entry(h.entry_id(), |e| {
1707 assert_eq!(e.route, ToastRoute::Broadcast);
1708 })
1709 .unwrap();
1710 }
1711
1712 #[test]
1713 fn no_window_no_target_falls_back_to_broadcast() {
1714 let r = fresh_registry();
1719 let (h, _) = r.enqueue(Toast::error(lit!("no context here")));
1720 r.with_entry(h.entry_id(), |e| {
1721 assert_eq!(e.route, ToastRoute::Broadcast);
1722 })
1723 .unwrap();
1724 }
1725
1726 #[test]
1727 fn per_audience_admission_one_burst_does_not_starve_another_audience() {
1728 let r = small_registry(2);
1732 let audience_a = ToastAudience::new(1);
1733 let audience_b = ToastAudience::new(2);
1734
1735 let (a1, _) = r.enqueue(Toast::info(lit!("a1")).target(audience_a));
1736 let (a2, _) = r.enqueue(Toast::info(lit!("a2")).target(audience_a));
1737 let (a3, _) = r.enqueue(Toast::info(lit!("a3")).target(audience_a));
1740 assert!(
1741 !a3.is_alive(),
1742 "audience A's third toast overflows its own bucket"
1743 );
1744 assert!(a1.is_alive() && a2.is_alive());
1745
1746 let (b1, _) = r.enqueue(Toast::info(lit!("b1")).target(audience_b));
1748 let (b2, _) = r.enqueue(Toast::info(lit!("b2")).target(audience_b));
1749 assert!(
1750 b1.is_alive() && b2.is_alive(),
1751 "audience B's own admission must be unaffected by A's burst"
1752 );
1753 assert_eq!(
1754 r.live_count(),
1755 4,
1756 "2 live A entries + 2 live B entries — B was never starved"
1757 );
1758
1759 let (b3, _) = r.enqueue(Toast::info(lit!("b3")).target(audience_b));
1763 assert!(!b3.is_alive());
1764 assert_eq!(r.live_count(), 4);
1765 }
1766
1767 #[test]
1768 fn per_audience_high_priority_evicts_oldest_normal_within_the_same_bucket_only() {
1769 let r = small_registry(2);
1770 let audience_a = ToastAudience::new(1);
1771 let audience_b = ToastAudience::new(2);
1772
1773 let (a_old, _) = r.enqueue(Toast::info(lit!("a-old")).target(audience_a));
1774 let (a_new, _) = r.enqueue(Toast::info(lit!("a-new")).target(audience_a));
1775 let (b1, _) = r.enqueue(Toast::info(lit!("b1")).target(audience_b));
1776 let (b2, _) = r.enqueue(Toast::info(lit!("b2")).target(audience_b));
1777
1778 let (a_high, _) = r.enqueue(
1782 Toast::info(lit!("a-urgent"))
1783 .target(audience_a)
1784 .priority(ToastPriority::High),
1785 );
1786 let live_ids = r.live_entry_ids();
1787 assert!(
1788 !live_ids.contains(&a_old.entry_id()),
1789 "oldest Normal within audience A's bucket is evicted"
1790 );
1791 assert!(live_ids.contains(&a_new.entry_id()));
1792 assert!(live_ids.contains(&a_high.entry_id()));
1793 assert!(
1794 live_ids.contains(&b1.entry_id()) && live_ids.contains(&b2.entry_id()),
1795 "audience B's entries must be untouched by A's High-priority eviction"
1796 );
1797 assert_eq!(r.live_count(), 4);
1798 }
1799
1800 #[test]
1801 fn host_renders_no_surfaces_when_registry_empty() {
1802 let (tree, registry) = setup_host_tree(host::ToastInstallOptions::default());
1805 assert_eq!(registry.live_count(), 0);
1806 assert!(
1807 tree.find_by_role(teksilo_core::accesskit::Role::Status)
1808 .is_none()
1809 );
1810 assert!(
1811 tree.find_by_role(teksilo_core::accesskit::Role::Alert)
1812 .is_none()
1813 );
1814 }
1815
1816 use teksilo_canvas::Point;
1817 use teksilo_core::widget_id::WidgetId;
1818
1819 fn toast_tree(
1826 registry: &ToastRegistry,
1827 entry_id: u64,
1828 ) -> (teksilo_core::widget_tree::WidgetTree, WidgetId) {
1829 use crate::toast::surface::{ToastSurface, ToastSurfaceData};
1830
1831 let mut tree = teksilo_core::widget_tree::WidgetTree::new()
1832 .with_theme(teksilo_core::presets::intui::light());
1833 let data = ToastSurfaceData {
1834 entry_id,
1835 severity: ToastSeverity::Info,
1836 priority: teksilo_core::styles::ToastPriority::Normal,
1837 title: lit!("Saved"),
1838 body: None,
1839 announcement: None,
1840 actions: Rc::new(Vec::new()),
1841 show_close_button: false,
1842 on_click: None,
1843 style_override: None,
1844 body_state: teksilo_core::signal::Signal::new(0),
1845 };
1846 let id = tree.add(ToastSurface::new(data, None, registry.clone(), false));
1847 tree.layout(teksilo_canvas::SizeProposal::exact(600.0, 400.0));
1848 (tree, id)
1849 }
1850
1851 #[test]
1852 fn a_held_toast_does_not_time_out() {
1853 let registry = fresh_registry();
1854 let (handle, _) = registry
1855 .enqueue(Toast::info(lit!("Saved")).auto_dismiss_after(Duration::from_millis(200)));
1856 let (mut tree, surface) = toast_tree(®istry, handle.entry_id());
1857
1858 let at = {
1859 let b = tree.bounds(surface);
1860 Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5)
1861 };
1862 let f = tree.new_contact();
1863 tree.touch_down(f, at);
1864 for _ in 0..10 {
1865 registry.tick_timers(Duration::from_millis(100), false);
1866 }
1867 assert!(
1868 registry.live_entry_ids().contains(&handle.entry_id()),
1869 "a finger holding the toast holds its timer, the way a resting mouse does",
1870 );
1871
1872 tree.touch_up(f, at);
1873 registry.tick_timers(Duration::from_millis(250), false);
1874 assert!(
1875 !registry.live_entry_ids().contains(&handle.entry_id()),
1876 "and the release lets it expire",
1877 );
1878 }
1879
1880 #[test]
1885 fn a_cancelled_contact_releases_the_toast_hold() {
1886 let registry = fresh_registry();
1887 let (handle, _) = registry
1888 .enqueue(Toast::info(lit!("Saved")).auto_dismiss_after(Duration::from_millis(200)));
1889 let (mut tree, surface) = toast_tree(®istry, handle.entry_id());
1890
1891 let at = {
1892 let b = tree.bounds(surface);
1893 Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5)
1894 };
1895 let f = tree.new_contact();
1896 tree.touch_down(f, at);
1897 tree.touch_cancel(f, at);
1898 registry.tick_timers(Duration::from_millis(250), false);
1899 assert!(
1900 !registry.live_entry_ids().contains(&handle.entry_id()),
1901 "a revoked contact holds nothing",
1902 );
1903 }
1904
1905 #[test]
1906 fn a_swipe_dismisses_a_toast_and_a_mouse_drag_does_not() {
1907 use crate::toast::Toast;
1908
1909 let swipe = teksilo_tokens::GestureProfile::TOUCH.swipe_min_distance + 40.0;
1910
1911 let registry = fresh_registry();
1913 let (handle, _) = registry.enqueue(Toast::info(lit!("Saved")));
1914 let (mut tree, surface) = toast_tree(®istry, handle.entry_id());
1915 let at = {
1916 let b = tree.bounds(surface);
1917 Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5)
1918 };
1919 let f = tree.new_contact();
1920 tree.touch_down(f, at);
1921 tree.touch_move(f, Point::new(at.x + swipe * 0.5, at.y));
1922 tree.advance_input_time(Duration::from_millis(16));
1923 tree.touch_move(f, Point::new(at.x + swipe, at.y));
1924 tree.touch_up(f, Point::new(at.x + swipe, at.y));
1925 assert!(
1926 !registry.live_entry_ids().contains(&handle.entry_id()),
1927 "a horizontal swipe dismisses it — the close button needs aim, this does not",
1928 );
1929
1930 let registry = fresh_registry();
1932 let (handle, _) = registry.enqueue(Toast::info(lit!("Saved")));
1933 let (mut tree, surface) = toast_tree(®istry, handle.entry_id());
1934 let at = {
1935 let b = tree.bounds(surface);
1936 Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5)
1937 };
1938 tree.pointer_move(at);
1939 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
1940 tree.pointer_move(Point::new(at.x + swipe * 0.5, at.y));
1941 tree.advance_input_time(Duration::from_millis(16));
1942 tree.pointer_move(Point::new(at.x + swipe, at.y));
1943 tree.pointer_up_button(
1944 Point::new(at.x + swipe, at.y),
1945 teksilo_core::event::PointerButton::Primary,
1946 );
1947 assert!(
1948 registry.live_entry_ids().contains(&handle.entry_id()),
1949 "a mouse that happens to drag across a toast has not asked for anything",
1950 );
1951 }
1952}