Desktop: background store reads from the tray (no document.hidden skip, backoff), notifications by kind (rejected, approved, live, halted, crash, reviews) with switches, quiet hours and mute, click-through on next show; tray menu with apps and their states, read now, mute, updates, settings, tooltip summary and attention dot; tray_update command; tests
This commit is contained in:
parent
5d8a0f5e9a
commit
03ef999dac
27 changed files with 1050 additions and 55 deletions
122
apps/desktop/src-tauri/tests/tray.rs
Normal file
122
apps/desktop/src-tauri/tests/tray.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
//! 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<String> {
|
||||
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<u8> = 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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue