//! The tray menu panels' pure half: the tree and its stable keys, live updates, placement inside the work area, the //! panel book (transient vs pinned, parents, restore after a restart). use storebound_desktop::menu_panel::*; use storebound_desktop::store::State; use storebound_desktop::tray::{menu_model, Entry, TrayApp, TrayLabels, TraySummary}; const WORK: Rect = (0.0, 0.0, 1920.0, 1040.0); const AT: Anchor = Anchor::At { x: 0.0, y: 0.0 }; fn summary(state: &str, apps_label: &str) -> TraySummary { TraySummary { apps: vec![TrayApp { key: "k1".into(), name: "First".into(), state: state.into() }, TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() }], labels: TrayLabels { apps: apps_label.into(), ..TrayLabels::default() }, ..TraySummary::default() } } fn tree() -> Vec { from_entries(&[ Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }, Entry::Separator, Entry::Sub { key: String::new(), label: "Fleet".into(), items: vec![Entry::Sub { key: String::new(), label: "Recently lowered".into(), items: vec![] }] }, Entry::Sub { key: String::new(), label: "Profile: Work".into(), items: vec![] }, ]) } #[test] fn keys_ignore_live_parts_and_stay_unique() { assert_eq!(sub_key("Profile: Work"), "profile"); assert_eq!(sub_key("\u{25CF} First \u{00B7} ok"), "first"); assert_eq!(sub_key("Queue (1 running, 2 queued)"), "queue"); assert_eq!(sub_key("\u{2716}"), "sub"); let nodes = unique_keys(vec![Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }, Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }]); assert!(matches!(&nodes[1], Node::Sub { key, .. } if key == "a-2")); } #[test] fn find_walks_keys() { let t = tree(); let (items, label) = find(&t, &["fleet".into(), "recently-lowered".into()]).unwrap(); assert_eq!((items.len(), label), (0, Some("Recently lowered"))); assert!(find(&t, &["nope".into()]).is_none()); assert_eq!(find(&t, &[]).unwrap().0.len(), 4); } #[test] fn the_tray_tree_keeps_its_apps_key_in_every_language_and_value() { let en = from_entries(&menu_model(&summary("App Store: In review", "Apps"))); let tr = from_entries(&menu_model(&summary("App Store: Live", "Uygulamalar"))); let key = |n: &[Node]| n.iter().find_map(|n| if let Node::Sub { key, .. } = n { Some(key.clone()) } else { None }).unwrap(); assert_eq!(key(&en), "apps"); assert_eq!(key(&tr), "apps"); // a live value changed: the tree differs (so it is pushed), the open Apps panel still resolves assert_ne!(en, tr); let (items, _) = find(&tr, &["apps".into()]).unwrap(); assert!(matches!(&items[0], Node::Item { id, label, .. } if id == "tray-app:k1" && label.contains("Live"))); // the same summary twice: the same tree (nothing is pushed) assert_eq!(from_entries(&menu_model(&summary("x", "Apps"))), from_entries(&menu_model(&summary("x", "Apps")))); } #[test] fn the_tree_serializes_as_the_panel_page_reads_it() { let v = serde_json::to_value(tree()).unwrap(); assert_eq!(v[0], serde_json::json!({ "kind": "item", "id": "open", "label": "Open", "enabled": true })); assert_eq!(v[1], serde_json::json!({ "kind": "sep" })); assert_eq!(v[2]["kind"], "sub"); assert_eq!(v[2]["key"], "fleet"); } #[test] fn root_opens_away_from_the_taskbar_and_stays_inside() { // bottom taskbar, icon at the right: above and left of the click let (x, y) = place_menu(Anchor::Point { x: 1900.0, y: 1060.0 }, (300.0, 400.0), WORK); assert!(x + 300.0 <= 1920.0 - GAP && y + 400.0 <= 1040.0 - GAP && x >= 0.0 && y >= 0.0); // macOS menu bar at the top: below it let (_, y) = place_menu(Anchor::Point { x: 1800.0, y: 0.0 }, (300.0, 400.0), (0.0, 25.0, 1920.0, 1055.0)); assert!(y >= 25.0 + GAP); // a second monitor left of the primary (negative x) let (x, _) = place_menu(Anchor::Point { x: -10.0, y: 500.0 }, (300.0, 400.0), (-1920.0, 0.0, 1920.0, 1080.0)); assert!(x + 300.0 <= -GAP + 0.001 && x >= -1920.0); // a panel taller than the work area: pinned to the top edge, never above it let (_, y) = place_menu(Anchor::Point { x: 100.0, y: 100.0 }, (300.0, 2000.0), WORK); assert_eq!(y, GAP); } #[test] fn tray_root_sits_just_above_the_icon_never_over_the_taskbar() { // icon in a bottom taskbar (the icon is below the work area): bottom edge exactly GAP above the icon's top let icon = Anchor::Tray { x: 1800.0, y: 1044.0, w: 32.0, h: 32.0 }; let (_, y) = place_menu(icon, (260.0, 300.0), WORK); assert_eq!(y + 300.0, 1040.0 - GAP); // even when the reported work area wrongly includes the taskbar, the panel stays above the icon let (_, y) = place_menu(icon, (260.0, 300.0), (0.0, 0.0, 1920.0, 1080.0)); assert!(y + 300.0 <= 1044.0 - GAP); } #[test] fn submenus_open_beside_and_flip_left_at_the_edge() { let (x, _) = place_menu(Anchor::Beside { left: 1600.0, right: 1880.0, top: 100.0 }, (280.0, 200.0), WORK); assert!(x + 280.0 <= 1602.0); let (x, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 100.0 }, (280.0, 200.0), WORK); assert!((378.0..=380.0).contains(&x) && (90.0..=100.0).contains(&y)); // a row near the bottom: the submenu moves up into the work area let (_, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 1000.0 }, (280.0, 300.0), WORK); assert!(y + 300.0 <= 1040.0 - GAP); } #[test] fn pinned_panels_step_aside_and_stay_inside() { let others = [(100.0, 100.0, 280.0, 300.0), (100.0, 408.0, 280.0, 300.0)]; let (x, y) = free_spot((120.0, 120.0, 280.0, 300.0), &others, WORK); assert!(others.iter().all(|o| !overlaps((x, y, 280.0, 300.0), *o))); assert!(x >= 0.0 && y >= 0.0 && x + 280.0 <= 1920.0 && y + 300.0 <= 1040.0); // no room left below: the next column let col = [(8.0, 8.0, 280.0, 1000.0)]; let (x, _) = free_spot((8.0, 8.0, 280.0, 300.0), &col, WORK); assert!(x >= 288.0); // nothing in the way: where it was assert_eq!(free_spot((500.0, 500.0, 280.0, 300.0), &others, WORK), (500.0, 500.0)); } #[test] fn several_menus_open_and_pinned_ones_survive_a_new_menu() { let mut b = Book::default(); let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap(); assert!(b.open_sub(&root, "fleet", AT, false).is_none(), "the same submenu again is a no-op"); let (deep, _) = b.open_sub(&fleet, "recently-lowered", AT, false).unwrap(); assert_eq!(b.descendants(&root).len(), 2); // pinning the Fleet panel detaches it; the root closes, its own child stays its own let closes = b.pin(&fleet, true); assert_eq!(closes, vec![root.clone()]); assert!(b.panels[&fleet].pinned && b.panels[&fleet].parent.is_none()); assert_eq!(b.descendants(&fleet), vec![deep.clone()]); let (root2, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); assert_eq!(closes, vec![deep]); assert!(b.panels.contains_key(&fleet)); assert_eq!(b.transient(), vec![root2.clone()]); // a pick / Esc: transient panels go, the pinned one stays assert_eq!(b.dismiss(), vec![root2]); assert_eq!(b.panels.len(), 1); // unpinned, it behaves like a menu again and goes with the next one b.pin(&fleet, false); let (_, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); assert!(closes.contains(&fleet)); } #[test] fn a_torn_off_submenu_keeps_one_pinned_copy() { let mut b = Book::default(); let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); let (p1, _) = b.open_sub(&root, "fleet", AT, true).unwrap(); let (p2, closes) = b.open_sub(&root, "fleet", AT, true).unwrap(); assert!(closes.contains(&p1) && b.panels[&p2].pinned && !b.panels.contains_key(&p1)); assert_eq!(b.panels[&p2].path, vec!["fleet".to_string()]); // the root stays open (a tear-off is not a pick) assert!(b.panels.contains_key(&root)); // closing a panel that is already gone does nothing assert!(b.close("menu-99").is_empty()); } #[test] fn a_live_update_closes_nothing_that_still_exists() { let mut b = Book::default(); let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); let (profile, _) = b.open_sub(&root, "profile", AT, true).unwrap(); let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap(); assert!(b.prune(&tree()).is_empty()); // the Fleet submenu is gone from the tree: its transient panel closes, the pinned Profile panel stays even when its // submenu goes too (it shows that it is gone) let smaller = from_entries(&[Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }]); assert_eq!(b.prune(&smaller), vec![fleet]); assert!(b.panels.contains_key(&root) && b.panels.contains_key(&profile)); } #[test] fn pins_are_saved_in_the_state_and_restored_after_a_restart() { let mut b = Book::default(); let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 }); let (p, _) = b.open_sub(&root, "apps", AT, true).unwrap(); // not placed yet: nothing to save assert!(b.pins().is_empty()); b.panels.get_mut(&p).unwrap().pos = Some((40.4, 50.6)); let pins = b.pins(); assert_eq!(pins, vec![PinRecord { path: vec!["apps".into()], x: 40, y: 51 }]); // through state.json let st = State { menu_pins: pins.clone(), ..State::default() }; let text = serde_json::to_string(&st).unwrap(); assert!(text.contains("\"menuPins\"")); let back: State = serde_json::from_str(&text).unwrap(); assert_eq!(back.menu_pins, pins); // an empty list is not written at all (older files stay as they were) assert!(!serde_json::to_string(&State::default()).unwrap().contains("menuPins")); let mut fresh = Book::default(); let mut saved = back.menu_pins.clone(); saved.push(saved[0].clone()); // a duplicate is dropped let labels = fresh.restore(&saved); assert_eq!(labels.len(), 1); let r = &fresh.panels[&labels[0]]; assert!(r.pinned && r.path == vec!["apps".to_string()] && r.pos == Some((40.0, 51.0)) && r.parent.is_none()); // a restored pin survives the next menu let (_, closes) = fresh.open_root(Anchor::Point { x: 0.0, y: 0.0 }); assert!(closes.is_empty()); let many: Vec = (0..40).map(|i| PinRecord { path: vec![format!("s{i}")], x: 0, y: 0 }).collect(); assert_eq!(Book::default().restore(&many).len(), MAX_PINS); } #[test] fn panel_labels_are_menu_and_a_number() { assert!(is_menu_label("menu-1") && is_menu_label("menu-204")); for bad in ["menu-", "menu-a", "card", "main", "menu-1-2", "xmenu-1"] { assert!(!is_menu_label(bad), "{bad}"); } }