//! The tray's pure parts: menu model, tooltip, click actions, notification targets, the attention dot, settings. use storebound_desktop::tray::*; use storebound_desktop::workspace::{check_settings, valid_hhmm, Settings}; fn summary() -> TraySummary { TraySummary { tooltip: "storebound · 2 in review · 1 needs you".into(), attention: 1, muted: false, apps: vec![ TrayApp { key: "k1".into(), name: "First".into(), state: "App Store: In review · Play: Live".into() }, TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() }, ], labels: TrayLabels::default(), } } fn ids(m: &[Entry]) -> Vec { m.iter().filter_map(|e| if let Entry::Item { id, .. } = e { Some(id.clone()) } else { None }).collect() } #[test] fn menu_has_every_entry_in_order() { let m = menu_model(&summary()); assert_eq!(ids(&m), vec![ID_OPEN, ID_READ, ID_MUTE, ID_UPDATE, ID_SETTINGS, ID_QUIT]); let Entry::Sub { label, items } = &m[3] else { panic!("the Apps submenu comes after the first separator") }; assert_eq!(label, "Apps"); assert_eq!(items.len(), 2); let Entry::Item { id, label, enabled } = &items[0] else { panic!() }; assert_eq!((id.as_str(), *enabled), ("tray-app:k1", true)); assert!(label.contains("First") && label.contains("In review"), "{label}"); let Entry::Item { label, .. } = &items[1] else { panic!() }; assert_eq!(label, "Second", "an app without a state shows its name only"); } #[test] fn mute_entry_follows_the_state_and_empty_apps_say_so() { let mut s = summary(); s.muted = true; s.apps.clear(); let m = menu_model(&s); let Entry::Item { label, .. } = m.iter().find(|e| matches!(e, Entry::Item { id, .. } if id == ID_MUTE)).unwrap() else { panic!() }; assert_eq!(label, "Unmute notifications"); let Entry::Sub { items, .. } = &m[3] else { panic!() }; assert!(matches!(&items[0], Entry::Item { enabled: false, label, .. } if label == "No apps yet")); } #[test] fn tooltip_is_one_short_line() { assert_eq!(tooltip_text(&summary()), "storebound · 2 in review · 1 needs you"); let mut s = summary(); s.tooltip = "a\nb\t c".into(); assert_eq!(tooltip_text(&s), "a b c"); s.tooltip = "x".repeat(500); assert_eq!(tooltip_text(&s).chars().count(), MAX_TIP); s.tooltip = " ".into(); assert_eq!(tooltip_text(&s), "storebound"); } #[test] fn clicks_map_to_actions() { assert_eq!(action_of(ID_OPEN), Some(Action::Open)); assert_eq!(action_of(ID_QUIT), Some(Action::Quit)); assert_eq!(action_of(ID_READ), Some(Action::Event("read"))); assert_eq!(action_of(ID_MUTE), Some(Action::Event("mute-toggle"))); assert_eq!(action_of("tray-app:k1"), Some(Action::Route(r#"{"appId":"k1","screen":"app","tab":"releases"}"#.into()))); assert_eq!(action_of("tray-app:"), None); assert_eq!(action_of("other"), None); let Some(Action::Route(r)) = action_of(ID_UPDATE) else { panic!() }; assert!(valid_route(&r) && r.contains("updates")); } #[test] fn only_known_small_routes_are_remembered() { assert!(valid_route(r#"{"screen":"app","appId":"k1","tab":"releases"}"#)); assert!(valid_route(r#"{"screen":"portfolio"}"#)); assert!(!valid_route(r#"{"screen":"evil"}"#)); assert!(!valid_route(r#"{"screen":"app","appId":5}"#)); assert!(!valid_route("[]")); assert!(!valid_route("not json")); assert!(!valid_route(&format!(r#"{{"screen":"app","appId":"{}"}}"#, "a".repeat(500)))); // handed out once set_pending(r#"{"screen":"portfolio"}"#); assert_eq!(take_pending().as_deref(), Some(r#"{"screen":"portfolio"}"#)); assert_eq!(take_pending(), None); set_pending(r#"{"screen":"evil"}"#); assert_eq!(take_pending(), None); } #[test] fn attention_dot_changes_only_the_corner() { let (w, h) = (32u32, 32u32); let base: Vec = std::iter::repeat([10u8, 20, 30, 255]).take((w * h) as usize).flatten().collect(); let out = with_attention_dot(&base, w, h); assert_eq!(out.len(), base.len()); let at = |v: &[u8], x: u32, y: u32| -> [u8; 4] { let i = ((y * w + x) * 4) as usize; [v[i], v[i + 1], v[i + 2], v[i + 3]] }; assert_eq!(at(&out, 2, 30), [10, 20, 30, 255], "the bottom left stays"); let dot = at(&out, 25, 6); assert!(dot[0] > 200 && dot[1] < 90 && dot[2] < 90, "red in the middle of the dot: {dot:?}"); // transparent icon: the dot is opaque, the rest stays clear let clear = vec![0u8; (w * h * 4) as usize]; let out = with_attention_dot(&clear, w, h); assert_eq!(at(&out, 2, 30)[3], 0); assert_eq!(at(&out, 25, 6)[3], 255); // a wrong buffer size is returned unchanged assert_eq!(with_attention_dot(&[1, 2, 3], w, h), vec![1, 2, 3]); } #[test] fn quiet_hours_settings_are_checked_and_default_off() { let d = Settings::default(); assert!(!d.notify.quiet_hours.enabled); assert_eq!((d.notify.quiet_hours.from.as_str(), d.notify.quiet_hours.to.as_str()), ("23:00", "08:00")); assert!(d.notify.rejected && d.notify.approved && d.notify.live); assert!(valid_hhmm("00:00") && valid_hhmm("23:59")); assert!(!valid_hhmm("24:00") && !valid_hhmm("7:00") && !valid_hhmm("12:60") && !valid_hhmm("ab:cd")); let mut s = d.clone(); s.notify.quiet_hours.from = "25:00".into(); assert!(check_settings(&s).is_err()); let old: Settings = serde_json::from_value(serde_json::json!({ "notify": { "crash": false } })).unwrap(); assert!(!old.notify.crash && old.notify.rejected && !old.notify.quiet_hours.enabled, "an older file gets the new defaults"); }