From d85190664f6a7c8f8297e1f0fad6452d99bee44f Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:30:45 -0500 Subject: [PATCH 01/27] rename notification list modules Move list and row modules to shorter names now that they already live under ui/list, and update the local imports to match the new layout. --- .../src/ui/list/{list_blocks.rs => blocks.rs} | 33 ++++--- crates/unixnotis-center/src/ui/list/build.rs | 32 ++++--- .../ui/list/{list_grouping.rs => grouping.rs} | 0 .../src/ui/list/{list_index.rs => index.rs} | 4 + .../src/ui/list/{list_item.rs => item.rs} | 4 + .../list/{list_lifecycle.rs => lifecycle.rs} | 76 ++++++++------- .../ui/list/list_row/notification/tests.rs | 92 ------------------ crates/unixnotis-center/src/ui/list/mod.rs | 23 +++-- .../ui/list/{list_mutation.rs => mutation.rs} | 20 ++-- .../src/ui/list/{list_row => row}/empty.rs | 4 + .../src/ui/list/{list_row => row}/group.rs | 6 +- .../src/ui/list/{list_row => row}/mod.rs | 0 .../{list_row => row}/notification/build.rs | 0 .../{list_row => row}/notification/mod.rs | 18 +++- .../{list_row => row}/notification/state.rs | 0 .../{list_row => row}/notification/update.rs | 7 +- crates/unixnotis-center/src/ui/list/types.rs | 6 +- .../src/ui/list/{list_update.rs => update.rs} | 96 ++++++++++++++----- .../ui/list/{list_widgets.rs => widgets.rs} | 27 +++--- 19 files changed, 236 insertions(+), 212 deletions(-) rename crates/unixnotis-center/src/ui/list/{list_blocks.rs => blocks.rs} (90%) rename crates/unixnotis-center/src/ui/list/{list_grouping.rs => grouping.rs} (100%) rename crates/unixnotis-center/src/ui/list/{list_index.rs => index.rs} (98%) rename crates/unixnotis-center/src/ui/list/{list_item.rs => item.rs} (98%) rename crates/unixnotis-center/src/ui/list/{list_lifecycle.rs => lifecycle.rs} (68%) delete mode 100644 crates/unixnotis-center/src/ui/list/list_row/notification/tests.rs rename crates/unixnotis-center/src/ui/list/{list_mutation.rs => mutation.rs} (93%) rename crates/unixnotis-center/src/ui/list/{list_row => row}/empty.rs (96%) rename crates/unixnotis-center/src/ui/list/{list_row => row}/group.rs (98%) rename crates/unixnotis-center/src/ui/list/{list_row => row}/mod.rs (100%) rename crates/unixnotis-center/src/ui/list/{list_row => row}/notification/build.rs (100%) rename crates/unixnotis-center/src/ui/list/{list_row => row}/notification/mod.rs (61%) rename crates/unixnotis-center/src/ui/list/{list_row => row}/notification/state.rs (100%) rename crates/unixnotis-center/src/ui/list/{list_row => row}/notification/update.rs (98%) rename crates/unixnotis-center/src/ui/list/{list_update.rs => update.rs} (80%) rename crates/unixnotis-center/src/ui/list/{list_widgets.rs => widgets.rs} (86%) diff --git a/crates/unixnotis-center/src/ui/list/list_blocks.rs b/crates/unixnotis-center/src/ui/list/blocks.rs similarity index 90% rename from crates/unixnotis-center/src/ui/list/list_blocks.rs rename to crates/unixnotis-center/src/ui/list/blocks.rs index 2bbb02f7..ab246481 100644 --- a/crates/unixnotis-center/src/ui/list/list_blocks.rs +++ b/crates/unixnotis-center/src/ui/list/blocks.rs @@ -5,7 +5,7 @@ use std::rc::Rc; use gtk::glib; use gtk::glib::object::Cast; -use super::list_item::{RowData, RowPresentation}; +use super::item::{RowData, RowPresentation}; use super::types::{NotificationList, RowKey}; use super::RowItem; @@ -142,19 +142,24 @@ fn collapsed_stack_depth(count: usize, expanded: bool) -> u8 { pub(super) fn common_prefix_suffix(current: &[RowKey], next: &[RowKey]) -> (usize, usize) { // Compute shared prefix/suffix so list-store splices only touch the minimal changed window. - let mut prefix = 0; - let min_len = current.len().min(next.len()); - while prefix < min_len && current[prefix] == next[prefix] { - prefix += 1; - } - - let mut suffix = 0; - while suffix < current.len().saturating_sub(prefix) - && suffix < next.len().saturating_sub(prefix) - && current[current.len() - 1 - suffix] == next[next.len() - 1 - suffix] - { - suffix += 1; - } + let prefix = current + .iter() + .zip(next.iter()) + .take_while(|(left, right)| left == right) + .count(); + + let suffix_limit = current.len().min(next.len()).saturating_sub(prefix); + let suffix = current + .iter() + .rev() + .zip(next.iter().rev()) + .take(suffix_limit) + .take_while(|(left, right)| left == right) + .count(); (prefix, suffix) } + +#[cfg(test)] +#[path = "tests/blocks.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/list/build.rs b/crates/unixnotis-center/src/ui/list/build.rs index 36d1cd99..028389e0 100644 --- a/crates/unixnotis-center/src/ui/list/build.rs +++ b/crates/unixnotis-center/src/ui/list/build.rs @@ -13,12 +13,10 @@ use tokio::sync::mpsc; use crate::dbus::{UiCommand, UiEvent}; use super::super::icons::IconResolver; -use super::list_item::RowKind; -use super::list_row::empty::{build_empty_row, update_empty_row}; -use super::list_widgets::{ - bind_row, ensure_row_widgets, get_row_widgets, set_row_widgets, RowWidgets, -}; +use super::item::RowKind; +use super::row::empty::{build_empty_row, update_empty_row}; use super::types::{NotificationList, NotificationListConfig}; +use super::widgets::{bind_row, ensure_row_widgets, get_row_widgets, set_row_widgets, RowWidgets}; impl NotificationList { pub fn new( @@ -28,7 +26,7 @@ impl NotificationList { icon_resolver: Rc, config: NotificationListConfig, ) -> Self { - let store = gio::ListStore::new::(); + let store = gio::ListStore::new::(); let selection = gtk::NoSelection::new(Some(store.clone())); let factory = gtk::SignalListItemFactory::new(); @@ -55,35 +53,35 @@ impl NotificationList { let command_tx_clone = command_tx.clone(); let event_tx_clone = event_tx.clone(); - factory.connect_setup(move |_, list_item| { + factory.connect_setup(move |_, gtk_item| { let widgets = RowWidgets::new( RowKind::Notification, command_tx_clone.clone(), event_tx_clone.clone(), ); - set_row_widgets(list_item, Rc::new(widgets)); + set_row_widgets(gtk_item, Rc::new(widgets)); }); let command_tx_clone = command_tx.clone(); let event_tx_clone = event_tx.clone(); let icon_resolver_clone = icon_resolver.clone(); - factory.connect_bind(move |_, list_item| { - let Some(item) = list_item.item().and_downcast::() else { + factory.connect_bind(move |_, gtk_item| { + let Some(row_item) = gtk_item.item().and_downcast::() else { return; }; - let data = item.data(); + let data = row_item.data(); let widgets = ensure_row_widgets( - list_item, + gtk_item, data.kind, command_tx_clone.clone(), event_tx_clone.clone(), ); - bind_row(widgets, &item, &data, icon_resolver_clone.clone()); + bind_row(widgets, &row_item, &data, icon_resolver_clone.clone()); }); - factory.connect_unbind(move |_, list_item| { - if let Some(widgets) = get_row_widgets(list_item) { + factory.connect_unbind(move |_, gtk_item| { + if let Some(widgets) = get_row_widgets(gtk_item) { widgets.unbind(); } // Keep RowWidgets attached so GTK can recycle rows without rebuilding @@ -166,3 +164,7 @@ impl NotificationList { } } } + +#[cfg(test)] +#[path = "tests/build.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/list/list_grouping.rs b/crates/unixnotis-center/src/ui/list/grouping.rs similarity index 100% rename from crates/unixnotis-center/src/ui/list/list_grouping.rs rename to crates/unixnotis-center/src/ui/list/grouping.rs diff --git a/crates/unixnotis-center/src/ui/list/list_index.rs b/crates/unixnotis-center/src/ui/list/index.rs similarity index 98% rename from crates/unixnotis-center/src/ui/list/list_index.rs rename to crates/unixnotis-center/src/ui/list/index.rs index 73c1f7e5..2ca6c918 100644 --- a/crates/unixnotis-center/src/ui/list/list_index.rs +++ b/crates/unixnotis-center/src/ui/list/index.rs @@ -140,3 +140,7 @@ fn remove_from_group_bucket(map: &mut HashMap, VecDeque>, key: &Rc< } } } + +#[cfg(test)] +#[path = "tests/index.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/list/list_item.rs b/crates/unixnotis-center/src/ui/list/item.rs similarity index 98% rename from crates/unixnotis-center/src/ui/list/list_item.rs rename to crates/unixnotis-center/src/ui/list/item.rs index c96abd2a..c0fa0865 100644 --- a/crates/unixnotis-center/src/ui/list/list_item.rs +++ b/crates/unixnotis-center/src/ui/list/item.rs @@ -176,3 +176,7 @@ impl RowItem { self.imp().data.borrow().clone() } } + +#[cfg(test)] +#[path = "tests/item.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/list/list_lifecycle.rs b/crates/unixnotis-center/src/ui/list/lifecycle.rs similarity index 68% rename from crates/unixnotis-center/src/ui/list/list_lifecycle.rs rename to crates/unixnotis-center/src/ui/list/lifecycle.rs index 34c2248b..4b14817a 100644 --- a/crates/unixnotis-center/src/ui/list/list_lifecycle.rs +++ b/crates/unixnotis-center/src/ui/list/lifecycle.rs @@ -2,13 +2,14 @@ //! //! These helpers own the base storage lifecycle so mutation code can stay focused on updates +use std::collections::{HashMap, VecDeque}; use std::rc::Rc; use std::time::{SystemTime, UNIX_EPOCH}; use tracing::debug; use unixnotis_core::NotificationView; -use super::list_item::{RowData, RowItem, RowPresentation}; +use super::item::{RowData, RowItem, RowPresentation}; use super::types::{NotificationEntry, NotificationList}; impl NotificationList { @@ -34,6 +35,7 @@ impl NotificationList { self.entries.clear(); self.active_order.clear(); self.history_order.clear(); + clear_seed_group_expansion(&mut self.group_expanded); self.group_headers.clear(); self.group_order.clear(); self.group_order_scratch.clear(); @@ -62,43 +64,19 @@ impl NotificationList { } pub(super) fn trim_to_limits(&mut self) { - if self.max_active == 0 { - // Zero active capacity behaves like hard-drop for active notifications - let drained: Vec = self.active_order.drain(..).collect(); - for id in drained { - if let Some(entry) = self.entries.remove(&id) { - self.index_remove(&entry.app_key, id, entry.is_active); - self.dirty_groups.insert(entry.app_key); - } - } - } else { - while self.active_order.len() > self.max_active { - if let Some(id) = self.active_order.pop_back() { - if let Some(entry) = self.entries.remove(&id) { - self.index_remove(&entry.app_key, id, entry.is_active); - self.dirty_groups.insert(entry.app_key); - } - } + // Active order is newest-first, so trimming from the back drops oldest rows + for id in drain_order_over_limit(&mut self.active_order, self.max_active) { + if let Some(entry) = self.entries.remove(&id) { + self.index_remove(&entry.app_key, id, entry.is_active); + self.dirty_groups.insert(entry.app_key); } } - if self.max_entries == 0 { - // Zero history capacity keeps history storage fully disabled - let drained: Vec = self.history_order.drain(..).collect(); - for id in drained { - if let Some(entry) = self.entries.remove(&id) { - self.index_remove(&entry.app_key, id, entry.is_active); - self.dirty_groups.insert(entry.app_key); - } - } - } else { - while self.history_order.len() > self.max_entries { - if let Some(id) = self.history_order.pop_back() { - if let Some(entry) = self.entries.remove(&id) { - self.index_remove(&entry.app_key, id, entry.is_active); - self.dirty_groups.insert(entry.app_key); - } - } + // History uses the same newest-first order and capacity rule as active rows + for id in drain_order_over_limit(&mut self.history_order, self.max_entries) { + if let Some(entry) = self.entries.remove(&id) { + self.index_remove(&entry.app_key, id, entry.is_active); + self.dirty_groups.insert(entry.app_key); } } } @@ -160,3 +138,31 @@ fn now_millis() -> i64 { .and_then(|duration| i64::try_from(duration.as_millis()).ok()) .unwrap_or(0) } + +fn clear_seed_group_expansion(group_expanded: &mut HashMap, bool>) { + group_expanded.clear(); +} + +fn drain_order_over_limit(order: &mut VecDeque, max_entries: usize) -> Vec { + if max_entries == 0 { + // A zero limit means the section is disabled, so every id must leave storage + return order.drain(..).collect(); + } + if order.len() <= max_entries { + return Vec::new(); + } + + let remove_count = order.len() - max_entries; + let mut drained = Vec::with_capacity(remove_count); + for _ in 0..remove_count { + // Orders store newest at the front, so the back is always the oldest id + if let Some(id) = order.pop_back() { + drained.push(id); + } + } + drained +} + +#[cfg(test)] +#[path = "tests/lifecycle.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/list/list_row/notification/tests.rs b/crates/unixnotis-center/src/ui/list/list_row/notification/tests.rs deleted file mode 100644 index 8d58a61a..00000000 --- a/crates/unixnotis-center/src/ui/list/list_row/notification/tests.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Small notification-row tests that do not need full GTK setup -//! -//! Keeping these beside the row module makes the text rules easier to maintain - -use super::state::MAX_SUMMARY_LABEL_CHARS; -use unixnotis_core::{NotificationImage, NotificationView, Urgency}; - -use super::update::{ - clamp_action_label_text, notification_has_thumbnail, notification_meta_label, - optional_label_state, relative_time_badge, -}; - -#[test] -fn panel_summary_row_hides_when_text_is_empty() { - // Empty summaries should not leave a blank strip above the body - let state = optional_label_state("", MAX_SUMMARY_LABEL_CHARS); - - assert!(!state.visible); - assert!(state.text.is_empty()); -} - -#[test] -fn panel_summary_row_hides_when_text_is_only_whitespace() { - // Space-only payloads should collapse the same as truly empty payloads - let state = optional_label_state("\n\t ", MAX_SUMMARY_LABEL_CHARS); - - assert!(!state.visible); - assert!(state.text.is_empty()); -} - -#[test] -fn panel_summary_row_shows_when_text_has_real_content() { - // Leading and trailing space should not hide actual notification text - let state = optional_label_state(" hello ", MAX_SUMMARY_LABEL_CHARS); - - assert!(state.visible); - assert_eq!(state.text.as_ref(), " hello "); -} - -#[test] -fn panel_summary_row_hides_when_clamp_intentionally_blanks_text() { - // Zero-char clamps should collapse the row instead of leaving an empty label - let state = optional_label_state("hello", 0); - - assert!(!state.visible); - assert!(state.text.is_empty()); -} - -#[test] -fn panel_action_labels_are_clamped_before_button_build() { - // Long labels should be shortened before the action row sees them - let long_label = "This action label is much longer than the row should allow"; - let rendered = clamp_action_label_text(long_label); - - assert!(rendered.len() < long_label.len()); - assert!(rendered.ends_with('…')); -} - -#[test] -fn notification_metadata_falls_back_to_urgency_label() { - let mut notification = sample_notification(); - notification.urgency = Urgency::Critical as u8; - - assert_eq!(notification_meta_label(¬ification), "ALERT"); -} - -#[test] -fn notification_thumbnail_only_uses_real_image_sources() { - let mut notification = sample_notification(); - assert!(!notification_has_thumbnail(¬ification)); - - notification.image.image_path = "/tmp/demo.png".to_string(); - assert!(notification_has_thumbnail(¬ification)); -} - -#[test] -fn empty_timestamp_hides_relative_time_badge() { - assert!(relative_time_badge(0).is_empty()); -} - -fn sample_notification() -> NotificationView { - NotificationView { - id: 1, - app_name: "demo".to_string(), - summary: "summary".to_string(), - body: "body".to_string(), - actions: Vec::new(), - urgency: Urgency::Normal as u8, - is_transient: false, - image: NotificationImage::default(), - } -} diff --git a/crates/unixnotis-center/src/ui/list/mod.rs b/crates/unixnotis-center/src/ui/list/mod.rs index 038e22c1..88cb56f1 100644 --- a/crates/unixnotis-center/src/ui/list/mod.rs +++ b/crates/unixnotis-center/src/ui/list/mod.rs @@ -2,18 +2,21 @@ //! //! The folder root stays focused on module wiring and the public list surface +mod blocks; mod build; -mod list_blocks; -mod list_grouping; -mod list_index; -mod list_item; -mod list_lifecycle; -mod list_mutation; -mod list_row; -mod list_update; -mod list_widgets; +mod grouping; +mod index; +mod item; +mod lifecycle; +mod mutation; +mod row; +#[cfg(test)] +#[path = "tests/support.rs"] +pub(super) mod test_support; mod types; +mod update; +mod widgets; pub use self::types::{NotificationList, NotificationListConfig}; -pub(super) use self::list_item::RowItem; +pub(super) use self::item::RowItem; diff --git a/crates/unixnotis-center/src/ui/list/list_mutation.rs b/crates/unixnotis-center/src/ui/list/mutation.rs similarity index 93% rename from crates/unixnotis-center/src/ui/list/list_mutation.rs rename to crates/unixnotis-center/src/ui/list/mutation.rs index 70946241..d50c629b 100644 --- a/crates/unixnotis-center/src/ui/list/list_mutation.rs +++ b/crates/unixnotis-center/src/ui/list/mutation.rs @@ -44,9 +44,9 @@ impl NotificationList { } let mut ordering_changed = false; - if is_active { + if existing && is_active { // Reorder only when the notification is not already at the front - if was_in_history || !was_in_active || !was_front { + if should_move_active_to_front(was_in_history, was_in_active, was_front) { self.history_order.retain(|entry| *entry != id); self.active_order.retain(|entry| *entry != id); self.active_order.push_front(id); @@ -79,19 +79,19 @@ impl NotificationList { .copied() .unwrap_or(false); let group_len = self.grouped_cache.get(&entry.app_key).map_or(0, Vec::len); - let stacked = !expanded && group_len > 1; + let stacked = collapsed_group_is_stacked(expanded, group_len); let stack_depth = if expanded { 0 } else { group_len.saturating_sub(1).min(2) as u8 }; - let presentation = super::list_item::RowPresentation { + let presentation = super::item::RowPresentation { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, }; // Update the row object in-place when the visible span stays identical - entry.item.update(super::list_item::RowData::notification( + entry.item.update(super::item::RowData::notification( entry.app_key.clone(), entry.view.clone(), stacked, @@ -109,7 +109,7 @@ impl NotificationList { .unwrap_or(false); if let Some(header) = self.group_headers.get(&entry.app_key) { // Refresh the group header count and sample notification - header.update(super::list_item::RowData::group_header( + header.update(super::item::RowData::group_header( entry.app_key.clone(), ids.len(), expanded, @@ -235,6 +235,14 @@ impl NotificationList { } } +fn should_move_active_to_front(was_in_history: bool, was_in_active: bool, was_front: bool) -> bool { + was_in_history || !was_in_active || !was_front +} + +fn collapsed_group_is_stacked(expanded: bool, group_len: usize) -> bool { + !expanded && group_len > 1 +} + fn should_archive_entry( notification: &NotificationView, reason: CloseReason, diff --git a/crates/unixnotis-center/src/ui/list/list_row/empty.rs b/crates/unixnotis-center/src/ui/list/row/empty.rs similarity index 96% rename from crates/unixnotis-center/src/ui/list/list_row/empty.rs rename to crates/unixnotis-center/src/ui/list/row/empty.rs index 058cfd92..9ca0eb1d 100644 --- a/crates/unixnotis-center/src/ui/list/list_row/empty.rs +++ b/crates/unixnotis-center/src/ui/list/row/empty.rs @@ -38,3 +38,7 @@ pub(in crate::ui::list) fn update_empty_row(root: >k::Box, text: &str) { } } } + +#[cfg(test)] +#[path = "tests/empty.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/list/list_row/group.rs b/crates/unixnotis-center/src/ui/list/row/group.rs similarity index 98% rename from crates/unixnotis-center/src/ui/list/list_row/group.rs rename to crates/unixnotis-center/src/ui/list/row/group.rs index 686ca928..4a932257 100644 --- a/crates/unixnotis-center/src/ui/list/list_row/group.rs +++ b/crates/unixnotis-center/src/ui/list/row/group.rs @@ -14,7 +14,7 @@ use unixnotis_core::{css::hooks, util}; use crate::dbus::UiEvent; use super::super::super::icons::IconResolver; -use super::super::list_item::RowData; +use super::super::item::RowData; pub(in crate::ui::list) struct GroupRowWidgets { pub(super) icon: gtk::Image, @@ -182,3 +182,7 @@ fn set_class_state(widget: >k::Box, class_name: &str, enabled: bool) { widget.remove_css_class(class_name); } } + +#[cfg(test)] +#[path = "tests/group.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/list/list_row/mod.rs b/crates/unixnotis-center/src/ui/list/row/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/list/list_row/mod.rs rename to crates/unixnotis-center/src/ui/list/row/mod.rs diff --git a/crates/unixnotis-center/src/ui/list/list_row/notification/build.rs b/crates/unixnotis-center/src/ui/list/row/notification/build.rs similarity index 100% rename from crates/unixnotis-center/src/ui/list/list_row/notification/build.rs rename to crates/unixnotis-center/src/ui/list/row/notification/build.rs diff --git a/crates/unixnotis-center/src/ui/list/list_row/notification/mod.rs b/crates/unixnotis-center/src/ui/list/row/notification/mod.rs similarity index 61% rename from crates/unixnotis-center/src/ui/list/list_row/notification/mod.rs rename to crates/unixnotis-center/src/ui/list/row/notification/mod.rs index 225c6904..307a428d 100644 --- a/crates/unixnotis-center/src/ui/list/list_row/notification/mod.rs +++ b/crates/unixnotis-center/src/ui/list/row/notification/mod.rs @@ -3,10 +3,26 @@ //! `mod.rs` only wires the notification row pieces together //! Build, state, update, and tests stay in their own files +#[cfg(test)] +#[path = "tests/actions.rs"] +mod actions_tests; mod build; +#[cfg(test)] +#[path = "tests/labels.rs"] +mod labels_tests; +#[cfg(test)] +#[path = "tests/metadata.rs"] +mod metadata_tests; mod state; #[cfg(test)] -mod tests; +#[path = "tests/state.rs"] +mod state_tests; +#[cfg(test)] +#[path = "tests/support.rs"] +mod test_support; +#[cfg(test)] +#[path = "tests/thumbnail.rs"] +mod thumbnail_tests; mod update; // The list factory only needs the stable notification-row entry points diff --git a/crates/unixnotis-center/src/ui/list/list_row/notification/state.rs b/crates/unixnotis-center/src/ui/list/row/notification/state.rs similarity index 100% rename from crates/unixnotis-center/src/ui/list/list_row/notification/state.rs rename to crates/unixnotis-center/src/ui/list/row/notification/state.rs diff --git a/crates/unixnotis-center/src/ui/list/list_row/notification/update.rs b/crates/unixnotis-center/src/ui/list/row/notification/update.rs similarity index 98% rename from crates/unixnotis-center/src/ui/list/list_row/notification/update.rs rename to crates/unixnotis-center/src/ui/list/row/notification/update.rs index 11bee28f..bdecaeaf 100644 --- a/crates/unixnotis-center/src/ui/list/list_row/notification/update.rs +++ b/crates/unixnotis-center/src/ui/list/row/notification/update.rs @@ -17,7 +17,7 @@ use crate::ui::icons::IconResolver; use crate::ui::input_guard::ClickCooldown; use crate::ui::try_send_command; -use super::super::super::list_item::RowData; +use super::super::super::item::RowData; use super::state::{ IconSignature, NotificationRowWidgets, OptionalLabelState, MAX_ACTION_LABEL_CHARS, MAX_BODY_LABEL_CHARS, MAX_SUMMARY_LABEL_CHARS, @@ -109,8 +109,9 @@ pub(in crate::ui::list) fn update_notification_row( icon_resolver.apply_icon(&row.icon, notification, 22, scale); *sig_guard = Some(next_sig); } - if has_thumbnail && (signature_changed || !row.thumbnail.get_visible()) { - // Thumbnail visibility can change from config reload without changing the app icon source + if has_thumbnail { + // The icon cache handles repeat thumbnail lookups cheaply + // Reapply while visible so config reloads cannot leave a stale preview let scale = card.scale_factor(); icon_resolver.apply_icon(&row.thumbnail, notification, 56, scale); } diff --git a/crates/unixnotis-center/src/ui/list/types.rs b/crates/unixnotis-center/src/ui/list/types.rs index 29ec9c09..f23d497f 100644 --- a/crates/unixnotis-center/src/ui/list/types.rs +++ b/crates/unixnotis-center/src/ui/list/types.rs @@ -8,7 +8,7 @@ use std::rc::Rc; use gtk::glib; use unixnotis_core::NotificationView; -use super::list_item::RowItem; +use super::item::RowItem; /// Maintains notification data and renders grouped widgets into the panel list. pub struct NotificationList { @@ -88,3 +88,7 @@ pub(super) enum RowKey { GroupHeader { group: Rc }, Notification { id: u32 }, } + +#[cfg(test)] +#[path = "tests/types.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/list/list_update.rs b/crates/unixnotis-center/src/ui/list/update.rs similarity index 80% rename from crates/unixnotis-center/src/ui/list/list_update.rs rename to crates/unixnotis-center/src/ui/list/update.rs index d975b349..a7f50daf 100644 --- a/crates/unixnotis-center/src/ui/list/list_update.rs +++ b/crates/unixnotis-center/src/ui/list/update.rs @@ -3,6 +3,7 @@ //! Keeps list-store mutation logic separate from data mutation methods. use std::collections::{HashMap, HashSet}; +use std::ops::Not; use std::rc::Rc; use gio::prelude::ListModelExt; @@ -11,7 +12,7 @@ use gtk::glib::object::Cast; use gtk::prelude::WidgetExt; use tracing::debug; -use super::list_blocks; +use super::blocks; use super::types::{GroupRange, NotificationList, RowKey}; use super::RowItem; @@ -21,7 +22,7 @@ impl NotificationList { return; } self.needs_rebuild = false; - if self.store.n_items() == 0 || self.group_ranges.is_empty() { + if should_rebuild_from_scratch(self.store.n_items(), self.group_ranges.len()) { self.rebuild_list(); return; } @@ -70,8 +71,21 @@ impl NotificationList { } let mut current_keys = std::mem::take(&mut self.current_keys); - let (prefix, suffix) = list_blocks::common_prefix_suffix(¤t_keys, &keys); - let current_mid = current_keys.len().saturating_sub(prefix + suffix); + let store_len = self.store.n_items() as usize; + let keys_match_store = store_len == current_keys.len(); + // If the GTK store length drifted, cached keys can no longer define a safe splice window + // Replace the visible store from scratch while keeping the logical keys authoritative + let (prefix, suffix) = if keys_match_store { + blocks::common_prefix_suffix(¤t_keys, &keys) + } else { + (0, 0) + }; + let current_len = if keys_match_store { + current_keys.len() + } else { + store_len + }; + let current_mid = current_len.saturating_sub(prefix + suffix); let next_mid = keys.len().saturating_sub(prefix + suffix); if current_mid != 0 || next_mid != 0 { let mut objects = std::mem::take(&mut self.objects_scratch); @@ -102,7 +116,7 @@ impl NotificationList { self.group_order_scratch = old_group_order; self.group_ranges = group_ranges; // Prune interned keys that are no longer referenced by any list state. - self.interned.retain(|key| Rc::strong_count(key) > 1); + self.interned.retain(intern_key_is_live); self.dirty_groups.clear(); self.update_empty_overlay(); @@ -141,7 +155,7 @@ impl NotificationList { continue; } let desired_len = self.group_block_len(key, visible_ids.as_ref()); - if !self.dirty_groups.contains(key) && range.len == desired_len { + if should_keep_group(&self.dirty_groups, key, range.len, desired_len) { // Stable groups with identical span lengths are kept in place. keep_groups.insert(key.clone()); } else { @@ -151,18 +165,7 @@ impl NotificationList { } } - remove_ranges.sort_by_key(|range| range.start); - let mut merged: Vec = Vec::new(); - for range in remove_ranges { - if let Some(last) = merged.last_mut() { - if last.start + last.len == range.start { - // Adjacent removals are merged to reduce ListStore splice calls. - last.len += range.len; - continue; - } - } - merged.push(range); - } + let merged = merge_adjacent_ranges(remove_ranges); for range in merged.into_iter().rev() { self.remove_block(range.start, range.len); } @@ -220,7 +223,7 @@ impl NotificationList { } } - if !pending_items.is_empty() { + if has_pending_items(pending_items.len()) { let _inserted_len = self.insert_block(pending_start, &pending_items, &pending_keys); // Final dirty batch also owns valid ranges for the next incremental pass for (key, range) in pending_ranges.drain(..) { @@ -235,7 +238,7 @@ impl NotificationList { self.dirty_groups.clear(); // Prune interned keys that are no longer referenced by any list state. - self.interned.retain(|key| Rc::strong_count(key) > 1); + self.interned.retain(intern_key_is_live); self.update_empty_overlay(); @@ -246,11 +249,11 @@ impl NotificationList { .filter(|key| { self.grouped_cache .get(*key) - .map(|ids| !self.visible_ids_for_group(ids).is_empty()) + .map(|ids| self.group_ids_are_visible(ids)) .unwrap_or(false) }) .count(); - if self.group_ranges.len() != expected_ranges { + if range_count_mismatch(self.group_ranges.len(), expected_ranges) { // Missing ranges leave later stack edits dependent on a full expand/collapse rebuild debug!( expected_ranges, @@ -273,6 +276,10 @@ impl NotificationList { self.needs_rebuild = true; } + fn group_ids_are_visible(&self, ids: &[u32]) -> bool { + self.visible_ids_for_group(ids).is_empty().not() + } + fn update_empty_overlay(&self) { let is_empty = self.store.n_items() == 0; // Compare against the widget's own visible flag @@ -282,3 +289,48 @@ impl NotificationList { } } } + +fn should_rebuild_from_scratch(store_items: u32, group_range_count: usize) -> bool { + store_items == 0 || group_range_count == 0 +} + +fn has_pending_items(count: usize) -> bool { + count > 0 +} + +fn range_count_mismatch(actual: usize, expected: usize) -> bool { + actual.abs_diff(expected) > 0 +} + +fn intern_key_is_live(key: &Rc) -> bool { + Rc::strong_count(key) > 1 +} + +fn should_keep_group( + dirty_groups: &HashSet>, + key: &Rc, + current_len: usize, + desired_len: usize, +) -> bool { + !dirty_groups.contains(key) && current_len == desired_len +} + +fn merge_adjacent_ranges(mut ranges: Vec) -> Vec { + ranges.sort_by_key(|range| range.start); + let mut merged: Vec = Vec::new(); + for range in ranges { + if let Some(last) = merged.last_mut() { + if last.start + last.len == range.start { + // Adjacent removals are merged to reduce ListStore splice calls + last.len += range.len; + continue; + } + } + merged.push(range); + } + merged +} + +#[cfg(test)] +#[path = "tests/update.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/list/list_widgets.rs b/crates/unixnotis-center/src/ui/list/widgets.rs similarity index 86% rename from crates/unixnotis-center/src/ui/list/list_widgets.rs rename to crates/unixnotis-center/src/ui/list/widgets.rs index 963a9ae0..495a8b06 100644 --- a/crates/unixnotis-center/src/ui/list/list_widgets.rs +++ b/crates/unixnotis-center/src/ui/list/widgets.rs @@ -15,9 +15,9 @@ use tracing::debug; use crate::dbus::{UiCommand, UiEvent}; use super::super::icons::IconResolver; -use super::list_item::{RowData, RowItem, RowKind}; -use super::list_row::group::{build_group_row, update_group_row, GroupRowWidgets}; -use super::list_row::notification::{ +use super::item::{RowData, RowItem, RowKind}; +use super::row::group::{build_group_row, update_group_row, GroupRowWidgets}; +use super::row::notification::{ build_notification_row, update_notification_row, NotificationRowWidgets, }; @@ -101,19 +101,19 @@ impl RowWidgets { } pub(super) fn ensure_row_widgets( - list_item: >k::ListItem, + item: >k::ListItem, kind: RowKind, command_tx: mpsc::Sender, event_tx: Sender, ) -> Rc { - if let Some(existing) = get_row_widgets(list_item) { + if let Some(existing) = get_row_widgets(item) { if existing.kind == kind { return existing.clone(); } } let widgets = Rc::new(RowWidgets::new(kind, command_tx, event_tx)); - set_row_widgets(list_item, widgets.clone()); + set_row_widgets(item, widgets.clone()); debug!(?kind, "row widgets created"); widgets } @@ -137,23 +137,26 @@ pub(super) fn bind_row( *widgets.handler.borrow_mut() = Some((item.clone(), handler)); } -pub(super) fn set_row_widgets(list_item: >k::ListItem, widgets: Rc) { +pub(super) fn set_row_widgets(item: >k::ListItem, widgets: Rc) { // Attach the actual row root whenever the cached widget bundle changes // Setup also uses this so GTK never keeps an empty placeholder child - list_item.set_child(Some(&widgets.root)); + item.set_child(Some(&widgets.root)); unsafe { // SAFETY: gtk::ListItem stays on the GTK main thread and never crosses threads. // RowWidgets uses Rc and is only accessed from list factory callbacks on the // main thread. Data is replaced in ensure_row_widgets when the row kind changes // and otherwise kept to let GTK reuse the row widgets across scroll events. - list_item.set_qdata(row_widgets_quark(), widgets); + item.set_qdata(row_widgets_quark(), widgets); } } -pub(super) fn get_row_widgets(list_item: >k::ListItem) -> Option> { +pub(super) fn get_row_widgets(item: >k::ListItem) -> Option> { unsafe { - list_item - .qdata::>(row_widgets_quark()) + item.qdata::>(row_widgets_quark()) .map(|ptr| ptr.as_ref().clone()) } } + +#[cfg(test)] +#[path = "tests/widgets.rs"] +mod tests; From 4025135af9f02bedc2654740277290962283eb16 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:31:02 -0500 Subject: [PATCH 02/27] cover notification list lifecycle Add reusable list test fixtures and move lifecycle behavior into a dedicated test file outside the implementation module. --- .../src/ui/list/tests/lifecycle.rs | 266 ++++++++++++++++++ .../src/ui/list/tests/support.rs | 63 +++++ 2 files changed, 329 insertions(+) create mode 100644 crates/unixnotis-center/src/ui/list/tests/lifecycle.rs create mode 100644 crates/unixnotis-center/src/ui/list/tests/support.rs diff --git a/crates/unixnotis-center/src/ui/list/tests/lifecycle.rs b/crates/unixnotis-center/src/ui/list/tests/lifecycle.rs new file mode 100644 index 00000000..5426ac37 --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/tests/lifecycle.rs @@ -0,0 +1,266 @@ +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::{clear_seed_group_expansion, drain_order_over_limit}; + +use crate::ui::list::test_support as support; + +fn ordered_ids(ids: &[u32]) -> VecDeque { + ids.iter().copied().collect() +} + +#[test] +fn seed_group_state_reset_clears_expanded_groups() { + let mut group_expanded = HashMap::from([(Rc::::from("Crash Reporting System"), true)]); + + clear_seed_group_expansion(&mut group_expanded); + + assert!(group_expanded.is_empty()); +} + +#[test] +fn seed_group_state_reset_clears_collapsed_groups_too() { + let mut group_expanded = HashMap::from([ + (Rc::::from("Crash Reporting System"), false), + (Rc::::from("notify-send"), true), + ]); + + clear_seed_group_expansion(&mut group_expanded); + + assert!(group_expanded.is_empty()); +} + +#[test] +fn seed_group_state_reset_accepts_empty_state() { + let mut group_expanded = HashMap::new(); + + clear_seed_group_expansion(&mut group_expanded); + + assert!(group_expanded.is_empty()); +} + +#[test] +fn drain_order_over_limit_removes_oldest_ids_from_back() { + let mut order = ordered_ids(&[4, 3, 2, 1]); + + let drained = drain_order_over_limit(&mut order, 2); + + assert_eq!(drained, vec![1, 2]); + assert_eq!(order, ordered_ids(&[4, 3])); +} + +#[test] +fn drain_order_over_limit_keeps_exact_capacity() { + let mut order = ordered_ids(&[3, 2, 1]); + + let drained = drain_order_over_limit(&mut order, 3); + + assert!(drained.is_empty()); + assert_eq!(order, ordered_ids(&[3, 2, 1])); +} + +#[test] +fn drain_order_over_limit_keeps_under_capacity() { + let mut order = ordered_ids(&[2, 1]); + + let drained = drain_order_over_limit(&mut order, 5); + + assert!(drained.is_empty()); + assert_eq!(order, ordered_ids(&[2, 1])); +} + +#[test] +fn drain_order_over_limit_zero_capacity_drains_every_id() { + let mut order = ordered_ids(&[3, 2, 1]); + + let drained = drain_order_over_limit(&mut order, 0); + + assert_eq!(drained, vec![3, 2, 1]); + assert!(order.is_empty()); +} + +#[test] +fn drain_order_over_limit_zero_capacity_accepts_empty_order() { + let mut order = VecDeque::new(); + + let drained = drain_order_over_limit(&mut order, 0); + + assert!(drained.is_empty()); + assert!(order.is_empty()); +} + +#[gtk::test] +fn seed_replaces_existing_state_and_requests_rebuild() { + let mut list = support::make_list(); + let stale_key = Rc::::from("crash reporting system"); + list.group_expanded.insert(stale_key, true); + + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + ], + vec![support::notification(3, "History")], + ); + + assert!(list.group_expanded.is_empty()); + assert_eq!(list.total_count(), 3); + assert_eq!(list.active_order, ordered_ids(&[2, 1])); + assert_eq!(list.history_order, ordered_ids(&[3])); + assert!(list.needs_rebuild()); +} + +#[gtk::test] +fn seed_trims_to_current_limits() { + let mut list = support::make_list(); + list.max_active = 1; + list.max_entries = 1; + + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + ], + vec![ + support::notification(3, "History"), + support::notification(4, "History"), + ], + ); + + assert_eq!(list.active_order, ordered_ids(&[2])); + assert_eq!(list.history_order, ordered_ids(&[4])); + assert!(!list.entries.contains_key(&1)); + assert!(!list.entries.contains_key(&3)); +} + +#[gtk::test] +fn apply_limits_trims_active_notifications_when_limit_changes() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + support::notification(3, "Terminal"), + ], + Vec::new(), + ); + list.needs_rebuild = false; + + list.apply_limits(2, 10); + + assert_eq!(list.active_order, ordered_ids(&[3, 2])); + assert!(!list.entries.contains_key(&1)); + assert!(list.needs_rebuild()); +} + +#[gtk::test] +fn apply_limits_trims_history_notifications_when_limit_changes() { + let mut list = support::make_list(); + list.seed( + Vec::new(), + vec![ + support::notification(1, "History"), + support::notification(2, "History"), + support::notification(3, "History"), + ], + ); + list.needs_rebuild = false; + + list.apply_limits(10, 2); + + assert_eq!(list.history_order, ordered_ids(&[3, 2])); + assert!(!list.entries.contains_key(&1)); + assert!(list.needs_rebuild()); +} + +#[gtk::test] +fn apply_limits_same_values_keeps_state_unchanged() { + let mut list = support::make_list(); + list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + list.needs_rebuild = false; + + list.apply_limits(10, 10); + + assert_eq!(list.active_order, ordered_ids(&[1])); + assert!(!list.needs_rebuild()); +} + +#[gtk::test] +fn remove_entry_drops_entry_from_all_orders() { + let mut list = support::make_list(); + list.seed( + vec![support::notification(1, "Terminal")], + vec![support::notification(2, "History")], + ); + + list.remove_entry(1); + list.remove_entry(2); + + assert!(list.entries.is_empty()); + assert!(list.active_order.is_empty()); + assert!(list.history_order.is_empty()); +} + +#[gtk::test] +fn remove_entry_drops_active_order_without_touching_history_order() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + ], + vec![support::notification(3, "History")], + ); + + list.remove_entry(1); + + assert_eq!(list.active_order, ordered_ids(&[2])); + assert_eq!(list.history_order, ordered_ids(&[3])); + assert!(!list.entries.contains_key(&1)); +} + +#[gtk::test] +fn remove_entry_drops_history_order_without_touching_active_order() { + let mut list = support::make_list(); + list.seed( + vec![support::notification(1, "Terminal")], + vec![ + support::notification(2, "History"), + support::notification(3, "History"), + ], + ); + + list.remove_entry(2); + + assert_eq!(list.active_order, ordered_ids(&[1])); + assert_eq!(list.history_order, ordered_ids(&[3])); + assert!(!list.entries.contains_key(&2)); +} + +#[gtk::test] +fn insert_entry_records_recent_local_timestamp() { + let mut list = support::make_list(); + let before = super::now_millis(); + let key = list.insert_entry(support::notification(9, "Terminal"), true); + let after = super::now_millis(); + + let entry = list.entries.get(&9).expect("entry should be stored"); + assert_eq!(key.as_ref(), "terminal"); + assert!(entry.received_at_ms >= before); + assert!(entry.received_at_ms <= after); +} + +#[test] +fn now_millis_tracks_current_unix_time() { + let system_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_millis(); + let system_ms = i64::try_from(system_ms).expect("current millis should fit i64"); + + let actual = super::now_millis(); + + assert!(actual > 1_700_000_000_000); + assert!((actual - system_ms).abs() < 5_000); +} diff --git a/crates/unixnotis-center/src/ui/list/tests/support.rs b/crates/unixnotis-center/src/ui/list/tests/support.rs new file mode 100644 index 00000000..2ac16789 --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/tests/support.rs @@ -0,0 +1,63 @@ +use std::rc::Rc; +use std::sync::Once; + +use async_channel::Sender; +use tokio::sync::mpsc; +use unixnotis_core::{NotificationImage, NotificationView}; + +use crate::dbus::{UiCommand, UiEvent}; +use crate::ui::icons::IconResolver; +use crate::ui::list::{NotificationList, NotificationListConfig}; + +static GTK_INIT: Once = Once::new(); + +pub(super) fn init_gtk() { + GTK_INIT.call_once(|| { + gtk::init().expect("gtk should initialize under xvfb"); + }); +} + +pub(super) fn list_config() -> NotificationListConfig { + NotificationListConfig { + max_active: 10, + max_entries: 10, + transient_to_history: true, + show_notification_metadata: false, + show_notification_thumbnails: false, + empty_text: "No notifications".to_string(), + empty_offset_top: 24, + } +} + +pub(super) fn make_list() -> NotificationList { + init_gtk(); + let scroller = gtk::ScrolledWindow::new(); + let (command_tx, _command_rx) = mpsc::channel::(8); + let (event_tx, _event_rx) = async_channel::bounded::(8); + NotificationList::new( + scroller, + command_tx, + event_tx, + Rc::new(IconResolver::new()), + list_config(), + ) +} + +pub(super) fn channels() -> (mpsc::Sender, Sender) { + let (command_tx, _command_rx) = mpsc::channel::(8); + let (event_tx, _event_rx) = async_channel::bounded::(8); + (command_tx, event_tx) +} + +pub(super) fn notification(id: u32, app_name: &str) -> NotificationView { + NotificationView { + id, + app_name: app_name.to_string(), + summary: format!("summary {id}"), + body: format!("body {id}"), + actions: Vec::new(), + urgency: 1, + is_transient: false, + image: NotificationImage::default(), + } +} From e0adf0743538ddb0fe46487e306ba01d6e2a81e2 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:31:17 -0500 Subject: [PATCH 03/27] cover notification list blocks Add tests for grouped block construction so collapsed groups, visible notifications, and standalone rows keep their expected shape. --- .../src/ui/list/tests/blocks.rs | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 crates/unixnotis-center/src/ui/list/tests/blocks.rs diff --git a/crates/unixnotis-center/src/ui/list/tests/blocks.rs b/crates/unixnotis-center/src/ui/list/tests/blocks.rs new file mode 100644 index 00000000..6dff22f8 --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/tests/blocks.rs @@ -0,0 +1,233 @@ +use std::rc::Rc; + +use gio::prelude::ListModelExt; + +use super::{collapsed_stack_depth, common_prefix_suffix}; +use crate::ui::list::item::{RowData, RowItem}; +use crate::ui::list::test_support as support; +use crate::ui::list::types::GroupRange; +use crate::ui::list::types::RowKey; + +#[test] +fn collapsed_stack_depth_caps_at_two() { + assert_eq!(collapsed_stack_depth(1, false), 0); + assert_eq!(collapsed_stack_depth(2, false), 1); + assert_eq!(collapsed_stack_depth(4, false), 2); +} + +#[test] +fn collapsed_stack_depth_is_zero_when_expanded() { + assert_eq!(collapsed_stack_depth(4, true), 0); +} + +#[test] +fn common_prefix_suffix_finds_stable_edges() { + let group = Rc::::from("terminal"); + let current = vec![ + RowKey::GroupHeader { + group: group.clone(), + }, + RowKey::Notification { id: 1 }, + RowKey::Notification { id: 2 }, + RowKey::Notification { id: 3 }, + ]; + let next = vec![ + RowKey::GroupHeader { group }, + RowKey::Notification { id: 9 }, + RowKey::Notification { id: 2 }, + RowKey::Notification { id: 3 }, + ]; + + assert_eq!(common_prefix_suffix(¤t, &next), (1, 2)); +} + +#[test] +fn common_prefix_suffix_handles_empty_inputs() { + assert_eq!(common_prefix_suffix(&[], &[]), (0, 0)); + assert_eq!( + common_prefix_suffix(&[], &[RowKey::Notification { id: 1 }]), + (0, 0) + ); +} + +#[test] +fn common_prefix_suffix_does_not_overlap_when_lists_are_equal() { + let keys = vec![ + RowKey::Notification { id: 1 }, + RowKey::Notification { id: 2 }, + ]; + + assert_eq!(common_prefix_suffix(&keys, &keys), (2, 0)); +} + +#[gtk::test] +fn build_group_block_collapses_group_to_header_and_top_notification() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + support::notification(3, "Terminal"), + ], + Vec::new(), + ); + let key = list.entries.get(&3).expect("entry").app_key.clone(); + let ids = list.grouped_cache.get(&key).expect("group ids").clone(); + + let (items, keys) = list.build_group_block(&key, &ids); + + assert_eq!(items.len(), 2); + assert_eq!( + keys, + vec![ + RowKey::GroupHeader { group: key.clone() }, + RowKey::Notification { id: 3 } + ] + ); + let header = items[0].data(); + assert_eq!(header.count, 3); + assert!(!header.expanded); + let visible = items[1].data(); + assert!(visible.stacked); + assert_eq!(visible.stack_depth, 2); + assert!(!visible.expanded); +} + +#[gtk::test] +fn build_group_block_keeps_single_collapsed_notification_unstacked() { + let mut list = support::make_list(); + list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + let key = list.entries.get(&1).expect("entry").app_key.clone(); + let ids = list.grouped_cache.get(&key).expect("group ids").clone(); + + let (items, _keys) = list.build_group_block(&key, &ids); + + assert_eq!(items.len(), 2); + let visible = items[1].data(); + assert!(!visible.stacked); + assert_eq!(visible.stack_depth, 0); +} + +#[gtk::test] +fn build_group_block_expands_group_to_all_notifications() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + support::notification(3, "Terminal"), + ], + Vec::new(), + ); + let key = list.entries.get(&3).expect("entry").app_key.clone(); + list.group_expanded.insert(key.clone(), true); + let ids = list.grouped_cache.get(&key).expect("group ids").clone(); + + let (items, keys) = list.build_group_block(&key, &ids); + + assert_eq!(list.group_block_len(&key, &ids), 4); + assert_eq!(items.len(), 4); + assert_eq!( + keys, + vec![ + RowKey::GroupHeader { group: key.clone() }, + RowKey::Notification { id: 3 }, + RowKey::Notification { id: 2 }, + RowKey::Notification { id: 1 }, + ] + ); + for item in items.iter().skip(1) { + let data = item.data(); + assert!(!data.stacked); + assert_eq!(data.stack_depth, 0); + assert!(data.expanded); + } +} + +#[gtk::test] +fn group_block_len_counts_header_and_visible_rows() { + let mut list = support::make_list(); + let key = Rc::::from("terminal"); + let ids = vec![1, 2, 3]; + + assert_eq!(list.group_block_len(&key, &ids), 2); + + list.group_expanded.insert(key.clone(), true); + assert_eq!(list.group_block_len(&key, &ids), 4); +} + +#[gtk::test] +fn insert_and_remove_block_keep_store_keys_and_ranges_in_sync() { + let mut list = support::make_list(); + let terminal = Rc::::from("terminal"); + let browser = Rc::::from("browser"); + let existing_items = vec![RowItem::new(RowData::default())]; + let existing_keys = vec![RowKey::Notification { id: 0 }]; + + assert_eq!(list.insert_block(0, &existing_items, &existing_keys), 1); + + list.group_ranges + .insert(terminal.clone(), GroupRange { start: 0, len: 1 }); + list.group_ranges + .insert(browser.clone(), GroupRange { start: 1, len: 1 }); + + let items = vec![ + RowItem::new(RowData::default()), + RowItem::new(RowData::default()), + ]; + let keys = vec![ + RowKey::Notification { id: 1 }, + RowKey::Notification { id: 2 }, + ]; + + assert_eq!(list.insert_block(1, &items, &keys), 2); + assert_eq!(list.store.n_items(), 3); + assert_eq!( + list.current_keys, + vec![ + RowKey::Notification { id: 0 }, + RowKey::Notification { id: 1 }, + RowKey::Notification { id: 2 }, + ] + ); + assert_eq!(list.group_ranges[&terminal].start, 0); + assert_eq!(list.group_ranges[&browser].start, 3); + + list.remove_block(1, 1); + + assert_eq!(list.store.n_items(), 2); + assert_eq!( + list.current_keys, + vec![ + RowKey::Notification { id: 0 }, + RowKey::Notification { id: 2 }, + ] + ); + assert_eq!(list.group_ranges[&terminal].start, 0); + assert_eq!(list.group_ranges[&browser].start, 2); +} + +#[gtk::test] +fn shift_group_ranges_excludes_exact_start_for_removals() { + let mut list = support::make_list(); + let exact = Rc::::from("exact"); + let after = Rc::::from("after"); + list.group_ranges + .insert(exact.clone(), GroupRange { start: 1, len: 1 }); + list.group_ranges + .insert(after.clone(), GroupRange { start: 2, len: 1 }); + + list.shift_group_ranges(1, -1, false); + + assert_eq!(list.group_ranges[&exact].start, 1); + assert_eq!(list.group_ranges[&after].start, 1); +} + +#[gtk::test] +fn insert_block_noops_when_there_are_no_items() { + let mut list = support::make_list(); + + assert_eq!(list.insert_block(0, &[], &[]), 0); + assert_eq!(list.store.n_items(), 0); + assert!(list.current_keys.is_empty()); +} From c5af4f7e2900534819f3efec8ee72c8d7f9afa74 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:31:30 -0500 Subject: [PATCH 04/27] cover notification list row identity Add tests for row index ordering and RowItem data replacement so list-store updates preserve stable row identity. --- .../src/ui/list/tests/index.rs | 125 ++++++++++++ .../src/ui/list/tests/item.rs | 185 ++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 crates/unixnotis-center/src/ui/list/tests/index.rs create mode 100644 crates/unixnotis-center/src/ui/list/tests/item.rs diff --git a/crates/unixnotis-center/src/ui/list/tests/index.rs b/crates/unixnotis-center/src/ui/list/tests/index.rs new file mode 100644 index 00000000..4377029b --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/tests/index.rs @@ -0,0 +1,125 @@ +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; + +use super::remove_from_group_bucket; +use crate::ui::list::test_support as support; + +#[test] +fn remove_from_group_bucket_removes_only_requested_id() { + let key = Rc::::from("terminal"); + let mut map = HashMap::from([(key.clone(), VecDeque::from([3, 2, 1]))]); + + remove_from_group_bucket(&mut map, &key, 2); + + assert_eq!(map.get(&key), Some(&VecDeque::from([3, 1]))); +} + +#[test] +fn remove_from_group_bucket_drops_empty_bucket() { + let key = Rc::::from("terminal"); + let mut map = HashMap::from([(key.clone(), VecDeque::from([1]))]); + + remove_from_group_bucket(&mut map, &key, 1); + + assert!(!map.contains_key(&key)); +} + +#[test] +fn remove_from_group_bucket_ignores_unknown_group() { + let key = Rc::::from("terminal"); + let other = Rc::::from("browser"); + let mut map = HashMap::from([(key.clone(), VecDeque::from([1]))]); + + remove_from_group_bucket(&mut map, &other, 1); + + assert_eq!(map.get(&key), Some(&VecDeque::from([1]))); +} + +#[gtk::test] +fn clear_group_indices_drops_all_parallel_group_caches() { + let mut list = support::make_list(); + let key = Rc::::from("terminal"); + list.group_active_index + .insert(key.clone(), VecDeque::from([3, 2])); + list.group_history_index + .insert(key.clone(), VecDeque::from([1])); + list.grouped_cache.insert(key, vec![3, 2, 1]); + + list.clear_group_indices(); + + assert!(list.group_active_index.is_empty()); + assert!(list.group_history_index.is_empty()); + assert!(list.grouped_cache.is_empty()); +} + +#[gtk::test] +fn index_insert_front_deduplicates_and_syncs_group_cache() { + let mut list = support::make_list(); + let key = Rc::::from("terminal"); + + list.index_insert_front(&key, 1, true); + list.index_insert_front(&key, 2, true); + list.index_insert_front(&key, 1, true); + list.index_insert_front(&key, 9, false); + + assert_eq!(list.group_active_index[&key], VecDeque::from([1, 2])); + assert_eq!(list.group_history_index[&key], VecDeque::from([9])); + assert_eq!(list.grouped_cache[&key], vec![1, 2, 9]); +} + +#[gtk::test] +fn index_remove_updates_bucket_and_cache() { + let mut list = support::make_list(); + let key = Rc::::from("terminal"); + list.index_insert_front(&key, 1, true); + list.index_insert_front(&key, 2, true); + list.index_insert_front(&key, 9, false); + + list.index_remove(&key, 1, true); + + assert_eq!(list.group_active_index[&key], VecDeque::from([2])); + assert_eq!(list.grouped_cache[&key], vec![2, 9]); + + list.index_remove(&key, 2, true); + list.index_remove(&key, 9, false); + + assert!(!list.group_active_index.contains_key(&key)); + assert!(!list.group_history_index.contains_key(&key)); + assert!(!list.grouped_cache.contains_key(&key)); +} + +#[gtk::test] +fn index_move_to_front_reorders_without_duplicates() { + let mut list = support::make_list(); + let key = Rc::::from("terminal"); + list.index_insert_front(&key, 1, true); + list.index_insert_front(&key, 2, true); + list.index_insert_front(&key, 3, true); + + list.index_move_to_front(&key, 1, true); + list.index_move_to_front(&key, 1, true); + + assert_eq!(list.group_active_index[&key], VecDeque::from([1, 3, 2])); + assert_eq!(list.grouped_cache[&key], vec![1, 3, 2]); +} + +#[gtk::test] +fn rebuild_group_index_for_key_uses_current_orders_and_entries() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Browser"), + support::notification(3, "Terminal"), + ], + vec![support::notification(4, "Terminal")], + ); + let key = list.entries.get(&3).expect("entry").app_key.clone(); + list.clear_group_indices(); + + list.rebuild_group_index_for_key(&key); + + assert_eq!(list.group_active_index[&key], VecDeque::from([3, 1])); + assert_eq!(list.group_history_index[&key], VecDeque::from([4])); + assert_eq!(list.grouped_cache[&key], vec![3, 1, 4]); +} diff --git a/crates/unixnotis-center/src/ui/list/tests/item.rs b/crates/unixnotis-center/src/ui/list/tests/item.rs new file mode 100644 index 00000000..1306fcf9 --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/tests/item.rs @@ -0,0 +1,185 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gtk::glib::object::ObjectExt; +use unixnotis_core::{NotificationImage, NotificationView}; + +use super::{RowData, RowItem, RowKind, RowPresentation}; + +fn notification(id: u32) -> Rc { + Rc::new(NotificationView { + id, + app_name: "Terminal".to_string(), + summary: "summary".to_string(), + body: "body".to_string(), + actions: Vec::new(), + urgency: 1, + is_transient: false, + image: NotificationImage::default(), + }) +} + +#[test] +fn row_data_group_header_sets_expected_fields() { + let sample = notification(7); + let data = RowData::group_header(Rc::from("terminal"), 4, true, sample.clone()); + + assert_eq!(data.kind, RowKind::GroupHeader); + assert_eq!(data.count, 4); + assert!(data.expanded); + assert!(Rc::ptr_eq( + data.notification.as_ref().expect("sample"), + &sample + )); +} + +#[test] +fn row_data_notification_sets_expected_fields() { + let view = notification(42); + let presentation = RowPresentation { + received_at_ms: 123, + show_metadata: true, + show_thumbnail: true, + }; + + let data = RowData::notification( + Rc::from("terminal"), + view.clone(), + true, + 2, + false, + true, + presentation, + ); + + assert_eq!(data.kind, RowKind::Notification); + assert_eq!(data.id, 42); + assert!(data.stacked); + assert_eq!(data.stack_depth, 2); + assert!(data.is_active); + assert_eq!(data.presentation, presentation); + assert!(Rc::ptr_eq(data.notification.as_ref().expect("view"), &view)); +} + +#[test] +fn row_item_update_emits_only_for_changed_data() { + let item = RowItem::new(RowData::notification( + Rc::from("terminal"), + notification(1), + false, + 0, + false, + true, + RowPresentation::default(), + )); + let updates = Rc::new(Cell::new(0)); + let updates_clone = updates.clone(); + item.connect_local("updated", false, move |_| { + updates_clone.set(updates_clone.get() + 1); + None + }); + + let same = item.data(); + item.update(same); + assert_eq!(updates.get(), 0); + + item.update(RowData::notification( + Rc::from("terminal"), + notification(2), + false, + 0, + false, + true, + RowPresentation::default(), + )); + assert_eq!(updates.get(), 1); +} + +#[test] +fn row_data_equivalence_requires_every_rendered_field_to_match() { + let group = Rc::::from("terminal"); + let view = notification(1); + let base = RowData::notification( + group.clone(), + view.clone(), + false, + 0, + false, + true, + RowPresentation { + received_at_ms: 12, + show_metadata: true, + show_thumbnail: false, + }, + ); + + assert!(base.is_equivalent(&base.clone())); + + let mut changed = base.clone(); + changed.kind = RowKind::GroupHeader; + assert!(!base.is_equivalent(&changed)); + + let mut changed = base.clone(); + changed.id = 2; + assert!(!base.is_equivalent(&changed)); + + let mut changed = base.clone(); + changed.group_key = Rc::from("browser"); + assert!(!base.is_equivalent(&changed)); + + let mut changed = base.clone(); + changed.count = 4; + assert!(!base.is_equivalent(&changed)); + + let mut changed = base.clone(); + changed.expanded = true; + assert!(!base.is_equivalent(&changed)); + + let mut changed = base.clone(); + changed.stacked = true; + assert!(!base.is_equivalent(&changed)); + + let mut changed = base.clone(); + changed.stack_depth = 2; + assert!(!base.is_equivalent(&changed)); + + let mut changed = base.clone(); + changed.is_active = false; + assert!(!base.is_equivalent(&changed)); + + let mut changed = base.clone(); + changed.presentation.show_thumbnail = true; + assert!(!base.is_equivalent(&changed)); + + let mut changed = base; + changed.notification = Some(notification(1)); + assert!(!RowData::notification( + group, + view, + false, + 0, + false, + true, + RowPresentation { + received_at_ms: 12, + show_metadata: true, + show_thumbnail: false, + }, + ) + .is_equivalent(&changed)); +} + +#[test] +fn row_data_same_notification_matches_none_and_shared_rc_only() { + let left = notification(1); + let right = notification(1); + + assert!(RowData::same_notification(&None, &None)); + assert!(RowData::same_notification( + &Some(left.clone()), + &Some(left.clone()) + )); + assert!(!RowData::same_notification(&Some(left), &Some(right))); + assert!(!RowData::same_notification(&Some(notification(2)), &None)); + assert!(!RowData::same_notification(&None, &Some(notification(2)))); +} From f483caa4e85374017700078f40ec5fed95a66c04 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:31:43 -0500 Subject: [PATCH 05/27] cover list widget plumbing Add tests for list construction, config defaults, and reusable row widget binding so GTK row recycling remains predictable. --- .../src/ui/list/tests/build.rs | 77 ++++++++ .../src/ui/list/tests/types.rs | 43 +++++ .../src/ui/list/tests/widgets.rs | 173 ++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 crates/unixnotis-center/src/ui/list/tests/build.rs create mode 100644 crates/unixnotis-center/src/ui/list/tests/types.rs create mode 100644 crates/unixnotis-center/src/ui/list/tests/widgets.rs diff --git a/crates/unixnotis-center/src/ui/list/tests/build.rs b/crates/unixnotis-center/src/ui/list/tests/build.rs new file mode 100644 index 00000000..ef82c7dd --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/tests/build.rs @@ -0,0 +1,77 @@ +use gtk::prelude::WidgetExt; +use gtk::Align; + +use crate::ui::list::test_support as support; + +#[gtk::test] +fn new_list_attaches_overlay_to_scroller() { + support::init_gtk(); + let scroller = gtk::ScrolledWindow::new(); + let (command_tx, event_tx) = support::channels(); + + let list = crate::ui::list::NotificationList::new( + scroller.clone(), + command_tx, + event_tx, + std::rc::Rc::new(crate::ui::icons::IconResolver::new()), + support::list_config(), + ); + + assert!(scroller.child().is_some()); + assert_eq!(list.empty_text, "No notifications"); + assert_eq!(list.empty_offset_top, 24); + assert!(list.empty_overlay.get_visible()); +} + +#[gtk::test] +fn apply_config_updates_empty_copy_and_offset() { + let mut list = support::make_list(); + let mut config = support::list_config(); + config.empty_text = "All clear".to_string(); + config.empty_offset_top = 48; + + list.apply_config(&config, true); + + assert_eq!(list.empty_text, "All clear"); + assert_eq!(list.empty_offset_top, 48); + assert_eq!(list.empty_overlay.margin_top(), 48); +} + +#[gtk::test] +fn apply_config_requests_rebuild_when_metadata_or_thumbnail_flags_change() { + let mut list = support::make_list(); + let mut config = support::list_config(); + config.show_notification_metadata = true; + + list.apply_config(&config, true); + + assert!(list.show_notification_metadata); + assert!(!list.show_notification_thumbnails); + assert!(list.needs_rebuild()); + + list.needs_rebuild = false; + config.show_notification_thumbnails = true; + + list.apply_config(&config, true); + + assert!(list.show_notification_metadata); + assert!(list.show_notification_thumbnails); + assert!(list.needs_rebuild()); +} + +#[gtk::test] +fn set_empty_layout_switches_between_widget_offset_and_centered_empty_state() { + let list = support::make_list(); + + list.empty_overlay.set_valign(Align::Center); + list.empty_overlay.set_margin_top(0); + list.set_empty_layout(true); + + assert_eq!(list.empty_overlay.valign(), Align::Start); + assert_eq!(list.empty_overlay.margin_top(), list.empty_offset_top); + + list.set_empty_layout(false); + + assert_eq!(list.empty_overlay.valign(), Align::Center); + assert_eq!(list.empty_overlay.margin_top(), 0); +} diff --git a/crates/unixnotis-center/src/ui/list/tests/types.rs b/crates/unixnotis-center/src/ui/list/tests/types.rs new file mode 100644 index 00000000..0af104d2 --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/tests/types.rs @@ -0,0 +1,43 @@ +use std::collections::HashSet; +use std::rc::Rc; + +use super::{FilterQuery, GroupRange, RowKey}; + +#[test] +fn row_key_hash_and_equality_keep_group_and_notification_distinct() { + let group = RowKey::GroupHeader { + group: Rc::from("terminal"), + }; + let notification = RowKey::Notification { id: 1 }; + let mut keys = HashSet::new(); + + keys.insert(group.clone()); + keys.insert(notification.clone()); + keys.insert(group); + keys.insert(notification); + + assert_eq!(keys.len(), 2); +} + +#[test] +fn filter_query_equality_includes_ascii_mode() { + let ascii = FilterQuery { + text: "term".into(), + ascii_only: true, + }; + let unicode = FilterQuery { + text: "term".into(), + ascii_only: false, + }; + + assert_ne!(ascii, unicode); +} + +#[test] +fn group_range_copy_preserves_span() { + let range = GroupRange { start: 4, len: 2 }; + let copied = range; + + assert_eq!(copied.start, 4); + assert_eq!(copied.len, 2); +} diff --git a/crates/unixnotis-center/src/ui/list/tests/widgets.rs b/crates/unixnotis-center/src/ui/list/tests/widgets.rs new file mode 100644 index 00000000..4a07ddfc --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/tests/widgets.rs @@ -0,0 +1,173 @@ +use gtk::glib::object::Cast; +use gtk::prelude::{ListItemExt, WidgetExt}; +use std::rc::Rc; + +use super::{bind_row, ensure_row_widgets, get_row_widgets, set_row_widgets, RowWidgets}; +use crate::ui::icons::IconResolver; +use crate::ui::list::item::{RowData, RowItem, RowKind, RowPresentation}; + +use crate::ui::list::test_support as support; + +fn new_gtk_item() -> gtk::ListItem { + gtk::glib::Object::new::() +} + +fn contains_label_text(root: >k::Widget, text: &str) -> bool { + if root + .downcast_ref::() + .map(|label| label.text().as_str() == text) + .unwrap_or(false) + { + return true; + } + + let mut child = root.first_child(); + while let Some(widget) = child { + if contains_label_text(&widget, text) { + return true; + } + child = widget.next_sibling(); + } + false +} + +#[gtk::test] +fn set_and_get_row_widgets_round_trips_cached_bundle() { + support::init_gtk(); + let (command_tx, event_tx) = support::channels(); + let gtk_item = new_gtk_item(); + let widgets = std::rc::Rc::new(RowWidgets::new(RowKind::Notification, command_tx, event_tx)); + + set_row_widgets(>k_item, widgets.clone()); + + let cached = get_row_widgets(>k_item).expect("widgets should be cached"); + assert!(std::rc::Rc::ptr_eq(&cached, &widgets)); + assert!(gtk_item.child().is_some()); +} + +#[gtk::test] +fn ensure_row_widgets_reuses_same_kind() { + support::init_gtk(); + let (command_tx, event_tx) = support::channels(); + let gtk_item = new_gtk_item(); + + let first = ensure_row_widgets( + >k_item, + RowKind::Notification, + command_tx.clone(), + event_tx.clone(), + ); + let second = ensure_row_widgets(>k_item, RowKind::Notification, command_tx, event_tx); + + assert!(std::rc::Rc::ptr_eq(&first, &second)); +} + +#[gtk::test] +fn ensure_row_widgets_replaces_different_kind() { + support::init_gtk(); + let (command_tx, event_tx) = support::channels(); + let gtk_item = new_gtk_item(); + + let first = ensure_row_widgets( + >k_item, + RowKind::Notification, + command_tx.clone(), + event_tx.clone(), + ); + let second = ensure_row_widgets(>k_item, RowKind::GroupHeader, command_tx, event_tx); + + assert!(!std::rc::Rc::ptr_eq(&first, &second)); +} + +#[gtk::test] +fn bind_row_refreshes_notification_widget_and_tracks_item_updates() { + support::init_gtk(); + let (command_tx, event_tx) = support::channels(); + let widgets = Rc::new(RowWidgets::new(RowKind::Notification, command_tx, event_tx)); + let notification = Rc::new(support::notification(1, "Terminal")); + let item = RowItem::new(RowData::notification( + Rc::from("terminal"), + notification, + false, + 0, + false, + true, + RowPresentation::default(), + )); + + bind_row( + widgets.clone(), + &item, + &item.data(), + Rc::new(IconResolver::new()), + ); + + assert!(contains_label_text( + &widgets.root.clone().upcast::(), + "summary 1" + )); + + let changed = Rc::new(support::notification(2, "Terminal")); + item.update(RowData::notification( + Rc::from("terminal"), + changed, + false, + 0, + false, + true, + RowPresentation::default(), + )); + + assert!(contains_label_text( + &widgets.root.clone().upcast::(), + "summary 2" + )); +} + +#[gtk::test] +fn unbind_disconnects_row_item_update_handler() { + support::init_gtk(); + let (command_tx, event_tx) = support::channels(); + let widgets = Rc::new(RowWidgets::new(RowKind::Notification, command_tx, event_tx)); + let notification = Rc::new(support::notification(1, "Terminal")); + let item = RowItem::new(RowData::notification( + Rc::from("terminal"), + notification, + false, + 0, + false, + true, + RowPresentation::default(), + )); + bind_row( + widgets.clone(), + &item, + &item.data(), + Rc::new(IconResolver::new()), + ); + assert!(contains_label_text( + &widgets.root.clone().upcast::(), + "summary 1" + )); + + widgets.unbind(); + let changed = Rc::new(support::notification(2, "Terminal")); + item.update(RowData::notification( + Rc::from("terminal"), + changed, + false, + 0, + false, + true, + RowPresentation::default(), + )); + + assert!(contains_label_text( + &widgets.root.clone().upcast::(), + "summary 1" + )); + assert!(!contains_label_text( + &widgets.root.clone().upcast::(), + "summary 2" + )); +} From af143cf084ac6c623aecffe6997560b6e9671cf5 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:31:56 -0500 Subject: [PATCH 06/27] cover notification list updates Add list-store update tests for insertion, replacement, removal, and ordering so row reconciliation failures are caught directly. --- .../src/ui/list/tests/update.rs | 393 ++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 crates/unixnotis-center/src/ui/list/tests/update.rs diff --git a/crates/unixnotis-center/src/ui/list/tests/update.rs b/crates/unixnotis-center/src/ui/list/tests/update.rs new file mode 100644 index 00000000..7b54d031 --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/tests/update.rs @@ -0,0 +1,393 @@ +use gio::prelude::ListModelExt; +use gtk::prelude::WidgetExt; +use std::rc::Rc; + +use crate::ui::list::item::RowData; +use crate::ui::list::test_support as support; +use crate::ui::list::types::{GroupRange, RowKey}; + +use super::{ + has_pending_items, intern_key_is_live, merge_adjacent_ranges, range_count_mismatch, + should_keep_group, should_rebuild_from_scratch, +}; + +#[gtk::test] +fn request_rebuild_marks_list_dirty() { + let mut list = support::make_list(); + assert!(!list.needs_rebuild()); + + list.request_rebuild(); + + assert!(list.needs_rebuild()); +} + +#[test] +fn rebuild_from_scratch_policy_covers_empty_store_and_missing_ranges() { + assert!(should_rebuild_from_scratch(0, 0)); + assert!(should_rebuild_from_scratch(0, 2)); + assert!(should_rebuild_from_scratch(3, 0)); + assert!(!should_rebuild_from_scratch(3, 2)); +} + +#[test] +fn pending_item_policy_and_range_count_mismatch_use_counts() { + assert!(!has_pending_items(0)); + assert!(has_pending_items(1)); + assert!(!range_count_mismatch(2, 2)); + assert!(range_count_mismatch(1, 2)); + assert!(range_count_mismatch(3, 2)); +} + +#[gtk::test] +fn group_ids_are_visible_respects_empty_ids_and_active_filter() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Browser"), + ], + Vec::new(), + ); + let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); + let browser = list.entries.get(&2).expect("browser").app_key.clone(); + let terminal_ids = list + .grouped_cache + .get(&terminal) + .expect("terminal ids") + .clone(); + let browser_ids = list + .grouped_cache + .get(&browser) + .expect("browser ids") + .clone(); + + assert!(!list.group_ids_are_visible(&[])); + assert!(list.group_ids_are_visible(&terminal_ids)); + + assert!(list.set_filter_query("browser")); + assert!(!list.group_ids_are_visible(&terminal_ids)); + assert!(list.group_ids_are_visible(&browser_ids)); +} + +#[test] +fn intern_key_liveness_requires_a_reference_outside_the_intern_set() { + let stale = Rc::::from("stale"); + assert!(!intern_key_is_live(&stale)); + + let live = Rc::::from("live"); + let _external = live.clone(); + assert!(intern_key_is_live(&live)); +} + +#[test] +fn keep_group_policy_requires_clean_group_and_matching_span() { + let key = Rc::::from("terminal"); + let mut dirty = std::collections::HashSet::new(); + + assert!(should_keep_group(&dirty, &key, 2, 2)); + assert!(!should_keep_group(&dirty, &key, 2, 3)); + + dirty.insert(key.clone()); + assert!(!should_keep_group(&dirty, &key, 2, 2)); +} + +#[test] +fn merge_adjacent_ranges_combines_only_touching_ranges() { + let merged = merge_adjacent_ranges(vec![ + GroupRange { start: 6, len: 1 }, + GroupRange { start: 0, len: 3 }, + GroupRange { start: 3, len: 2 }, + GroupRange { start: 8, len: 1 }, + ]); + + assert_eq!(merged.len(), 3); + assert_eq!(merged[0].start, 0); + assert_eq!(merged[0].len, 5); + assert_eq!(merged[1].start, 6); + assert_eq!(merged[1].len, 1); + assert_eq!(merged[2].start, 8); + assert_eq!(merged[2].len, 1); +} + +#[gtk::test] +fn flush_rebuild_noops_when_list_is_clean() { + let mut list = support::make_list(); + list.flush_rebuild(); + + assert!(!list.needs_rebuild()); + assert_eq!(list.store.n_items(), 0); +} + +#[gtk::test] +fn flush_rebuild_builds_seeded_rows_and_hides_empty_overlay() { + let mut list = support::make_list(); + list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + + list.flush_rebuild(); + + assert!(!list.needs_rebuild()); + assert_eq!(list.store.n_items(), 2); + assert!(!list.empty_overlay.get_visible()); +} + +#[gtk::test] +fn flush_rebuild_filters_existing_list_with_minimal_middle_splice() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Browser"), + ], + Vec::new(), + ); + list.flush_rebuild(); + let browser = list.entries.get(&2).expect("browser").app_key.clone(); + assert_eq!(list.store.n_items(), 4); + + assert!(list.set_filter_query("browser")); + list.flush_rebuild(); + + assert_eq!(list.store.n_items(), 2); + assert_eq!( + list.current_keys, + vec![ + RowKey::GroupHeader { + group: browser.clone() + }, + RowKey::Notification { id: 2 }, + ] + ); + assert_eq!(list.group_ranges[&browser].start, 0); + assert_eq!(list.group_ranges[&browser].len, 2); +} + +#[gtk::test] +fn flush_rebuild_rebuilds_from_nonempty_store_when_ranges_are_missing() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Browser"), + support::notification(3, "Editor"), + ], + Vec::new(), + ); + list.flush_rebuild(); + let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); + let browser = list.entries.get(&2).expect("browser").app_key.clone(); + let editor = list.entries.get(&3).expect("editor").app_key.clone(); + list.interned.insert(Rc::from("stale")); + + list.remove_entry(2); + list.group_ranges.clear(); + list.request_rebuild(); + list.flush_rebuild(); + + assert_eq!(list.store.n_items(), 4); + assert_eq!( + list.current_keys, + vec![ + RowKey::GroupHeader { + group: editor.clone() + }, + RowKey::Notification { id: 3 }, + RowKey::GroupHeader { + group: terminal.clone() + }, + RowKey::Notification { id: 1 }, + ] + ); + assert!(!list.group_ranges.contains_key(&browser)); + assert_eq!(list.group_ranges[&editor].start, 0); + assert_eq!(list.group_ranges[&terminal].start, 2); + assert!(!list.interned.iter().any(|key| key.as_ref() == "stale")); +} + +#[gtk::test] +fn flush_rebuild_applies_dirty_group_span_changes_incrementally() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + support::notification(3, "Browser"), + ], + Vec::new(), + ); + list.flush_rebuild(); + let terminal = list.entries.get(&2).expect("terminal").app_key.clone(); + let browser = list.entries.get(&3).expect("browser").app_key.clone(); + + list.toggle_group(terminal.as_ref()); + list.flush_rebuild(); + + assert_eq!(list.store.n_items(), 5); + assert_eq!( + list.current_keys, + vec![ + RowKey::GroupHeader { + group: browser.clone() + }, + RowKey::Notification { id: 3 }, + RowKey::GroupHeader { + group: terminal.clone() + }, + RowKey::Notification { id: 2 }, + RowKey::Notification { id: 1 }, + ] + ); + assert_eq!(list.group_ranges[&browser].start, 0); + assert_eq!(list.group_ranges[&browser].len, 2); + assert_eq!(list.group_ranges[&terminal].start, 2); + assert_eq!(list.group_ranges[&terminal].len, 3); +} + +#[gtk::test] +fn flush_rebuild_refreshes_dirty_group_even_when_span_is_stable() { + let mut list = support::make_list(); + list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + list.flush_rebuild(); + let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); + let view = list.entries.get(&1).expect("terminal").view.clone(); + let header = list.group_headers.get(&terminal).expect("header").clone(); + header.update(RowData::group_header( + terminal.clone(), + 99, + false, + view.clone(), + )); + + list.dirty_groups.insert(terminal.clone()); + list.request_rebuild(); + list.flush_rebuild(); + + assert_eq!(list.store.n_items(), 2); + assert_eq!(header.data().count, 1); + assert_eq!(list.group_ranges[&terminal].start, 0); + assert_eq!(list.group_ranges[&terminal].len, 2); +} + +#[gtk::test] +fn flush_rebuild_places_multiple_pending_dirty_groups_before_kept_group() { + let mut list = support::make_list(); + list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + list.flush_rebuild(); + + list.add_or_update(support::notification(2, "Browser"), true); + list.add_or_update(support::notification(3, "Editor"), true); + list.flush_rebuild(); + + let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); + let browser = list.entries.get(&2).expect("browser").app_key.clone(); + let editor = list.entries.get(&3).expect("editor").app_key.clone(); + assert_eq!(list.store.n_items(), 6); + assert_eq!(list.group_ranges[&editor].start, 0); + assert_eq!(list.group_ranges[&browser].start, 2); + assert_eq!(list.group_ranges[&terminal].start, 4); +} + +#[gtk::test] +fn flush_rebuild_removes_empty_dirty_group_and_keeps_following_ranges_valid() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Browser"), + ], + Vec::new(), + ); + list.flush_rebuild(); + let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); + let browser = list.entries.get(&2).expect("browser").app_key.clone(); + + list.remove_entry(2); + list.dirty_groups.insert(browser.clone()); + list.request_rebuild(); + list.flush_rebuild(); + + assert_eq!(list.store.n_items(), 2); + assert_eq!( + list.current_keys, + vec![ + RowKey::GroupHeader { + group: terminal.clone() + }, + RowKey::Notification { id: 1 }, + ] + ); + assert!(!list.group_ranges.contains_key(&browser)); + assert_eq!(list.group_ranges[&terminal].start, 0); + assert_eq!(list.group_ranges[&terminal].len, 2); +} + +#[gtk::test] +fn flush_rebuild_restores_missing_range_with_full_rebuild_fallback() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Browser"), + ], + Vec::new(), + ); + list.flush_rebuild(); + let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); + let browser = list.entries.get(&2).expect("browser").app_key.clone(); + list.group_ranges.remove(&terminal); + + list.request_rebuild(); + list.flush_rebuild(); + + assert_eq!(list.store.n_items(), 4); + assert_eq!(list.group_ranges[&browser].start, 0); + assert_eq!(list.group_ranges[&terminal].start, 2); +} + +#[gtk::test] +fn flush_rebuild_restores_store_length_with_full_rebuild_fallback() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Browser"), + ], + Vec::new(), + ); + list.flush_rebuild(); + list.store.remove(0); + + list.request_rebuild(); + list.flush_rebuild(); + + assert_eq!(list.store.n_items(), 4); + assert_eq!(list.current_keys.len(), 4); +} + +#[gtk::test] +fn flush_rebuild_batches_new_dirty_groups_before_kept_groups() { + let mut list = support::make_list(); + list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + list.flush_rebuild(); + + list.add_or_update(support::notification(2, "Browser"), true); + list.flush_rebuild(); + + let browser = list.entries.get(&2).expect("browser").app_key.clone(); + let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); + assert_eq!(list.store.n_items(), 4); + assert_eq!( + list.current_keys, + vec![ + RowKey::GroupHeader { + group: browser.clone() + }, + RowKey::Notification { id: 2 }, + RowKey::GroupHeader { + group: terminal.clone() + }, + RowKey::Notification { id: 1 }, + ] + ); + assert_eq!(list.group_ranges[&browser].start, 0); + assert_eq!(list.group_ranges[&terminal].start, 2); +} From bd707aaf63a916d679eac74e286ba40e68bfd23f Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:32:06 -0500 Subject: [PATCH 07/27] cover notification grouping rules Expand grouping tests for app names, fallback keys, and collapsed group presentation so grouped lists stay deterministic. --- .../src/ui/list/tests/grouping.rs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/unixnotis-center/src/ui/list/tests/grouping.rs b/crates/unixnotis-center/src/ui/list/tests/grouping.rs index e1296a15..55f04845 100644 --- a/crates/unixnotis-center/src/ui/list/tests/grouping.rs +++ b/crates/unixnotis-center/src/ui/list/tests/grouping.rs @@ -1,4 +1,5 @@ use super::*; +use crate::ui::list::test_support as support; fn normalize_filter_query(query: &str) -> Option { let trimmed = query.trim(); @@ -45,3 +46,69 @@ fn unicode_filter_still_matches_non_ascii_text() { assert!(contains_casefold("Äpfel und Birnen", &query)); assert!(!contains_casefold("Cafe", &query)); } + +#[gtk::test] +fn normalize_group_key_strips_invisible_chars_and_lowercases_ascii() { + let list = support::make_list(); + + assert_eq!( + list.normalize_group_key(" Te\u{200B}r\tminal ").as_ref(), + "terminal" + ); + assert_eq!(list.normalize_group_key("\u{200B}\u{200C}").as_ref(), ""); +} + +#[gtk::test] +fn expected_list_len_tracks_collapsed_expanded_and_filtered_groups() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + support::notification(3, "Browser"), + ], + Vec::new(), + ); + list.flush_rebuild(); + let terminal = list.entries.get(&2).expect("terminal").app_key.clone(); + + assert_eq!(list.expected_list_len(), 4); + + list.group_expanded.insert(terminal, true); + assert_eq!(list.expected_list_len(), 5); + + assert!(list.set_filter_query("browser")); + list.flush_rebuild(); + assert_eq!(list.expected_list_len(), 2); +} + +#[gtk::test] +fn group_visibility_and_entry_filter_cover_app_summary_and_body() { + let mut list = support::make_list(); + let mut terminal = support::notification(1, "Terminal"); + terminal.summary = "Build complete".to_string(); + terminal.body = "Package uploaded".to_string(); + list.seed( + vec![terminal, support::notification(2, "Browser")], + Vec::new(), + ); + let terminal_key = list.entries.get(&1).expect("terminal").app_key.clone(); + let terminal_ids = list.grouped_cache.get(&terminal_key).expect("ids").clone(); + + assert!(!list.group_has_visible_entries(&[])); + assert!(list.group_has_visible_entries(&terminal_ids)); + + assert!(list.set_filter_query("uploaded")); + assert!(list.group_has_visible_entries(&terminal_ids)); + + assert!(list.set_filter_query("missing")); + assert!(!list.group_has_visible_entries(&terminal_ids)); +} + +#[test] +fn ignorable_group_chars_cover_controls_and_zero_width_marks() { + assert!(is_ignorable_group_char('\n')); + assert!(is_ignorable_group_char('\u{200B}')); + assert!(is_ignorable_group_char('\u{FEFF}')); + assert!(!is_ignorable_group_char('a')); +} From c3f2a2a2810adb9a55ba4d5408fe56e6ce2f06cc Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:32:15 -0500 Subject: [PATCH 08/27] cover notification list mutations Expand mutation tests for clear, dismiss, group toggles, and stacked rows so state changes remain explicit. --- .../src/ui/list/tests/mutation.rs | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) diff --git a/crates/unixnotis-center/src/ui/list/tests/mutation.rs b/crates/unixnotis-center/src/ui/list/tests/mutation.rs index 13d5b967..d0dd5027 100644 --- a/crates/unixnotis-center/src/ui/list/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/list/tests/mutation.rs @@ -1,6 +1,7 @@ use unixnotis_core::{Action, NotificationImage}; use super::*; +use crate::ui::list::test_support as support; fn make_view(is_transient: bool) -> NotificationView { NotificationView { @@ -18,6 +19,36 @@ fn make_view(is_transient: bool) -> NotificationView { } } +fn view(id: u32, app_name: &str, is_transient: bool) -> NotificationView { + NotificationView { + id, + app_name: app_name.to_string(), + summary: format!("summary {id}"), + body: format!("body {id}"), + actions: Vec::new(), + urgency: 1, + is_transient, + image: NotificationImage::default(), + } +} + +#[test] +fn active_move_policy_covers_history_new_and_non_front_rows() { + assert!(should_move_active_to_front(true, false, false)); + assert!(should_move_active_to_front(true, true, true)); + assert!(should_move_active_to_front(false, false, false)); + assert!(should_move_active_to_front(false, true, false)); + assert!(!should_move_active_to_front(false, true, true)); +} + +#[test] +fn collapsed_group_stacked_policy_requires_collapsed_group_with_multiple_rows() { + assert!(!collapsed_group_is_stacked(false, 0)); + assert!(!collapsed_group_is_stacked(false, 1)); + assert!(collapsed_group_is_stacked(false, 2)); + assert!(!collapsed_group_is_stacked(true, 2)); +} + #[test] fn transient_rows_follow_config_when_closed() { assert!(!should_archive_entry( @@ -45,3 +76,223 @@ fn user_dismiss_never_archives_locally() { true )); } + +#[gtk::test] +fn add_or_update_new_active_notification_updates_storage_and_requests_rebuild() { + let mut list = support::make_list(); + + list.add_or_update(view(1, "Terminal", false), true); + + let key = list.entries.get(&1).expect("entry").app_key.clone(); + assert_eq!( + list.active_order.iter().copied().collect::>(), + vec![1] + ); + assert!(list.history_order.is_empty()); + assert_eq!( + list.group_active_index[&key] + .iter() + .copied() + .collect::>(), + vec![1] + ); + assert_eq!(list.grouped_cache[&key], vec![1]); + assert!(list.dirty_groups.contains(&key)); + assert!(list.needs_rebuild()); +} + +#[gtk::test] +fn add_or_update_existing_front_active_row_uses_in_place_fast_path() { + let mut list = support::make_list(); + list.seed(vec![view(1, "Terminal", false)], Vec::new()); + list.flush_rebuild(); + let key = list.entries.get(&1).expect("entry").app_key.clone(); + let mut updated = view(1, "Terminal", false); + updated.summary = "changed".to_string(); + + list.add_or_update(updated, true); + + assert!(!list.needs_rebuild()); + assert!(list.dirty_groups.is_empty()); + assert_eq!(list.grouped_cache[&key], vec![1]); + let row = list.entries.get(&1).expect("entry").item.data(); + assert_eq!( + row.notification.expect("notification").summary.as_str(), + "changed" + ); +} + +#[gtk::test] +fn add_or_update_non_front_history_row_does_not_replace_group_header_sample() { + let mut list = support::make_list(); + list.seed( + Vec::new(), + vec![view(1, "Terminal", false), view(2, "Terminal", false)], + ); + list.flush_rebuild(); + let key = list.entries.get(&2).expect("entry").app_key.clone(); + let header = list.group_headers.get(&key).expect("header").clone(); + assert_eq!( + header + .data() + .notification + .as_ref() + .expect("header sample") + .id, + 2 + ); + + let mut updated = view(1, "Terminal", false); + updated.summary = "older changed".to_string(); + list.add_or_update(updated, false); + + assert!(!list.needs_rebuild()); + assert_eq!( + header + .data() + .notification + .as_ref() + .expect("header sample") + .id, + 2 + ); +} + +#[gtk::test] +fn add_or_update_existing_active_row_moves_non_front_id_to_front() { + let mut list = support::make_list(); + list.seed( + vec![view(1, "Terminal", false), view(2, "Browser", false)], + Vec::new(), + ); + list.flush_rebuild(); + + list.add_or_update(view(1, "Terminal", false), true); + + let key = list.entries.get(&1).expect("entry").app_key.clone(); + assert_eq!( + list.active_order.iter().copied().collect::>(), + vec![1, 2] + ); + assert_eq!(list.group_active_index[&key].front().copied(), Some(1)); + assert!(list.dirty_groups.contains(&key)); + assert!(list.needs_rebuild()); +} + +#[gtk::test] +fn add_or_update_history_row_promoted_to_active_moves_between_indices() { + let mut list = support::make_list(); + list.seed(Vec::new(), vec![view(1, "Terminal", false)]); + list.flush_rebuild(); + + list.add_or_update(view(1, "Terminal", false), true); + + let key = list.entries.get(&1).expect("entry").app_key.clone(); + assert_eq!( + list.active_order.iter().copied().collect::>(), + vec![1] + ); + assert!(list.history_order.is_empty()); + assert_eq!( + list.group_active_index[&key] + .iter() + .copied() + .collect::>(), + vec![1] + ); + assert!(!list.group_history_index.contains_key(&key)); +} + +#[gtk::test] +fn add_or_update_app_name_change_reindexes_old_and_new_groups() { + let mut list = support::make_list(); + list.seed(vec![view(1, "Terminal", false)], Vec::new()); + list.flush_rebuild(); + let old_key = list.entries.get(&1).expect("entry").app_key.clone(); + + list.add_or_update(view(1, "Browser", false), true); + + let new_key = list.entries.get(&1).expect("entry").app_key.clone(); + assert_ne!(old_key.as_ref(), new_key.as_ref()); + assert!(!list.grouped_cache.contains_key(&old_key)); + assert_eq!(list.grouped_cache[&new_key], vec![1]); + assert!(list.dirty_groups.contains(&old_key)); + assert!(list.dirty_groups.contains(&new_key)); +} + +#[gtk::test] +fn group_span_matches_visible_shape_detects_missing_or_stale_ranges() { + let mut list = support::make_list(); + list.seed( + vec![view(1, "Terminal", false), view(2, "Terminal", false)], + Vec::new(), + ); + list.flush_rebuild(); + let key = list.entries.get(&1).expect("entry").app_key.clone(); + + assert!(list.group_span_matches_visible_shape(&key)); + + list.group_ranges.get_mut(&key).expect("range").len = 99; + assert!(!list.group_span_matches_visible_shape(&key)); + + list.group_ranges.remove(&key); + assert!(!list.group_span_matches_visible_shape(&key)); +} + +#[gtk::test] +fn mark_closed_dismissed_row_removes_entry_and_marks_group_dirty() { + let mut list = support::make_list(); + list.seed(vec![view(1, "Terminal", false)], Vec::new()); + list.flush_rebuild(); + let key = list.entries.get(&1).expect("entry").app_key.clone(); + + list.mark_closed(1, CloseReason::DismissedByUser); + + assert!(!list.entries.contains_key(&1)); + assert!(list.active_order.is_empty()); + assert!(!list.grouped_cache.contains_key(&key)); + assert!(list.dirty_groups.contains(&key)); + assert!(list.needs_rebuild()); +} + +#[gtk::test] +fn mark_closed_expired_row_archives_to_history_when_policy_allows_it() { + let mut list = support::make_list(); + list.seed(vec![view(1, "Terminal", false)], Vec::new()); + list.flush_rebuild(); + let key = list.entries.get(&1).expect("entry").app_key.clone(); + + list.mark_closed(1, CloseReason::Expired); + + assert!(list.active_order.is_empty()); + assert_eq!( + list.history_order.iter().copied().collect::>(), + vec![1] + ); + assert!(!list.group_active_index.contains_key(&key)); + assert_eq!( + list.group_history_index[&key] + .iter() + .copied() + .collect::>(), + vec![1] + ); + assert_eq!(list.grouped_cache[&key], vec![1]); + assert!(list.dirty_groups.contains(&key)); +} + +#[gtk::test] +fn mark_closed_archived_row_does_not_duplicate_existing_history_id() { + let mut list = support::make_list(); + list.seed(vec![view(1, "Terminal", false)], Vec::new()); + list.flush_rebuild(); + + list.mark_closed(1, CloseReason::Expired); + list.needs_rebuild = false; + list.mark_closed(1, CloseReason::Expired); + + assert_eq!( + list.history_order.iter().copied().collect::>(), + vec![1] + ); +} From 22f9f553784ce741a077c99f10b67a315353acac Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:32:28 -0500 Subject: [PATCH 09/27] cover list row widgets Add row tests for empty-state and group-header widgets so row modules have local coverage under their own tests folder. --- .../src/ui/list/row/tests/empty.rs | 45 +++++++++ .../src/ui/list/row/tests/group.rs | 97 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 crates/unixnotis-center/src/ui/list/row/tests/empty.rs create mode 100644 crates/unixnotis-center/src/ui/list/row/tests/group.rs diff --git a/crates/unixnotis-center/src/ui/list/row/tests/empty.rs b/crates/unixnotis-center/src/ui/list/row/tests/empty.rs new file mode 100644 index 00000000..6fc28c61 --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/row/tests/empty.rs @@ -0,0 +1,45 @@ +use gtk::prelude::*; + +use super::{build_empty_row, update_empty_row}; + +use crate::ui::list::test_support as support; + +fn empty_label(root: >k::Box) -> gtk::Label { + root.first_child() + .expect("empty row should have label") + .downcast::() + .expect("child should be label") +} + +#[gtk::test] +fn build_empty_row_sets_layout_and_text() { + support::init_gtk(); + + let root = build_empty_row("Nothing here"); + let label = empty_label(&root); + + assert!(root.has_css_class("unixnotis-empty")); + assert!(label.has_css_class("unixnotis-empty-label")); + assert_eq!(label.text().as_str(), "Nothing here"); + assert_eq!(root.valign(), gtk::Align::Center); +} + +#[gtk::test] +fn update_empty_row_changes_label_text() { + support::init_gtk(); + let root = build_empty_row("Nothing here"); + + update_empty_row(&root, "All clear"); + + assert_eq!(empty_label(&root).text().as_str(), "All clear"); +} + +#[gtk::test] +fn update_empty_row_tolerates_missing_label() { + support::init_gtk(); + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + + update_empty_row(&root, "All clear"); + + assert!(root.first_child().is_none()); +} diff --git a/crates/unixnotis-center/src/ui/list/row/tests/group.rs b/crates/unixnotis-center/src/ui/list/row/tests/group.rs new file mode 100644 index 00000000..ae7ffe15 --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/row/tests/group.rs @@ -0,0 +1,97 @@ +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{NotificationImage, NotificationView}; + +use super::{build_group_row, update_group_row}; +use crate::dbus::UiEvent; +use crate::ui::icons::IconResolver; +use crate::ui::list::item::{RowData, RowKind}; + +use crate::ui::list::test_support as support; + +fn notification(app_name: &str) -> Rc { + Rc::new(NotificationView { + id: 1, + app_name: app_name.to_string(), + summary: "summary".to_string(), + body: "body".to_string(), + actions: Vec::new(), + urgency: 1, + is_transient: false, + image: NotificationImage::default(), + }) +} + +fn header_button(root: >k::Box) -> gtk::Button { + root.first_child() + .expect("group row should have button") + .downcast::() + .expect("group child should be button") +} + +#[gtk::test] +fn update_group_row_sets_title_count_and_expanded_state() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let data = RowData::group_header(Rc::from("terminal"), 3, false, notification("Terminal")); + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + assert_eq!(widgets.title.text().as_str(), "Terminal"); + assert_eq!(widgets.count.text().as_str(), "3"); + assert_eq!( + widgets.chevron.icon_name().as_deref(), + Some("pan-down-symbolic") + ); + assert!(root.has_css_class("unixnotis-group-row-collapsed")); + assert!(!root.has_css_class("unixnotis-group-row-expanded")); + + let data = RowData::group_header(Rc::from("terminal"), 4, true, notification("Terminal")); + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + assert_eq!(widgets.count.text().as_str(), "4"); + assert_eq!( + widgets.chevron.icon_name().as_deref(), + Some("pan-up-symbolic") + ); + assert!(!root.has_css_class("unixnotis-group-row-collapsed")); + assert!(root.has_css_class("unixnotis-group-row-expanded")); +} + +#[gtk::test] +fn update_group_row_falls_back_to_group_key_without_sample() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let data = RowData { + kind: RowKind::GroupHeader, + group_key: Rc::from("terminal"), + count: 1, + notification: None, + ..RowData::default() + }; + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + assert_eq!(widgets.title.text().as_str(), "terminal"); + assert!(!widgets.icon.get_visible()); + assert!(root.has_css_class("unixnotis-group-row-no-icon")); +} + +#[gtk::test] +fn group_header_click_sends_toggle_event() { + support::init_gtk(); + let (event_tx, event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let data = RowData::group_header(Rc::from("terminal"), 2, true, notification("Terminal")); + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + header_button(&root).emit_clicked(); + + match event_rx.try_recv().expect("toggle event") { + UiEvent::GroupToggled(group) => assert_eq!(group, "terminal"), + event => panic!("expected group toggle event, got {event:?}"), + } +} From 0883d1ce92e1f5fcdf60bfd73bfe10ad642451c8 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:32:38 -0500 Subject: [PATCH 10/27] split notification row label tests Move notification row fixtures and label-clamping checks into focused test files under the notification row module. --- .../ui/list/row/notification/tests/labels.rs | 50 +++++++++++++ .../ui/list/row/notification/tests/support.rs | 73 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 crates/unixnotis-center/src/ui/list/row/notification/tests/labels.rs create mode 100644 crates/unixnotis-center/src/ui/list/row/notification/tests/support.rs diff --git a/crates/unixnotis-center/src/ui/list/row/notification/tests/labels.rs b/crates/unixnotis-center/src/ui/list/row/notification/tests/labels.rs new file mode 100644 index 00000000..591c9419 --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/row/notification/tests/labels.rs @@ -0,0 +1,50 @@ +//! Text label rules for notification rows + +use super::state::MAX_SUMMARY_LABEL_CHARS; +use super::update::{clamp_action_label_text, optional_label_state}; + +#[test] +fn panel_summary_row_hides_when_text_is_empty() { + // Empty summaries should not leave a blank strip above the body + let state = optional_label_state("", MAX_SUMMARY_LABEL_CHARS); + + assert!(!state.visible); + assert!(state.text.is_empty()); +} + +#[test] +fn panel_summary_row_hides_when_text_is_only_whitespace() { + // Space-only payloads should collapse the same as truly empty payloads + let state = optional_label_state("\n\t ", MAX_SUMMARY_LABEL_CHARS); + + assert!(!state.visible); + assert!(state.text.is_empty()); +} + +#[test] +fn panel_summary_row_shows_when_text_has_real_content() { + // Leading and trailing space should not hide actual notification text + let state = optional_label_state(" hello ", MAX_SUMMARY_LABEL_CHARS); + + assert!(state.visible); + assert_eq!(state.text.as_ref(), " hello "); +} + +#[test] +fn panel_summary_row_hides_when_clamp_intentionally_blanks_text() { + // Zero-char clamps should collapse the row instead of leaving an empty label + let state = optional_label_state("hello", 0); + + assert!(!state.visible); + assert!(state.text.is_empty()); +} + +#[test] +fn panel_action_labels_are_clamped_before_button_build() { + // Long labels should be shortened before the action row sees them + let long_label = "This action label is much longer than the row should allow"; + let rendered = clamp_action_label_text(long_label); + + assert!(rendered.len() < long_label.len()); + assert!(rendered.ends_with('…')); +} diff --git a/crates/unixnotis-center/src/ui/list/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/list/row/notification/tests/support.rs new file mode 100644 index 00000000..88c4e7ee --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/row/notification/tests/support.rs @@ -0,0 +1,73 @@ +//! Shared fixtures for notification-row tests + +use std::rc::Rc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use gtk::prelude::*; +use unixnotis_core::{NotificationImage, NotificationView, Urgency}; + +use crate::ui::list::item::{RowData, RowPresentation}; +use crate::ui::list::test_support as support; + +use super::build::build_notification_row; +use super::state::NotificationRowWidgets; + +pub(super) fn sample_notification() -> NotificationView { + NotificationView { + id: 1, + app_name: "demo".to_string(), + summary: "summary".to_string(), + body: "body".to_string(), + actions: Vec::new(), + urgency: Urgency::Normal as u8, + is_transient: false, + image: NotificationImage::default(), + } +} + +pub(super) fn notification_row() -> (gtk::Box, NotificationRowWidgets) { + support::init_gtk(); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + build_notification_row(command_tx) +} + +pub(super) fn row_data( + notification: Rc, + is_active: bool, + stacked: bool, + stack_depth: u8, + show_metadata: bool, + show_thumbnail: bool, +) -> RowData { + RowData::notification( + Rc::from(notification.app_name.to_ascii_lowercase()), + notification, + stacked, + stack_depth, + false, + is_active, + RowPresentation { + received_at_ms: current_millis(), + show_metadata, + show_thumbnail, + }, + ) +} + +pub(super) fn current_millis() -> i64 { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_millis(); + i64::try_from(millis).expect("current millis should fit i64") +} + +pub(super) fn child_count(container: >k::Box) -> usize { + let mut count = 0; + let mut child = container.first_child(); + while let Some(widget) = child { + count += 1; + child = widget.next_sibling(); + } + count +} From bdd6e82a14ca2a6a4fef99350d97e4dcd5744ad2 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:32:53 -0500 Subject: [PATCH 11/27] cover notification row metadata Add focused tests for urgency labels and relative-time badges used by notification row metadata. --- .../list/row/notification/tests/metadata.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 crates/unixnotis-center/src/ui/list/row/notification/tests/metadata.rs diff --git a/crates/unixnotis-center/src/ui/list/row/notification/tests/metadata.rs b/crates/unixnotis-center/src/ui/list/row/notification/tests/metadata.rs new file mode 100644 index 00000000..55bd2604 --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/row/notification/tests/metadata.rs @@ -0,0 +1,39 @@ +//! Metadata and relative-time rules for notification rows + +use unixnotis_core::Urgency; + +use super::test_support::{current_millis, sample_notification}; +use super::update::{notification_meta_label, relative_time_badge}; + +#[test] +fn notification_metadata_falls_back_to_urgency_label() { + let mut notification = sample_notification(); + notification.urgency = Urgency::Critical as u8; + + assert_eq!(notification_meta_label(¬ification), "ALERT"); +} + +#[test] +fn notification_metadata_labels_cover_low_and_normal_urgency() { + let mut notification = sample_notification(); + notification.urgency = Urgency::Low as u8; + assert_eq!(notification_meta_label(¬ification), "LOW"); + + notification.urgency = Urgency::Normal as u8; + assert_eq!(notification_meta_label(¬ification), "NOTICE"); +} + +#[test] +fn empty_timestamp_hides_relative_time_badge() { + assert!(relative_time_badge(0).is_empty()); +} + +#[test] +fn relative_time_badge_formats_minutes_hours_and_days() { + let now = current_millis(); + + assert_eq!(relative_time_badge(now - 30_000), "now"); + assert_eq!(relative_time_badge(now - 5 * 60_000), "5m"); + assert_eq!(relative_time_badge(now - 2 * 3_600_000), "2h"); + assert_eq!(relative_time_badge(now - 3 * 86_400_000), "3d"); +} From 3587c3d6641b9403671989e7e348c4aefff8a373 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:33:06 -0500 Subject: [PATCH 12/27] cover notification row thumbnails Add tests for thumbnail source detection and row thumbnail visibility classes when images are present or absent. --- .../list/row/notification/tests/thumbnail.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 crates/unixnotis-center/src/ui/list/row/notification/tests/thumbnail.rs diff --git a/crates/unixnotis-center/src/ui/list/row/notification/tests/thumbnail.rs b/crates/unixnotis-center/src/ui/list/row/notification/tests/thumbnail.rs new file mode 100644 index 00000000..9ae9516c --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/row/notification/tests/thumbnail.rs @@ -0,0 +1,53 @@ +//! Thumbnail visibility rules for notification rows + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::hooks; + +use crate::ui::icons::IconResolver; + +use super::test_support::{notification_row, row_data, sample_notification}; +use super::update::{notification_has_thumbnail, update_notification_row}; + +#[test] +fn notification_thumbnail_only_uses_real_image_sources() { + let mut notification = sample_notification(); + assert!(!notification_has_thumbnail(¬ification)); + + notification.image.image_path = "/tmp/demo.png".to_string(); + assert!(notification_has_thumbnail(¬ification)); +} + +#[gtk::test] +fn update_notification_row_hides_optional_text_and_thumbnail_when_absent() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.summary = " ".to_string(); + notification.body.clear(); + let data = row_data(Rc::new(notification), false, false, 0, false, true); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.summary_label.get_visible()); + assert!(!row.body_label.get_visible()); + assert!(!row.thumbnail.get_visible()); + assert!(!row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} + +#[gtk::test] +fn update_notification_row_shows_thumbnail_when_config_and_image_allow_it() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.image_path = "/tmp/demo.png".to_string(); + let data = row_data(Rc::new(notification), false, false, 0, false, true); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.get_visible()); + assert!(row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); + assert!(!row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} From d6e756de80eb1f31d30b6b723345ae5ca316825c Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:33:17 -0500 Subject: [PATCH 13/27] cover notification row state Add notification row tests for active, critical, stacked, metadata, and footer CSS state updates. --- .../ui/list/row/notification/tests/state.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 crates/unixnotis-center/src/ui/list/row/notification/tests/state.rs diff --git a/crates/unixnotis-center/src/ui/list/row/notification/tests/state.rs b/crates/unixnotis-center/src/ui/list/row/notification/tests/state.rs new file mode 100644 index 00000000..be9553ff --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/row/notification/tests/state.rs @@ -0,0 +1,60 @@ +//! Visual state updates for notification rows + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{hooks, Action, Urgency}; + +use crate::ui::icons::IconResolver; + +use super::test_support::{notification_row, row_data, sample_notification}; +use super::update::update_notification_row; + +#[gtk::test] +fn update_notification_row_applies_state_classes_and_text() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.urgency = Urgency::Critical as u8; + let notification = Rc::new(notification); + let data = row_data(notification, true, true, 2, false, false); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.card.has_css_class(hooks::shared_state::CRITICAL)); + assert!(row.card.has_css_class(hooks::shared_state::ACTIVE)); + assert!(row.card.has_css_class(hooks::shared_state::STACKED)); + assert!(row.card.has_css_class(hooks::panel_card::GROUP_COLLAPSED)); + assert!(!row.card.has_css_class(hooks::panel_card::GROUP_EXPANDED)); + assert!(row.stack_ghost_1.get_visible()); + assert!(row.stack_ghost_2.get_visible()); + assert_eq!(row.app_label.text().as_str(), "demo"); + assert_eq!(row.summary_label.text().as_str(), "summary"); + assert_eq!(row.body_label.text().as_str(), "body"); + assert_eq!(row.notify_id.get(), 1); + assert!(row.icon_sig.borrow().is_some()); +} + +#[gtk::test] +fn update_notification_row_shows_metadata_lanes_and_footer_state() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.is_transient = true; + notification.actions = vec![Action { + key: "open".to_string(), + label: "Open".to_string(), + }]; + let data = row_data(Rc::new(notification), false, false, 0, true, true); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.meta_top.get_visible()); + assert!(row.footer.get_visible()); + assert!(row.meta_label.get_visible()); + assert_eq!(row.meta_label.text().as_str(), "NOTICE"); + assert!(row.time_badge.get_visible()); + assert_eq!(row.footer_left.text().as_str(), "TRANSIENT"); + assert!(row.footer_right.get_visible()); + assert_eq!(row.footer_right.text().as_str(), "1 ACTIONS"); +} From c242b004c4b9a80667dca9d669b683d3681fd15f Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:33:26 -0500 Subject: [PATCH 14/27] cover notification row actions Add notification row tests for action cache rebuilds and single-command click handling. --- .../ui/list/row/notification/tests/actions.rs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 crates/unixnotis-center/src/ui/list/row/notification/tests/actions.rs diff --git a/crates/unixnotis-center/src/ui/list/row/notification/tests/actions.rs b/crates/unixnotis-center/src/ui/list/row/notification/tests/actions.rs new file mode 100644 index 00000000..295c088e --- /dev/null +++ b/crates/unixnotis-center/src/ui/list/row/notification/tests/actions.rs @@ -0,0 +1,89 @@ +//! Action button update rules for notification rows + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{hooks, Action}; + +use crate::dbus::UiCommand; +use crate::ui::icons::IconResolver; + +use super::test_support::{child_count, notification_row, row_data, sample_notification}; +use super::update::update_notification_row; + +#[gtk::test] +fn update_notification_row_rebuilds_actions_only_when_signature_changes() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "open".to_string(), + label: "Open".to_string(), + }]; + let data = row_data(Rc::new(notification.clone()), true, false, 0, false, true); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + assert_eq!(child_count(&row.actions_box), 1); + assert!(row.card.has_css_class(hooks::panel_card::HAS_ACTIONS)); + assert!(!row.card.has_css_class(hooks::panel_card::NO_ACTIONS)); + assert_eq!( + row.action_cache.borrow().as_slice(), + &[("open".to_string(), "Open".to_string())] + ); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + assert_eq!(child_count(&row.actions_box), 1); + + notification.actions[0].label = "Open notification details now".to_string(); + let data = row_data(Rc::new(notification), true, false, 0, false, true); + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(child_count(&row.actions_box), 1); + assert!(row.action_cache.borrow()[0] + .1 + .starts_with("Open notification")); + + notification = sample_notification(); + notification.actions = vec![Action { + key: "reply".to_string(), + label: "Open notification details now".to_string(), + }]; + let data = row_data(Rc::new(notification), true, false, 0, false, true); + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(child_count(&row.actions_box), 1); + assert_eq!(row.action_cache.borrow()[0].0, "reply"); +} + +#[gtk::test] +fn update_notification_row_action_button_sends_command_once_per_click_window() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "open".to_string(), + label: "Open".to_string(), + }]; + let data = row_data(Rc::new(notification), true, false, 0, false, false); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + let button = row + .actions_box + .first_child() + .expect("action button") + .downcast::() + .expect("child should be action button"); + button.emit_clicked(); + + match command_rx.try_recv().expect("action command") { + UiCommand::InvokeAction { id, action_key } => { + assert_eq!(id, 1); + assert_eq!(action_key, "open"); + } + command => panic!("expected action command, got {command:?}"), + } + + button.emit_clicked(); + assert!(command_rx.try_recv().is_err()); +} From ebc262a11232a8e352f218bf2c7bd1a11391d937 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:33:36 -0500 Subject: [PATCH 15/27] sanitize notification markup in views Strip notification markup and decode common entities before panel, popup, and history views render plain GTK labels. --- .../unixnotis-core/src/model/notification.rs | 143 +++++++++++++++++- 1 file changed, 139 insertions(+), 4 deletions(-) diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index e6649642..0fceda9a 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -51,8 +51,8 @@ impl Notification { NotificationView { id: self.id, app_name: self.app_name.clone(), - summary: self.summary.clone(), - body: self.body.clone(), + summary: notification_plain_text(&self.summary), + body: notification_plain_text(&self.body), actions: self.actions.clone(), urgency: self.urgency.as_u8(), // Center and popup policy both need the transient bit to stay in sync @@ -68,8 +68,8 @@ impl Notification { NotificationView { id: self.id, app_name: self.app_name.clone(), - summary: self.summary.clone(), - body: self.body.clone(), + summary: notification_plain_text(&self.summary), + body: notification_plain_text(&self.body), actions: self.actions.clone(), urgency: self.urgency.as_u8(), // History policy still depends on the transient bit in panel rows @@ -112,6 +112,141 @@ impl Notification { } } +fn notification_plain_text(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '<' => { + // Notification bodies may contain simple HTML-like tags from desktop senders + let mut tag = String::new(); + let mut closed = false; + for next in chars.by_ref() { + if next == '>' { + // A closed tag is formatting, not text that belongs in a GTK label + closed = true; + break; + } + tag.push(next); + } + if closed { + // Block-like tags get spacing so joined text still reads naturally + push_tag_spacing(&mut output, &tag); + } else { + // Broken markup stays visible so the sender's text is not silently lost + output.push('<'); + output.push_str(&tag); + } + } + // Entities are decoded here because GTK labels receive plain text + '&' => output.push_str(&decode_entity(&mut chars)), + _ => output.push(ch), + } + } + + // Tag stripping can leave noisy gaps, so normalize once at the end + collapse_notification_whitespace(&output) +} + +fn push_tag_spacing(output: &mut String, tag: &str) { + // Trim "/" first so opening and closing tags use the same spacing rule + let tag_name = tag + .trim_start_matches('/') + .split(|ch: char| ch.is_whitespace() || ch == '/') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + if matches!(tag_name.as_str(), "br" | "p" | "div" | "li" | "tr") { + // These tags normally separate chunks of text + output.push('\n'); + } else { + // Inline tags still need a small break so words do not run together + output.push(' '); + } +} + +fn decode_entity(chars: &mut std::iter::Peekable) -> String +where + I: Iterator, +{ + let mut entity = String::new(); + let mut terminated = false; + while let Some(&next) = chars.peek() { + chars.next(); + if next == ';' { + // Only a semicolon-ended entity should be decoded + terminated = true; + break; + } + if entity.len() >= 16 { + // Very long entities are likely plain text or malformed sender data + return format!("&{entity}{next}"); + } + entity.push(next); + } + + if !terminated { + // Unterminated entities are kept literal, including common names like & + return format!("&{entity}"); + } + + match entity.as_str() { + // Keep the named set intentionally small and predictable + "amp" => "&".to_string(), + "apos" => "'".to_string(), + "gt" => ">".to_string(), + "lt" => "<".to_string(), + "nbsp" => " ".to_string(), + "quot" => "\"".to_string(), + _ => decode_numeric_entity(&entity).unwrap_or_else(|| format!("&{entity};")), + } +} + +fn decode_numeric_entity(entity: &str) -> Option { + // Desktop notifications can send both decimal and hex numeric entities + let value = if let Some(hex) = entity + .strip_prefix("#x") + .or_else(|| entity.strip_prefix("#X")) + { + u32::from_str_radix(hex, 16).ok()? + } else { + entity.strip_prefix('#')?.parse::().ok()? + }; + // Invalid scalar values are not text, so the caller keeps the original entity + char::from_u32(value).map(|ch| ch.to_string()) +} + +fn collapse_notification_whitespace(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut saw_space = false; + let mut saw_newline = false; + + for ch in input.chars() { + if ch == '\n' { + // Keep one newline for block boundaries, but avoid tall empty gaps + if !output.is_empty() && !saw_newline { + output.push('\n'); + } + saw_space = false; + saw_newline = true; + } else if ch.is_whitespace() { + // Plain spaces collapse to one space unless a newline already separated text + if !output.is_empty() && !saw_space && !saw_newline { + output.push(' '); + } + saw_space = true; + } else { + // Any real character resets spacing guards + output.push(ch); + saw_space = false; + saw_newline = false; + } + } + + output.trim().to_string() +} + /// Serializable view of a notification for D-Bus signals. #[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] pub struct NotificationView { From ba90310855b93553b3ad9e56f691e5d2f5eae8e8 Mon Sep 17 00:00:00 2001 From: locainin Date: Wed, 1 Jul 2026 17:33:49 -0500 Subject: [PATCH 16/27] cover notification text sanitizing Add notification model tests for tag stripping, whitespace normalization, entity decoding, and malformed markup fallbacks. --- .../src/model/tests/notification.rs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index 4fed03e4..4858d250 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -74,6 +74,73 @@ fn notification_view_keeps_ui_fields_and_transient_policy_flag() { assert!(view.image.has_image_data); } +#[test] +fn notification_view_strips_markup_from_ui_text() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.summary = "Crash Reporting System".to_string(); + notification.body = "/usr/lib/drkonqi has encountered "fatal"
error & closed.".to_string(); + + let view = notification.to_view(); + + // UI labels render plain text, so notification markup must be normalized first + assert_eq!(view.summary, "Crash Reporting System"); + assert_eq!( + view.body, + "/usr/lib/drkonqi has encountered \"fatal\"\nerror & closed." + ); +} + +#[test] +fn notification_view_decodes_numeric_entities() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.body = "Temperature: -5°C & falling".to_string(); + + let view = notification.to_view(); + + // Numeric entities appear in real notification bodies from markup-aware senders + assert_eq!(view.body, "Temperature: -5°C & falling"); +} + +#[test] +fn notification_view_decodes_common_named_entities() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.body = "Use <tag> and don't panic".to_string(); + + let view = notification.to_view(); + + assert_eq!(view.body, "Use and don't panic"); +} + +#[test] +fn notification_view_treats_self_closing_break_as_newline() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.body = "Line one
Line two".to_string(); + + let view = notification.to_view(); + + assert_eq!(view.body, "Line one\nLine two"); +} + +#[test] +fn notification_view_collapses_repeated_block_tag_newlines() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.body = "Line one

Line two".to_string(); + + let view = notification.to_view(); + + assert_eq!(view.body, "Line one\nLine two"); +} + +#[test] +fn notification_view_preserves_unterminated_entity_text() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.body = "Fish &chips".to_string(); + + let view = notification.to_view(); + + assert_eq!(view.body, "Fish &chips"); +} + #[test] fn list_view_strips_raw_image_bytes_but_keeps_icon_identifiers() { let notification = notification_with_image(image_with_raw_bytes()); From b5a1b3badbff45efd2bfc976f831a0b7374346cd Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 2 Jul 2026 21:07:23 -0500 Subject: [PATCH 17/27] bump workspace release version Set the UnixNotis workspace packages to v1.0.0 so release archives, installer UI, and GitHub tags share one version line. --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82862f9e..5345e8e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1958,7 +1958,7 @@ dependencies = [ [[package]] name = "noticenterctl" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "blake3", @@ -3318,7 +3318,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "unixnotis-center" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "async-channel", @@ -3347,7 +3347,7 @@ dependencies = [ [[package]] name = "unixnotis-core" -version = "0.1.0" +version = "1.0.0" dependencies = [ "chrono", "serde", @@ -3361,7 +3361,7 @@ dependencies = [ [[package]] name = "unixnotis-daemon" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "chrono", @@ -3381,7 +3381,7 @@ dependencies = [ [[package]] name = "unixnotis-installer" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "chrono", @@ -3396,7 +3396,7 @@ dependencies = [ [[package]] name = "unixnotis-popups" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "async-channel", @@ -3418,7 +3418,7 @@ dependencies = [ [[package]] name = "unixnotis-ui" -version = "0.1.0" +version = "1.0.0" dependencies = [ "gtk4", "notify", diff --git a/Cargo.toml b/Cargo.toml index 972bd204..d32213f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.1.0" +version = "1.0.0" edition = "2021" license = "MIT" From d6b3033b738b3d160134408c757dccd26b6db1d0 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 2 Jul 2026 21:07:29 -0500 Subject: [PATCH 18/27] discover installer release archives Teach install path discovery to recognize validated release archives from env overrides or the current installer location before falling back to source workspace discovery. --- .../src/paths/discovery.rs | 76 ++++++++++++++++++- crates/unixnotis-installer/src/paths/index.rs | 2 +- .../src/paths/tests/general.rs | 71 +++++++++++++++++ .../src/paths/tests/index.rs | 6 +- 4 files changed, 152 insertions(+), 3 deletions(-) diff --git a/crates/unixnotis-installer/src/paths/discovery.rs b/crates/unixnotis-installer/src/paths/discovery.rs index d272fb51..4b601e32 100644 --- a/crates/unixnotis-installer/src/paths/discovery.rs +++ b/crates/unixnotis-installer/src/paths/discovery.rs @@ -1,5 +1,6 @@ //! Install path discovery and service-manager construction +use std::collections::BTreeSet; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -14,6 +15,9 @@ use super::dirs::{ s6_user_dir_candidates, systemd_user_dir, }; +pub const RELEASE_MANIFEST_FILE: &str = "unixnotis-release.json"; +pub const RELEASE_BIN_DIR: &str = "bin"; + pub struct InstallPaths { pub repo_root: PathBuf, pub bin_dir: PathBuf, @@ -63,6 +67,19 @@ impl InstallPaths { }) .collect() } + + pub fn is_release_archive(&self) -> bool { + // Release archives carry a manifest beside the installer instead of a workspace Cargo.toml + self.release_manifest_path().is_file() + } + + pub fn release_manifest_path(&self) -> PathBuf { + self.repo_root.join(RELEASE_MANIFEST_FILE) + } + + pub fn release_binary_dir(&self) -> PathBuf { + self.repo_root.join(RELEASE_BIN_DIR) + } } fn service_manager_from_selection( @@ -122,6 +139,20 @@ fn service_manager_choice_from_environment() -> Result { } fn find_repo_root() -> Result { + if let Ok(root) = env::var("UNIXNOTIS_RELEASE_ROOT") { + // Manual release testing can point the installer at an unpacked archive + let root_path = PathBuf::from(root); + // Validate the manifest and bundled binaries before trusting the override + if is_unixnotis_release_archive(&root_path) { + return Ok(root_path); + } + } + + if let Some(root) = find_release_root_from_current_exe() { + // Downloaded archives should resolve here before the source checkout walk below + return Ok(root); + } + if let Ok(root) = env::var("UNIXNOTIS_REPO_ROOT") { let root_path = PathBuf::from(root); let cargo = root_path.join("Cargo.toml"); @@ -144,7 +175,7 @@ fn find_repo_root() -> Result { } Err(anyhow!( - "repository root not found (set UNIXNOTIS_REPO_ROOT or run from UnixNotis repo)" + "repository root or release archive not found (set UNIXNOTIS_REPO_ROOT, set UNIXNOTIS_RELEASE_ROOT, or run from UnixNotis repo/release)" )) } @@ -157,3 +188,46 @@ pub(in crate::paths) fn is_unixnotis_repo(cargo_toml: &Path) -> bool { && contents.contains("crates/unixnotis-daemon") && contents.contains("crates/unixnotis-core") } + +fn find_release_root_from_current_exe() -> Option { + // Installed tarballs run the installer from the archive root, next to the manifest + let exe = env::current_exe().ok()?; + let root = exe.parent()?.to_path_buf(); + // This check prevents a random copied installer from pretending to be a full release + is_unixnotis_release_archive(&root).then_some(root) +} + +pub(in crate::paths) fn is_unixnotis_release_archive(root: &Path) -> bool { + let manifest = root.join(RELEASE_MANIFEST_FILE); + let Ok(contents) = fs::read_to_string(manifest) else { + return false; + }; + let Ok(manifest) = serde_json::from_str::(&contents) else { + return false; + }; + let release_bin_dir = root.join(RELEASE_BIN_DIR); + // The archive layout is intentionally simple: installer at root, runtime tools in bin + if !release_bin_dir.is_dir() { + return false; + } + + // All managed tools must be present so a downloaded installer fails before any file changes + let names = manifest.binaries.into_iter().collect::>(); + release_archive_binaries() + .iter() + .all(|binary| names.contains(*binary) && release_bin_dir.join(binary).is_file()) +} + +fn release_archive_binaries() -> [&'static str; 4] { + [ + "unixnotis-daemon", + "unixnotis-popups", + "unixnotis-center", + "noticenterctl", + ] +} + +#[derive(serde::Deserialize)] +struct ReleaseArchiveManifest { + binaries: Vec, +} diff --git a/crates/unixnotis-installer/src/paths/index.rs b/crates/unixnotis-installer/src/paths/index.rs index c435548f..cd58f29a 100644 --- a/crates/unixnotis-installer/src/paths/index.rs +++ b/crates/unixnotis-installer/src/paths/index.rs @@ -11,7 +11,7 @@ pub use discovery::InstallPaths; pub use format::format_with_home; #[cfg(test)] -use discovery::is_unixnotis_repo; +use discovery::{is_unixnotis_release_archive, is_unixnotis_repo}; #[cfg(test)] #[path = "tests/index.rs"] diff --git a/crates/unixnotis-installer/src/paths/tests/general.rs b/crates/unixnotis-installer/src/paths/tests/general.rs index db84bbd7..ba14b2af 100644 --- a/crates/unixnotis-installer/src/paths/tests/general.rs +++ b/crates/unixnotis-installer/src/paths/tests/general.rs @@ -70,6 +70,58 @@ version = "0.1.0" let _ = fs::remove_dir_all(root); } +#[test] +fn release_archive_detection_requires_manifest_and_bundled_binaries() { + let root = env::temp_dir().join(format!( + "unixnotis-release-archive-detect-{}", + std::process::id() + )); + let bin_dir = root.join(RELEASE_BIN_DIR); + fs::create_dir_all(&bin_dir).expect("release bin dir"); + fs::write( + root.join(RELEASE_MANIFEST_FILE), + r#"{"version":"1.0.0","binaries":["unixnotis-daemon","unixnotis-popups","unixnotis-center","noticenterctl"]}"#, + ) + .expect("release manifest"); + + for binary in [ + "unixnotis-daemon", + "unixnotis-popups", + "unixnotis-center", + "noticenterctl", + ] { + fs::write(bin_dir.join(binary), format!("binary:{binary}")).expect("release binary"); + } + + assert!(is_unixnotis_release_archive(&root)); + + fs::remove_file(bin_dir.join("noticenterctl")).expect("remove required binary"); + assert!(!is_unixnotis_release_archive(&root)); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn release_root_discovery_prefers_explicit_archive_override() { + let _guard = env_lock(); + let root = env::temp_dir().join(format!("unixnotis-release-root-{}", std::process::id())); + write_release_archive(&root); + let previous_release = set_env( + "UNIXNOTIS_RELEASE_ROOT", + Some(root.to_string_lossy().as_ref()), + ); + let previous_repo = set_env("UNIXNOTIS_REPO_ROOT", None); + + let discovered = InstallPaths::discover_repo_root().expect("release root"); + + // Downloaded archives do not carry Cargo.toml, so release roots need their own lookup path + assert_eq!(discovered, root); + + restore_env("UNIXNOTIS_REPO_ROOT", previous_repo); + restore_env("UNIXNOTIS_RELEASE_ROOT", previous_release); + let _ = fs::remove_dir_all(root); +} + #[test] fn service_manager_choice_accepts_cli_names() { assert_eq!( @@ -90,6 +142,25 @@ fn service_manager_choice_accepts_cli_names() { ); } +fn write_release_archive(root: &std::path::Path) { + let bin_dir = root.join(RELEASE_BIN_DIR); + fs::create_dir_all(&bin_dir).expect("release bin dir"); + fs::write( + root.join(RELEASE_MANIFEST_FILE), + r#"{"version":"1.0.0","binaries":["unixnotis-daemon","unixnotis-popups","unixnotis-center","noticenterctl"]}"#, + ) + .expect("release manifest"); + + for binary in [ + "unixnotis-daemon", + "unixnotis-popups", + "unixnotis-center", + "noticenterctl", + ] { + fs::write(bin_dir.join(binary), format!("binary:{binary}")).expect("release binary"); + } +} + #[test] fn empty_service_manager_choice_keeps_env_default_but_rejects_explicit_cli_value() { // Environment parsing keeps the historical fallback for an empty export diff --git a/crates/unixnotis-installer/src/paths/tests/index.rs b/crates/unixnotis-installer/src/paths/tests/index.rs index 803acee7..3511b8d6 100644 --- a/crates/unixnotis-installer/src/paths/tests/index.rs +++ b/crates/unixnotis-installer/src/paths/tests/index.rs @@ -1,4 +1,8 @@ -use super::{format_with_home, is_unixnotis_repo, InstallPaths, ServiceManagerChoice}; +use super::{ + discovery::{RELEASE_BIN_DIR, RELEASE_MANIFEST_FILE}, + format_with_home, is_unixnotis_release_archive, is_unixnotis_repo, InstallPaths, + ServiceManagerChoice, +}; use std::env; use std::fs; use std::path::PathBuf; From 6dcff728a0082abebf65bf4dec730cea7a9d3711 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 2 Jul 2026 21:07:35 -0500 Subject: [PATCH 19/27] read release archive binary manifests Resolve bundled release binaries from unixnotis-release.json and keep source checkout validation tied to Cargo metadata. --- .../src/actions/actions_state/binaries.rs | 40 +++++++++++++++++-- .../actions/actions_state/tests/binaries.rs | 30 +++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/unixnotis-installer/src/actions/actions_state/binaries.rs b/crates/unixnotis-installer/src/actions/actions_state/binaries.rs index 3d915dcd..5f84c82a 100644 --- a/crates/unixnotis-installer/src/actions/actions_state/binaries.rs +++ b/crates/unixnotis-installer/src/actions/actions_state/binaries.rs @@ -16,7 +16,7 @@ pub(super) fn resolve_install_binaries(paths: &InstallPaths) -> Result Result Result { + if paths.is_release_archive() { + // Release archives already contain built binaries under their local bin directory + return Ok(paths.repo_root.clone()); + } let metadata = load_cargo_metadata(paths)?; Ok(metadata.target_directory) } @@ -80,6 +84,10 @@ fn legacy_binaries() -> Vec { } fn load_install_binaries_from_metadata(paths: &InstallPaths) -> Result> { + if paths.is_release_archive() { + return load_install_binaries_from_release_manifest(paths); + } + // Read the root Cargo.toml and extract the installer metadata list if present. let cargo_path = paths.repo_root.join("Cargo.toml"); let contents = @@ -87,6 +95,21 @@ fn load_install_binaries_from_metadata(paths: &InstallPaths) -> Result Result> { + // Release archives do not include Cargo metadata, so the manifest is the source of truth + let contents = fs::read_to_string(paths.release_manifest_path()) + .with_context(|| "failed to read UnixNotis release manifest")?; + parse_release_manifest_binaries(&contents) +} + +fn parse_release_manifest_binaries(contents: &str) -> Result> { + // The release manifest intentionally stores only deployable runtime binary names + let manifest: ReleaseManifest = serde_json::from_str(contents) + .with_context(|| "failed to parse UnixNotis release manifest")?; + // Dedup here so a bad manifest cannot copy or remove the same path twice + Ok(dedup_binary_names(manifest.binaries)) +} + fn parse_install_binaries_metadata(contents: &str) -> Result> { // Deserialize a minimal schema so the metadata stays readable and future-safe. let root: WorkspaceCargoToml = @@ -99,18 +122,24 @@ fn parse_install_binaries_metadata(contents: &str) -> Result> { .and_then(|installer| installer.binaries) .unwrap_or_default(); + Ok(dedup_binary_names(array)) +} + +fn dedup_binary_names(names: Vec) -> Vec { let mut seen = HashSet::new(); let mut binaries = Vec::new(); - for name in array { + for name in names { + // Blank names cannot map to a real binary and should not create bad install paths let name = name.trim(); if name.is_empty() { continue; } if seen.insert(name.to_string()) { + // First mention wins so release and workspace metadata keep stable order binaries.push(name.to_string()); } } - Ok(binaries) + binaries } fn discover_installed_binaries(paths: &InstallPaths) -> Vec { @@ -163,6 +192,11 @@ struct InstallerMetadata { binaries: Option>, } +#[derive(serde::Deserialize)] +struct ReleaseManifest { + binaries: Vec, +} + fn load_install_binaries_from_cargo_metadata(paths: &InstallPaths) -> Result> { let metadata = load_cargo_metadata(paths)?; Ok(extract_bins_from_metadata(&metadata)) diff --git a/crates/unixnotis-installer/src/actions/actions_state/tests/binaries.rs b/crates/unixnotis-installer/src/actions/actions_state/tests/binaries.rs index e766095e..4789f967 100644 --- a/crates/unixnotis-installer/src/actions/actions_state/tests/binaries.rs +++ b/crates/unixnotis-installer/src/actions/actions_state/tests/binaries.rs @@ -3,7 +3,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use super::{ discover_installed_binaries, extract_bins_from_metadata, legacy_binaries, - parse_install_binaries_metadata, resolve_install_binaries_best_effort, CargoMetadata, + parse_install_binaries_metadata, parse_release_manifest_binaries, + resolve_install_binaries_best_effort, CargoMetadata, }; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; @@ -61,6 +62,33 @@ binaries = ["unixnotis-popups", "unixnotis-daemon", "unixnotis-popups"] ); } +#[test] +fn parse_release_manifest_binaries_uses_release_archive_order() { + let input = r#" +{ + "version": "1.0.0", + "binaries": ["unixnotis-daemon", "noticenterctl", "unixnotis-daemon", " "] +} +"#; + + let binaries = parse_release_manifest_binaries(input).expect("release manifest"); + + // Release archives install the exact bundled list once, preserving manifest order + assert_eq!( + binaries, + vec!["unixnotis-daemon".to_string(), "noticenterctl".to_string()] + ); +} + +#[test] +fn parse_release_manifest_binaries_rejects_missing_binary_list() { + let err = parse_release_manifest_binaries(r#"{"version":"1.0.0"}"#) + .expect_err("missing binaries should fail"); + + // A release without an explicit binary list cannot be safely installed + assert!(err.to_string().contains("release manifest")); +} + #[test] fn extract_bins_from_cargo_metadata_skips_installer_binary() { let input = r#" From 25005c8c81fbeb3acfbb12d543dbb55bafaebdf4 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 2 Jul 2026 21:07:43 -0500 Subject: [PATCH 20/27] verify bundled release binaries Use the build phase to validate downloaded archive binaries before install while preserving Cargo release builds for source checkouts. --- .../src/actions/actions_state/plan.rs | 2 +- .../src/actions/actions_state/tests/plan.rs | 12 +- .../src/actions/build/compile.rs | 39 +++++++ .../src/actions/build/tests/compile.rs | 103 ++++++++++++++++++ 4 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 crates/unixnotis-installer/src/actions/build/tests/compile.rs diff --git a/crates/unixnotis-installer/src/actions/actions_state/plan.rs b/crates/unixnotis-installer/src/actions/actions_state/plan.rs index 8709a3e3..4d08061f 100644 --- a/crates/unixnotis-installer/src/actions/actions_state/plan.rs +++ b/crates/unixnotis-installer/src/actions/actions_state/plan.rs @@ -85,7 +85,7 @@ pub fn step_label(kind: StepKind) -> &'static str { match kind { StepKind::InstallCheck => "Check existing install", StepKind::StopDaemon => "Stop existing daemon", - StepKind::Build => "Build release binaries", + StepKind::Build => "Prepare release binaries", StepKind::EnsureConfig => "Ensure config files", StepKind::ResetConfig => "Reset config files", StepKind::RestoreConfig => "Restore config backup", diff --git a/crates/unixnotis-installer/src/actions/actions_state/tests/plan.rs b/crates/unixnotis-installer/src/actions/actions_state/tests/plan.rs index 1ecdfabb..a787a11d 100644 --- a/crates/unixnotis-installer/src/actions/actions_state/tests/plan.rs +++ b/crates/unixnotis-installer/src/actions/actions_state/tests/plan.rs @@ -46,10 +46,18 @@ fn test_plan_has_no_worker_steps() { #[test] fn steps_from_plan_uses_user_visible_labels() { - let steps = steps_from_plan(&[StepKind::InstallCheck, StepKind::EnableService]); + let steps = steps_from_plan(&[ + StepKind::InstallCheck, + StepKind::Build, + StepKind::EnableService, + ]); let labels = steps.into_iter().map(|step| step.name).collect::>(); assert_eq!( labels, - vec!["Check existing install", "Enable user service"] + vec![ + "Check existing install", + "Prepare release binaries", + "Enable user service" + ] ); } diff --git a/crates/unixnotis-installer/src/actions/build/compile.rs b/crates/unixnotis-installer/src/actions/build/compile.rs index d6e0a92c..ba28dcb5 100644 --- a/crates/unixnotis-installer/src/actions/build/compile.rs +++ b/crates/unixnotis-installer/src/actions/build/compile.rs @@ -5,6 +5,11 @@ use anyhow::{anyhow, Result}; use super::super::{binaries::resolve_install_binaries, log_line, run_command, ActionContext}; pub(crate) fn run_build(ctx: &mut ActionContext) -> Result<()> { + if ctx.paths.is_release_archive() { + // Downloaded releases already ship binaries, so "build" becomes a bundle check + return verify_release_binaries(ctx); + } + // Build release artifacts before copying them into the user bin directory log_line(ctx, "Building release binaries"); @@ -30,3 +35,37 @@ pub(crate) fn run_build(ctx: &mut ActionContext) -> Result<()> { )?; Ok(()) } + +fn verify_release_binaries(ctx: &mut ActionContext) -> Result<()> { + log_line(ctx, "Using bundled release binaries"); + + // The same resolver feeds build, install, and uninstall so the managed set cannot drift + let binaries = resolve_install_binaries(ctx.paths)?; + if binaries.is_empty() { + return Err(anyhow!( + "release manifest did not list installable binaries" + )); + } + + let missing = binaries + .iter() + // Release artifacts are copied from the archive bin folder during the install step + .map(|binary| ctx.paths.release_binary_dir().join(binary)) + // Every listed file must exist before install starts changing user-owned state + .filter(|path| !path.is_file()) + .map(|path| crate::paths::format_with_home(&path)) + .collect::>(); + + if !missing.is_empty() { + return Err(anyhow!( + "missing bundled release binaries: {}", + missing.join(", ") + )); + } + + Ok(()) +} + +#[cfg(test)] +#[path = "tests/compile.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/build/tests/compile.rs b/crates/unixnotis-installer/src/actions/build/tests/compile.rs new file mode 100644 index 00000000..bbb339d5 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/build/tests/compile.rs @@ -0,0 +1,103 @@ +use std::fs; +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::actions::ActionContext; +use crate::detect::Detection; +use crate::events::UiMessage; +use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +use super::run_build; + +#[test] +fn run_build_accepts_complete_release_archive_without_cargo() { + let root = test_root("release-build-complete"); + write_fake_release_archive(&root, true); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths); + + run_build(&mut ctx).expect("complete release archive should prepare binaries"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn run_build_rejects_release_archive_with_missing_bundled_binary() { + let root = test_root("release-build-missing"); + write_fake_release_archive(&root, false); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths); + + let err = run_build(&mut ctx).expect_err("missing binary should fail"); + + // Archive installs must stop before copying files when the bundle is incomplete + assert!(err.to_string().contains("missing bundled release binaries")); + + let _ = fs::remove_dir_all(root); +} + +fn test_context<'a>(detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { + let (tx, _rx) = mpsc::sync_channel::(32); + ActionContext { + detection, + paths, + install_state: None, + log_tx: tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + } +} + +fn test_paths(root: &std::path::Path) -> InstallPaths { + InstallPaths { + repo_root: root.to_path_buf(), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user(root.join("home").join(".config/systemd/user")), + } +} + +fn test_root(name: &str) -> std::path::PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + std::env::temp_dir().join(format!("unixnotis-installer-{name}-{stamp}")) +} + +fn write_fake_release_archive(root: &std::path::Path, complete: bool) { + let bin_dir = root.join("bin"); + fs::create_dir_all(&bin_dir).expect("release bin dir"); + fs::write( + root.join("unixnotis-release.json"), + r#"{"version":"1.0.0","binaries":["unixnotis-daemon","unixnotis-popups","unixnotis-center","noticenterctl"]}"#, + ) + .expect("release manifest"); + + let binaries = if complete { + [ + "unixnotis-daemon", + "unixnotis-popups", + "unixnotis-center", + "noticenterctl", + ] + .as_slice() + } else { + ["unixnotis-daemon", "unixnotis-popups", "unixnotis-center"].as_slice() + }; + + for binary in binaries { + fs::write(bin_dir.join(binary), format!("release:{binary}")).expect("release binary"); + } +} From ce4a12e3e2a17c2f16dc281bfefbb60861d392b5 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 2 Jul 2026 21:07:49 -0500 Subject: [PATCH 21/27] copy binaries from release archives Install bundled runtime binaries from the archive bin directory while keeping source builds on Cargo target output. --- .../src/actions/install/binaries.rs | 7 +++ .../src/actions/install/tests/binaries.rs | 50 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/crates/unixnotis-installer/src/actions/install/binaries.rs b/crates/unixnotis-installer/src/actions/install/binaries.rs index d0e611c1..05bb5bcf 100644 --- a/crates/unixnotis-installer/src/actions/install/binaries.rs +++ b/crates/unixnotis-installer/src/actions/install/binaries.rs @@ -40,6 +40,7 @@ pub(crate) fn install_binaries(ctx: &mut ActionContext) -> Result<()> { for binary in binaries { let source = release_dir.join(&binary); let destination = ctx.paths.bin_dir.join(&binary); + // One helper handles both source builds and downloaded archives after source resolution copy_binary(ctx, &source, &destination)?; } @@ -76,6 +77,12 @@ pub(crate) fn remove_binaries(ctx: &mut ActionContext) -> Result<()> { } fn resolve_release_dir(ctx: &mut ActionContext) -> Result { + if ctx.paths.is_release_archive() { + // Bundled releases copy from the archive bin dir instead of Cargo target output + // Keeping this branch here avoids leaking archive-specific paths into copy logic + return Ok(ctx.paths.release_binary_dir()); + } + // Ask cargo metadata for the target dir instead of assuming `target/release` let target_dir = resolve_target_directory(ctx.paths).with_context(|| { format!( diff --git a/crates/unixnotis-installer/src/actions/install/tests/binaries.rs b/crates/unixnotis-installer/src/actions/install/tests/binaries.rs index 2af8a446..142ee115 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/binaries.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/binaries.rs @@ -57,6 +57,37 @@ fn install_binaries_copies_all_managed_binaries_including_noticenterctl() { let _ = fs::remove_dir_all(&root); } +#[test] +fn install_binaries_copies_from_release_archive_bin_dir() { + let root = test_root("install-release-archive-binaries"); + let paths = test_paths(&root); + write_fake_release_archive(&root); + + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Install); + + install_binaries(&mut ctx).expect("release archive install should copy binaries"); + + for binary in [ + "unixnotis-daemon", + "unixnotis-popups", + "unixnotis-center", + "noticenterctl", + ] { + let installed = paths.bin_dir.join(binary); + assert!(installed.exists(), "{binary} should be installed"); + assert_eq!( + fs::read_to_string(&installed).expect("read installed binary"), + format!("release:{binary}") + ); + } + + let _ = fs::remove_dir_all(&root); +} + #[test] fn remove_binaries_removes_all_managed_binaries_including_noticenterctl() { // Uninstall must remove the same managed set that install copied in @@ -105,3 +136,22 @@ fn remove_binaries_removes_all_managed_binaries_including_noticenterctl() { let _ = fs::remove_dir_all(&root); } + +fn write_fake_release_archive(root: &std::path::Path) { + let bin_dir = root.join("bin"); + fs::create_dir_all(&bin_dir).expect("release bin dir"); + fs::write( + root.join("unixnotis-release.json"), + r#"{"version":"1.0.0","binaries":["unixnotis-daemon","unixnotis-popups","unixnotis-center","noticenterctl"]}"#, + ) + .expect("release manifest"); + + for binary in [ + "unixnotis-daemon", + "unixnotis-popups", + "unixnotis-center", + "noticenterctl", + ] { + fs::write(bin_dir.join(binary), format!("release:{binary}")).expect("release binary"); + } +} From cadb65ae9cb889e8c82eecc49ec2d405c74d4b93 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 2 Jul 2026 21:08:14 -0500 Subject: [PATCH 22/27] relax cargo checks for release archives Allow downloaded release archives to install without Cargo while keeping trial mode limited to source checkouts. --- crates/unixnotis-installer/src/checks/mod.rs | 17 ++++++++++++++--- crates/unixnotis-installer/src/checks/system.rs | 8 +++++++- .../src/checks/tests/session.rs | 15 +++++++++++++++ crates/unixnotis-installer/src/tests/app.rs | 1 + .../unixnotis-installer/src/ui/tests/support.rs | 1 + 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/unixnotis-installer/src/checks/mod.rs b/crates/unixnotis-installer/src/checks/mod.rs index ce82cb22..a6ccfd30 100644 --- a/crates/unixnotis-installer/src/checks/mod.rs +++ b/crates/unixnotis-installer/src/checks/mod.rs @@ -32,6 +32,7 @@ pub struct CheckItem { #[derive(Clone, Debug)] pub struct Checks { + pub release_archive: bool, pub wayland: CheckItem, pub hyprland: CheckItem, pub service_manager: CheckItem, @@ -50,13 +51,18 @@ impl Checks { let wayland = system::wayland_check(); let hyprland = system::hyprland_check(); let service_manager_check = system::service_manager_check(service_manager); - let cargo = system::cargo_check(); + let discovered_paths = InstallPaths::discover_with_service_manager(service_manager); + // Release archives do not require a Rust toolchain for install mode + let release_archive = discovered_paths + .as_ref() + .map(InstallPaths::is_release_archive) + .unwrap_or(false); + let cargo = system::cargo_check(release_archive); let pkg_config = system::pkg_config_check(); let gtk4_css_features = gtk::gtk4_css_features_check(&pkg_config); let gtk4_layer_shell = gtk::gtk4_layer_shell_check(&pkg_config); let busctl = system::busctl_check(); - let discovered_paths = InstallPaths::discover_with_service_manager(service_manager); let dbus_update_env = match &discovered_paths { Ok(paths) => system::dbus_update_env_check(Some(&paths.service)), Err(_) => system::dbus_update_env_check(None), @@ -75,6 +81,7 @@ impl Checks { }; Self { + release_archive, wayland, hyprland, service_manager: service_manager_check, @@ -92,6 +99,10 @@ impl Checks { pub fn ready_for(&self, mode: ActionMode) -> Result<(), String> { match mode { ActionMode::Test => { + if self.release_archive { + // Trial mode launches source-built binaries and cannot run from an archive + return Err("trial mode requires a source checkout".to_string()); + } // Trial mode only needs the runtime pieces required to launch from source if self.wayland.state == CheckState::Fail { return Err("Wayland session required".to_string()); @@ -114,7 +125,7 @@ impl Checks { if self.service_manager.state == CheckState::Fail { return Err("supported service manager session required".to_string()); } - if self.cargo.state == CheckState::Fail { + if !self.release_archive && self.cargo.state == CheckState::Fail { return Err("cargo is required for installation".to_string()); } if self.gtk4_layer_shell.state == CheckState::Fail { diff --git a/crates/unixnotis-installer/src/checks/system.rs b/crates/unixnotis-installer/src/checks/system.rs index a2026abc..de5466fe 100644 --- a/crates/unixnotis-installer/src/checks/system.rs +++ b/crates/unixnotis-installer/src/checks/system.rs @@ -114,7 +114,13 @@ pub(super) fn readiness_messages(issues: &[ReadinessIssue], errors: bool) -> Vec .collect() } -pub(super) fn cargo_check() -> CheckItem { +pub(super) fn cargo_check(release_archive: bool) -> CheckItem { + if release_archive { + // Downloaded releases install bundled binaries and do not need a Rust toolchain + // Source checkouts still require cargo because the installer builds before copying + return CheckItem::ok("cargo", "not required for release archive"); + } + match command_success("cargo", &["--version"]) { Ok(true) => CheckItem::ok("cargo", "available"), Ok(false) => CheckItem::fail("cargo", "not installed"), diff --git a/crates/unixnotis-installer/src/checks/tests/session.rs b/crates/unixnotis-installer/src/checks/tests/session.rs index ab56e597..aee1bdc5 100644 --- a/crates/unixnotis-installer/src/checks/tests/session.rs +++ b/crates/unixnotis-installer/src/checks/tests/session.rs @@ -181,6 +181,7 @@ fn ready_for_uninstall_only_requires_backend_and_writable_paths() { #[test] fn ready_for_reset_never_blocks_on_environment_checks() { let checks = Checks { + release_archive: false, wayland: item("Wayland", CheckState::Fail), hyprland: item("Hyprland", CheckState::Fail), service_manager: item("Service manager", CheckState::Fail), @@ -199,8 +200,22 @@ fn ready_for_reset_never_blocks_on_environment_checks() { assert!(checks.ready_for(ActionMode::Reset).is_ok()); } +#[test] +fn release_archive_install_does_not_require_cargo_but_trial_still_does() { + let mut checks = passing_checks(); + checks.release_archive = true; + checks.cargo = item("cargo", CheckState::Fail); + + assert!(checks.ready_for(ActionMode::Install).is_ok()); + assert_eq!( + checks.ready_for(ActionMode::Test), + Err("trial mode requires a source checkout".to_string()) + ); +} + fn passing_checks() -> Checks { Checks { + release_archive: false, wayland: item("Wayland", CheckState::Ok), hyprland: item("Hyprland", CheckState::Warn), service_manager: item("Service manager", CheckState::Ok), diff --git a/crates/unixnotis-installer/src/tests/app.rs b/crates/unixnotis-installer/src/tests/app.rs index 7c6388e4..b32343cf 100644 --- a/crates/unixnotis-installer/src/tests/app.rs +++ b/crates/unixnotis-installer/src/tests/app.rs @@ -177,6 +177,7 @@ fn passing_checks() -> Checks { }; Checks { + release_archive: false, wayland: item.clone(), hyprland: item.clone(), service_manager: item.clone(), diff --git a/crates/unixnotis-installer/src/ui/tests/support.rs b/crates/unixnotis-installer/src/ui/tests/support.rs index 1673fb06..e3c76575 100644 --- a/crates/unixnotis-installer/src/ui/tests/support.rs +++ b/crates/unixnotis-installer/src/ui/tests/support.rs @@ -165,6 +165,7 @@ fn passing_checks() -> Checks { }; Checks { + release_archive: false, wayland: item.clone(), hyprland: item.clone(), service_manager: item.clone(), From c7b9f2ed5e4b8a0f2050c2ce8a0fb2ee802d887a Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 2 Jul 2026 21:08:24 -0500 Subject: [PATCH 23/27] show installer release status Display the installed v1 version and best-effort latest GitHub release status in the installer welcome screen without making network checks part of tests. --- crates/unixnotis-installer/src/app.rs | 6 + crates/unixnotis-installer/src/main.rs | 1 + crates/unixnotis-installer/src/release.rs | 156 ++++++++++++++++++ crates/unixnotis-installer/src/tests/app.rs | 1 + .../unixnotis-installer/src/tests/release.rs | 60 +++++++ .../src/ui/tests/support.rs | 1 + .../src/ui/tests/welcome.rs | 19 +++ crates/unixnotis-installer/src/ui/welcome.rs | 38 ++++- 8 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 crates/unixnotis-installer/src/release.rs create mode 100644 crates/unixnotis-installer/src/tests/release.rs diff --git a/crates/unixnotis-installer/src/app.rs b/crates/unixnotis-installer/src/app.rs index 04f1cfd9..979a5834 100644 --- a/crates/unixnotis-installer/src/app.rs +++ b/crates/unixnotis-installer/src/app.rs @@ -6,6 +6,7 @@ use crate::checks::Checks; use crate::detect::Detection; use crate::model::{ActionMode, ActionStep, ResetAction}; use crate::paths::{InstallPaths, ServiceManagerChoice}; +use crate::release::ReleaseStatus; use std::collections::VecDeque; use std::path::PathBuf; use std::time::Instant; @@ -96,6 +97,9 @@ pub struct App { // Explicit backend selected at startup; None keeps environment/default behavior. pub service_manager: Option, + + // Current installer version and best-effort latest release status. + pub release_status: ReleaseStatus, } #[derive(Clone, Debug)] @@ -115,6 +119,7 @@ impl App { pub fn new(service_manager: Option) -> Self { // Initialize with current system state. let (checks, detection, install_state) = Self::load_state(service_manager); + let release_status = ReleaseStatus::detect(); Self { checks, @@ -134,6 +139,7 @@ impl App { restore_backups: Vec::new(), restore_menu_index: 0, service_manager, + release_status, } } diff --git a/crates/unixnotis-installer/src/main.rs b/crates/unixnotis-installer/src/main.rs index f058f2d8..039958d9 100644 --- a/crates/unixnotis-installer/src/main.rs +++ b/crates/unixnotis-installer/src/main.rs @@ -27,6 +27,7 @@ mod main_tests; mod model; #[path = "paths/index.rs"] mod paths; +mod release; mod service_manager; mod terminal; #[cfg(test)] diff --git a/crates/unixnotis-installer/src/release.rs b/crates/unixnotis-installer/src/release.rs new file mode 100644 index 00000000..9be2017d --- /dev/null +++ b/crates/unixnotis-installer/src/release.rs @@ -0,0 +1,156 @@ +//! Release version and update status helpers + +#[cfg(not(test))] +use std::process::Command; + +#[cfg(not(test))] +const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/locainin/UnixNotis/releases/latest"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReleaseStatus { + pub current: String, + pub latest: Option, + pub state: ReleaseUpdateState, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReleaseUpdateState { + UpToDate, + UpdateAvailable, + Unknown, +} + +impl ReleaseStatus { + pub fn detect() -> Self { + // Current version comes from Cargo so source and release builds agree + let current = current_version_tag(); + + #[cfg(test)] + { + // Unit tests must not depend on GitHub, network access, or curl timing + Self { + current, + latest: None, + state: ReleaseUpdateState::Unknown, + } + } + + #[cfg(not(test))] + match fetch_latest_release_tag() { + Ok(latest) => { + // GitHub can return any valid tag name, but the UI only treats stable tags as updates + // Invalid or unexpected tag formats fail closed as "not newer" + let state = if release_tag_is_newer(&latest, ¤t) { + ReleaseUpdateState::UpdateAvailable + } else { + ReleaseUpdateState::UpToDate + }; + Self { + current, + latest: Some(latest), + state, + } + } + Err(_) => Self { + // Update checks are informational and should never block installer startup + current, + latest: None, + state: ReleaseUpdateState::Unknown, + }, + } + } + + #[cfg(test)] + pub fn current_only() -> Self { + // Render tests use this helper so the welcome screen stays deterministic + Self { + current: current_version_tag(), + latest: None, + state: ReleaseUpdateState::Unknown, + } + } + + pub fn display_line(&self) -> String { + // Keep the line compact because it sits in the installer status panel + match (self.state, self.latest.as_deref()) { + (ReleaseUpdateState::UpdateAvailable, Some(latest)) => { + format!("{} installed; {latest} available", self.current) + } + (ReleaseUpdateState::UpToDate, Some(latest)) => { + format!("{} installed; latest release is {latest}", self.current) + } + _ => format!("{} installed; update check unavailable", self.current), + } + } +} + +fn current_version_tag() -> String { + // GitHub release tags use a v-prefix while Cargo stores the numeric version + format!("v{}", env!("CARGO_PKG_VERSION")) +} + +#[cfg(not(test))] +fn fetch_latest_release_tag() -> Result { + // GitHub's latest-release endpoint returns the newest non-draft, non-prerelease release + // Curl is used instead of adding an HTTP client dependency to the installer binary + let output = Command::new("curl") + .args([ + "-fsSL", + "--max-time", + "2", + // Keep requests explicit so GitHub API behavior does not depend on global curl config + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + LATEST_RELEASE_URL, + ]) + .output() + .map_err(|err| err.to_string())?; + + if !output.status.success() { + // Curl failure detail is useful for logs while the UI only needs unknown status + return Err(format!("curl exited with {}", output.status)); + } + + latest_tag_from_json(&output.stdout) +} + +fn latest_tag_from_json(bytes: &[u8]) -> Result { + // Parse only the single field needed for the TUI instead of binding to the full API shape + let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|err| err.to_string())?; + value + .get("tag_name") + .and_then(|tag| tag.as_str()) + // Empty tags are treated like malformed API data, not like "no update" + .filter(|tag| !tag.trim().is_empty()) + .map(|tag| tag.trim().to_string()) + .ok_or_else(|| "latest release response did not include tag_name".to_string()) +} + +fn release_tag_is_newer(latest: &str, current: &str) -> bool { + // Unknown tag formats are treated as non-updates so prereleases cannot surprise users + match (parse_version_tag(latest), parse_version_tag(current)) { + (Some(latest), Some(current)) => latest > current, + _ => false, + } +} + +fn parse_version_tag(tag: &str) -> Option<(u32, u32, u32)> { + // Only stable semver tags are supported by the installer update indicator + let trimmed = tag.trim().trim_start_matches('v'); + let mut parts = trimmed.split('.'); + // Tuple comparison keeps major/minor/patch ordering correct without string sorting + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next()?.parse::().ok()?; + // Stable release tags must stay simple so update checks do not misread prerelease text + if parts.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +#[cfg(test)] +#[path = "tests/release.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/tests/app.rs b/crates/unixnotis-installer/src/tests/app.rs index b32343cf..10d49c71 100644 --- a/crates/unixnotis-installer/src/tests/app.rs +++ b/crates/unixnotis-installer/src/tests/app.rs @@ -158,6 +158,7 @@ fn app_with_build_accel(detection: Option) -> App { restore_backups: Vec::new(), restore_menu_index: 0, service_manager: None, + release_status: crate::release::ReleaseStatus::current_only(), } } diff --git a/crates/unixnotis-installer/src/tests/release.rs b/crates/unixnotis-installer/src/tests/release.rs new file mode 100644 index 00000000..d123bb05 --- /dev/null +++ b/crates/unixnotis-installer/src/tests/release.rs @@ -0,0 +1,60 @@ +use super::{ + latest_tag_from_json, parse_version_tag, release_tag_is_newer, ReleaseStatus, + ReleaseUpdateState, +}; + +#[test] +fn latest_tag_from_json_reads_tag_name() { + let json = br#"{"tag_name":"v1.0.1","name":"UnixNotis v1.0.1"}"#; + + assert_eq!(latest_tag_from_json(json).expect("tag"), "v1.0.1"); +} + +#[test] +fn latest_tag_from_json_rejects_missing_tag() { + let json = br#"{"name":"UnixNotis"}"#; + + assert!(latest_tag_from_json(json).is_err()); +} + +#[test] +fn release_tag_compare_detects_newer_patch_minor_and_major() { + assert!(release_tag_is_newer("v1.0.1", "v1.0.0")); + assert!(release_tag_is_newer("v1.1.0", "v1.0.9")); + assert!(release_tag_is_newer("v2.0.0", "v1.9.9")); + assert!(!release_tag_is_newer("v1.0.0", "v1.0.0")); + assert!(!release_tag_is_newer("v0.9.9", "v1.0.0")); +} + +#[test] +fn release_status_display_line_reports_available_update() { + let status = ReleaseStatus { + current: "v1.0.0".to_string(), + latest: Some("v1.0.1".to_string()), + state: ReleaseUpdateState::UpdateAvailable, + }; + + assert_eq!(status.display_line(), "v1.0.0 installed; v1.0.1 available"); +} + +#[test] +fn release_status_display_line_reports_up_to_date_release() { + let status = ReleaseStatus { + current: "v1.0.0".to_string(), + latest: Some("v1.0.0".to_string()), + state: ReleaseUpdateState::UpToDate, + }; + + assert_eq!( + status.display_line(), + "v1.0.0 installed; latest release is v1.0.0" + ); +} + +#[test] +fn parse_version_tag_accepts_plain_and_prefixed_versions() { + assert_eq!(parse_version_tag("1.2.3"), Some((1, 2, 3))); + assert_eq!(parse_version_tag("v1.2.3"), Some((1, 2, 3))); + assert_eq!(parse_version_tag("v1.2"), None); + assert_eq!(parse_version_tag("v1.2.3.4"), None); +} diff --git a/crates/unixnotis-installer/src/ui/tests/support.rs b/crates/unixnotis-installer/src/ui/tests/support.rs index e3c76575..84063953 100644 --- a/crates/unixnotis-installer/src/ui/tests/support.rs +++ b/crates/unixnotis-installer/src/ui/tests/support.rs @@ -53,6 +53,7 @@ pub(super) fn app_for_rendering(screen: Screen) -> App { }), }), build_accel_menu_index: 1, + release_status: crate::release::ReleaseStatus::current_only(), reset_menu_index: 0, reset_action: ResetAction::RestoreBackup { path: PathBuf::from("/tmp/unixnotis-backup"), diff --git a/crates/unixnotis-installer/src/ui/tests/welcome.rs b/crates/unixnotis-installer/src/ui/tests/welcome.rs index 79b27be3..c914013c 100644 --- a/crates/unixnotis-installer/src/ui/tests/welcome.rs +++ b/crates/unixnotis-installer/src/ui/tests/welcome.rs @@ -1,5 +1,6 @@ use crate::app::Screen; use crate::detect::OwnerInfo; +use crate::release::{ReleaseStatus, ReleaseUpdateState}; use ratatui::style::{Color, Modifier}; use super::test_support::{ @@ -16,6 +17,8 @@ fn draw_welcome_renders_status_and_action_menu() { assert!(screen.contains("UnixNotis Installer")); assert!(screen.contains("System status")); assert!(screen.contains("Actions")); + assert!(screen.contains("Release")); + assert!(screen.contains("Version: v1.0.0 installed")); assert!(screen.contains("Compatibility")); assert!(screen.contains("[ok]")); assert!(screen.contains("test - ok")); @@ -128,3 +131,19 @@ fn draw_welcome_styles_warning_daemon_status_as_warning() { // Probe errors that are still displayable should stand out as warning state assert_eq!(status.fg, Some(Color::Yellow)); } + +#[test] +fn draw_welcome_styles_available_release_update_as_warning() { + let mut app = app_for_rendering(Screen::Welcome); + app.release_status = ReleaseStatus { + current: "v1.0.0".to_string(), + latest: Some("v1.0.1".to_string()), + state: ReleaseUpdateState::UpdateAvailable, + }; + + let buffer = render_app_buffer(&app); + let update = style_for_text(&buffer, "v1.0.1 available"); + + // Available updates should stand out without treating the installer as broken + assert_eq!(update.fg, Some(Color::Yellow)); +} diff --git a/crates/unixnotis-installer/src/ui/welcome.rs b/crates/unixnotis-installer/src/ui/welcome.rs index 0ead6522..a875d0a6 100644 --- a/crates/unixnotis-installer/src/ui/welcome.rs +++ b/crates/unixnotis-installer/src/ui/welcome.rs @@ -66,13 +66,26 @@ pub(super) fn draw_welcome(frame: &mut Frame<'_>, app: &App) { fn render_status(app: &App) -> Text<'static> { // Build a list of Lines that ratatui will render as a single Text block. // This is kept pure so rendering remains deterministic for any given App state. - let mut lines = Vec::new(); - - // Section heading: core environment checks. - lines.push(Line::from(Span::styled( - "Compatibility", - Style::default().add_modifier(Modifier::BOLD), - ))); + let mut lines = vec![ + // Section heading: release version and update status. + Line::from(Span::styled( + "Release", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(vec![ + Span::styled("Version: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + app.release_status.display_line(), + release_status_style(app.release_status.state), + ), + ]), + Line::from(""), + // Section heading: core environment checks. + Line::from(Span::styled( + "Compatibility", + Style::default().add_modifier(Modifier::BOLD), + )), + ]; lines.extend(render_check(&app.checks.wayland)); lines.extend(render_check(&app.checks.hyprland)); lines.extend(render_check(&app.checks.service_manager)); @@ -137,6 +150,17 @@ fn daemon_owner_style(has_owner: bool) -> Style { } } +fn release_status_style(state: crate::release::ReleaseUpdateState) -> Style { + match state { + // Green means the currently installed version matches the latest stable release + crate::release::ReleaseUpdateState::UpToDate => Style::default().fg(Color::Green), + // Yellow keeps updates visible without making the installer look blocked + crate::release::ReleaseUpdateState::UpdateAvailable => Style::default().fg(Color::Yellow), + // Unknown is expected offline, in CI, or when GitHub rate-limits the unauthenticated check + crate::release::ReleaseUpdateState::Unknown => Style::default().fg(Color::Gray), + } +} + fn daemon_status_style(daemon: &crate::detect::DetectedDaemon) -> Style { if daemon_status_is_warning(daemon) { Style::default().fg(Color::Yellow) From a0eb395fb21e8d37822057f66958e1b11497b187 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 2 Jul 2026 21:08:30 -0500 Subject: [PATCH 24/27] add manual release package script Create a checked packaging script that builds release binaries, assembles the tar.zst archive, writes the manifest, and emits a checksum. --- .github/workflows/ci.yml | 1 + .gitignore | 1 + scripts/package-release.sh | 121 +++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100755 scripts/package-release.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b01dd59a..ff94c833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,7 @@ jobs: run: | set -euo pipefail shellcheck \ + scripts/package-release.sh \ crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib \ crates/unixnotis-core/assets/scripts/unixnotis-blue-light-state \ crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on \ diff --git a/.gitignore b/.gitignore index 38ef23e9..38958bef 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ /tools/ /.ruff_cache /testing/ +/dist/ diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100755 index 00000000..3321c0db --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +set -euo pipefail + +main() { + local tag="${1:-}" + if [[ -z "$tag" ]]; then + printf 'usage: %s v1.0.0\n' "${0}" >&2 + exit 2 + fi + + if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + # Release artifacts and installer update checks both expect stable vMAJOR.MINOR.PATCH tags + printf 'release tag must look like v1.0.0: %s\n' "$tag" >&2 + exit 2 + fi + + local version="${tag#v}" + local target="x86_64-unknown-linux-gnu" + local root + root="$(repo_root)" + cd "$root" + + assert_workspace_version "$version" + # Build first so packaging never creates an archive around stale target artifacts + build_release_binaries + assemble_archive "$tag" "$version" "$target" +} + +repo_root() { + # Resolve through git so the script can run from any subdirectory + git rev-parse --show-toplevel +} + +assert_workspace_version() { + local expected="${1}" + local pkgid + local actual + + # cargo pkgid reads Cargo metadata and avoids hand-parsing Cargo.toml + pkgid="$(cargo pkgid -p unixnotis-installer)" + actual="${pkgid##*#}" + + if [[ "$actual" != "$expected" ]]; then + printf 'Cargo version %s does not match release tag v%s\n' "$actual" "$expected" >&2 + exit 1 + fi +} + +build_release_binaries() { + # Build only the programs the installer deploys plus the installer itself + cargo build --release \ + -p unixnotis-installer \ + -p unixnotis-daemon \ + -p unixnotis-popups \ + -p unixnotis-center \ + -p noticenterctl +} + +assemble_archive() { + local tag="${1}" + local version="${2}" + local target="${3}" + local package_root="unixnotis-${tag}-${target}" + local dist_root="dist/${package_root}" + local archive="dist/${package_root}.tar.zst" + + # Start from a clean package directory so stale binaries cannot leak into a release + # The dist directory itself is ignored so local package checks do not dirty commits + rm -rf "$dist_root" "$archive" "${archive}.sha256" + mkdir -p "${dist_root}/bin" + + # The top-level installer finds unixnotis-release.json beside itself at runtime + install -m 0755 target/release/unixnotis-installer "${dist_root}/unixnotis-installer" + + # Runtime tools stay under bin so the installer can validate and copy them as a group + install -m 0755 target/release/unixnotis-daemon "${dist_root}/bin/unixnotis-daemon" + install -m 0755 target/release/unixnotis-popups "${dist_root}/bin/unixnotis-popups" + install -m 0755 target/release/unixnotis-center "${dist_root}/bin/unixnotis-center" + install -m 0755 target/release/noticenterctl "${dist_root}/bin/noticenterctl" + + write_manifest "${dist_root}/unixnotis-release.json" "$tag" "$version" "$target" + write_readme "${dist_root}/README.txt" "$tag" + + # zstd keeps the release small while still being standard on modern Linux systems + tar --zstd -C dist -cf "$archive" "$package_root" + # The checksum file is uploaded with the archive for a simple manual verification path + sha256sum "$archive" > "${archive}.sha256" + + printf 'wrote %s\n' "$archive" + printf 'wrote %s\n' "${archive}.sha256" +} + +write_manifest() { + local path="${1}" + local tag="${2}" + local version="${3}" + local target="${4}" + + # Keep this schema tiny because the installer trusts it to find bundled binaries + printf '{"version":"%s","tag":"%s","target":"%s","binaries":["unixnotis-daemon","unixnotis-popups","unixnotis-center","noticenterctl"]}\n' \ + "$version" \ + "$tag" \ + "$target" \ + > "$path" +} + +write_readme() { + local path="${1}" + local tag="${2}" + + # The archive README is intentionally short because the TUI owns the real install flow + printf '%s\n' \ + "UnixNotis ${tag}" \ + "" \ + "Run ./unixnotis-installer from this directory to install bundled binaries." \ + "The installer writes user-level files only and supports systemd, dinit, runit, and s6." \ + > "$path" +} + +main "$@" From b5c3f326dedef7eba358472ae639b2993eb0e393 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 2 Jul 2026 21:08:36 -0500 Subject: [PATCH 25/27] add manual release package workflow Provide a workflow_dispatch release package job that validates the script and uploads tarball plus checksum artifacts for manual release publishing. --- .github/workflows/release.yml | 80 +++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..3fcdf6e3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,80 @@ +name: Release Package + +on: + # Release packaging is manual so maintainers can write the public changelog by hand + workflow_dispatch: + inputs: + tag: + description: Release tag to package, such as v1.0.0 + required: true + type: string + +permissions: + # The workflow builds archives only; publishing a GitHub Release stays a manual action + contents: read + +concurrency: + # A release tag should produce one archive set, so do not cancel a run already packaging it + group: release-package-${{ github.ref }}-${{ inputs.tag }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +defaults: + run: + shell: bash + +jobs: + package: + name: Build release tarball + runs-on: ubuntu-24.04 + container: debian:trixie-slim + timeout-minutes: 45 + + steps: + - name: Install system dependencies + run: | + set -euo pipefail + apt-get update + apt-get install -y --no-install-recommends \ + bash \ + build-essential \ + ca-certificates \ + curl \ + git \ + libgtk-4-dev \ + libgtk4-layer-shell-dev \ + pkg-config \ + shellcheck \ + xz-utils \ + zstd + + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install Rust toolchain + run: | + set -euo pipefail + curl --proto '=https' --tlsv1.2 -fsS https://sh.rustup.rs \ + | sh -s -- -y --profile minimal + echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" + source "${HOME}/.cargo/env" + rustup default stable + + - name: Check packaging script + run: shellcheck scripts/package-release.sh + + - name: Build package archive + # The script checks that Cargo's version matches the requested release tag + run: scripts/package-release.sh "${{ inputs.tag }}" + + - name: Upload package archive + uses: actions/upload-artifact@v4 + with: + name: unixnotis-${{ inputs.tag }}-release + path: | + dist/*.tar.zst + dist/*.sha256 + if-no-files-found: error From 37c8f62f372f677f430bc3d3899c5d973f1796e1 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 2 Jul 2026 21:08:42 -0500 Subject: [PATCH 26/27] document v1 release process Update install docs for release tarballs and record the manual release checklist, changelog flow, and packaging commands. --- CONTRIBUTING.md | 32 ++++++++++++++++++++++++++++++++ README.md | 25 ++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d842862f..a36ec5da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -176,6 +176,38 @@ Please try to keep pull requests easy to review. If docs live in the Wiki and are not updated in the PR, note what needs to be updated. +## Releases + +UnixNotis releases are cut from `master` with tags such as `v1.0.0`. + +Before tagging a release: + +- Confirm `dev` has been reviewed and merged into `master` +- Confirm config compatibility is documented, especially for renamed keys or changed defaults +- Confirm installer upgrade behavior is covered for existing config, service files, shell startup + entries, and bundled binary replacement +- Confirm release notes cover user-visible changes, known limitations, and any manual migration +- Confirm the workspace version in `Cargo.toml` matches the tag without the `v` prefix + +Build the release archive manually before creating the GitHub release: + +```sh +scripts/package-release.sh v1.0.0 +``` + +The same packaging step can be run from the manual **Release Package** GitHub Actions workflow. It +only builds and uploads workflow artifacts; it does not create tags, publish GitHub Releases, or +write release notes. + +Upload the generated `.tar.zst` and `.sha256` files from `dist/` or from the workflow artifact to +the GitHub release by hand. The tarball contains `unixnotis-installer`, +`unixnotis-release.json`, and the bundled runtime binaries under `bin/`. Release archives are +intentionally not distro packages and do not produce AppImage artifacts. + +The manually written GitHub release body is the changelog for that tag. Keep it focused on +user-visible changes, compatibility notes, and any upgrade steps that generated PR summaries would +miss. + ## Bug reports and fixes If fixing a bug: diff --git a/README.md b/README.md index 2063ee3b..e6601a99 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,25 @@ git clone https://github.com/locainin/UnixNotis.wiki.git - A supported user service manager for installer-managed startup. - Default: `systemd --user`. - Alternatives: `dinit --user`, user runit, or user s6-rc. -- Rust toolchain for builds and the installer. +- Rust toolchain for source builds. +- `curl` for the installer's best-effort update check. ## Quick start ![Installer CLI](assets/images/InstallerCLI.png) +Download the current release tarball from +[GitHub Releases](https://github.com/locainin/UnixNotis/releases), extract it, then run: + +```sh +./unixnotis-installer +``` + +Release archives include the installer and the bundled runtime binaries, so they do not require a +Rust toolchain on the target system. + +Source checkouts can still run the installer directly: + ```sh git clone https://github.com/locainin/UnixNotis cd UnixNotis @@ -64,6 +77,16 @@ The installer builds the release binaries, installs them under `$HOME/.local/bin default config/theme files, syncs the live Wayland session environment, and enables the selected user service. +When launched from a downloaded release, the installer verifies the bundled binaries and then copies +them into `$HOME/.local/bin` instead of building from source. The TUI shows the installed version and +reports when a newer GitHub release is available. + +Maintainers can build a local release archive manually: + +```sh +scripts/package-release.sh v1.0.0 +``` + ## Development ```sh From 372f2468a3c4099a41f6901c62a4a71ef60f6ed4 Mon Sep 17 00:00:00 2001 From: locainin Date: Thu, 2 Jul 2026 21:08:48 -0500 Subject: [PATCH 27/27] keep systemd reinstall scoped Stop only the UnixNotis user unit during reinstall and avoid wiring the service as a requester of graphical-session.target. --- .../src/service_manager/systemd.rs | 12 ++++-------- .../src/service_manager/tests/systemd.rs | 13 ++----------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/crates/unixnotis-installer/src/service_manager/systemd.rs b/crates/unixnotis-installer/src/service_manager/systemd.rs index 5235de8a..e4c00c8c 100644 --- a/crates/unixnotis-installer/src/service_manager/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/systemd.rs @@ -94,15 +94,11 @@ pub fn disable_now_command() -> Option { } pub fn stop_for_reinstall_command() -> Option { + // Stop only this unit during reinstall so systemd never treats the user session as disposable Some(CommandSpec::new( - format!("systemctl --user --job-mode=replace-irreversibly stop {SERVICE_NAME}"), + format!("systemctl --user stop {SERVICE_NAME}"), "systemctl", - [ - "--user", - "--job-mode=replace-irreversibly", - "stop", - SERVICE_NAME, - ], + ["--user", "stop", SERVICE_NAME], )) } @@ -150,8 +146,8 @@ fn render_unit(bin_dir: &Path) -> String { [ "[Unit]".to_string(), "Description=UnixNotis Notification Daemon".to_string(), + // Order after the graphical session without pulling that target into the unit graph "After=graphical-session.target".to_string(), - "Wants=graphical-session.target".to_string(), String::new(), "[Service]".to_string(), "Type=simple".to_string(), diff --git a/crates/unixnotis-installer/src/service_manager/tests/systemd.rs b/crates/unixnotis-installer/src/service_manager/tests/systemd.rs index 2478994d..708af0d3 100644 --- a/crates/unixnotis-installer/src/service_manager/tests/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/tests/systemd.rs @@ -24,7 +24,6 @@ fn systemd_backend_renders_exact_unit_artifact() { "[Unit]\n\ Description=UnixNotis Notification Daemon\n\ After=graphical-session.target\n\ - Wants=graphical-session.target\n\ \n\ [Service]\n\ Type=simple\n\ @@ -100,19 +99,11 @@ fn systemd_backend_commands_match_existing_behavior() { &["--user", "disable", "--now", UNIXNOTIS_DAEMON_SERVICE] ); - // Reinstall uses replace-irreversibly so session autostart cannot cancel the stop job + // Reinstall should stop only UnixNotis and never broaden into user-session targets let stop = manager .stop_for_reinstall_command() .expect("systemd can stop during reinstall"); - assert_eq!( - stop.args(), - &[ - "--user", - "--job-mode=replace-irreversibly", - "stop", - UNIXNOTIS_DAEMON_SERVICE, - ] - ); + assert_eq!(stop.args(), &["--user", "stop", UNIXNOTIS_DAEMON_SERVICE]); } #[test]