use std::cell::Cell;
use std::path::PathBuf;
use std::time::Duration;
use teksilo_core::signal::Signal;
use teksilo_data::ListModel;
use teksilo_settings::{AppPaths, Migrator, PersistedListModel, SettingsFileError};
use crate::notification::NotificationEntry;
pub const DEFAULT_ARCHIVE_LIMIT: usize = 200;
pub const ARCHIVE_FILE_NAME: &str = "notifications";
#[derive(Debug, Clone)]
pub enum NotificationArchive {
InMemory { limit: usize },
Persistent { file_name: String, limit: usize },
}
impl NotificationArchive {
pub fn in_memory() -> Self {
Self::InMemory {
limit: DEFAULT_ARCHIVE_LIMIT,
}
}
pub fn in_memory_with_limit(limit: usize) -> Self {
Self::InMemory { limit }
}
pub fn persistent(file_name: impl Into<String>) -> Self {
Self::Persistent {
file_name: file_name.into(),
limit: DEFAULT_ARCHIVE_LIMIT,
}
}
pub fn persistent_with_limit(file_name: impl Into<String>, limit: usize) -> Self {
Self::Persistent {
file_name: file_name.into(),
limit,
}
}
pub fn limit(&self) -> usize {
match self {
Self::InMemory { limit } | Self::Persistent { limit, .. } => *limit,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum NotificationArchiveError {
#[error("notification archive file I/O failed: {0}")]
File(#[from] SettingsFileError),
}
enum ArchiveBackend {
InMemory(ListModel<NotificationEntry>),
Persistent(PersistedListModel<NotificationEntry>),
}
impl ArchiveBackend {
fn model(&self) -> &ListModel<NotificationEntry> {
match self {
Self::InMemory(m) => m,
Self::Persistent(p) => p.model(),
}
}
fn find_by_id(&self, id: u64) -> Option<(usize, NotificationEntry)> {
let model = self.model();
(0..model.len()).find_map(|i| {
model
.with_item(i, |e| e.clone())
.filter(|e| e.id == id)
.map(|e| (i, e))
})
}
fn upsert_front(&self, entry: NotificationEntry) {
match self {
Self::InMemory(m) => m.insert(0, entry),
Self::Persistent(p) => p.upsert_front(entry),
}
}
fn update_in_place(&self, entry: NotificationEntry) -> bool {
match self {
Self::InMemory(m) => match self.find_by_id(entry.id) {
Some((idx, _)) => {
m.set(idx, entry);
true
}
None => false,
},
Self::Persistent(p) => p.update_in_place(entry),
}
}
fn remove(&self, id: u64) -> bool {
match self {
Self::InMemory(m) => match self.find_by_id(id) {
Some((idx, _)) => {
m.remove(idx);
true
}
None => false,
},
Self::Persistent(p) => p.remove(&id),
}
}
fn clear(&self) {
match self {
Self::InMemory(m) => m.clear(),
Self::Persistent(p) => p.clear(),
}
}
fn flush_now(&self) -> Result<(), SettingsFileError> {
match self {
Self::InMemory(_) => Ok(()),
Self::Persistent(p) => p.flush_now(),
}
}
}
pub struct NotificationArchiveModel {
backend: ArchiveBackend,
limit: usize,
next_id: Cell<u64>,
unread_count: Signal<usize>,
version: Signal<u64>,
}
impl NotificationArchiveModel {
pub fn open(
archive: &NotificationArchive,
paths: &AppPaths,
debounce: Duration,
) -> Result<Self, NotificationArchiveError> {
let limit = archive.limit();
let backend = match archive {
NotificationArchive::InMemory { .. } => ArchiveBackend::InMemory(ListModel::new()),
NotificationArchive::Persistent { file_name, .. } => {
let path: PathBuf = paths.config_file(file_name);
let plm: PersistedListModel<NotificationEntry> =
PersistedListModel::open(path, debounce, Migrator::new())?;
ArchiveBackend::Persistent(plm)
}
};
let model = backend.model();
let next_id_seed = (0..model.len())
.filter_map(|i| model.with_item(i, |e| e.id))
.max()
.map(|m| m + 1)
.unwrap_or(1);
let initial_unread = (0..model.len())
.filter_map(|i| model.with_item(i, |e| !e.read))
.filter(|x| *x)
.count();
Ok(Self {
backend,
limit,
next_id: Cell::new(next_id_seed),
unread_count: Signal::new(initial_unread),
version: Signal::new(0),
})
}
pub fn in_memory() -> Self {
Self {
backend: ArchiveBackend::InMemory(ListModel::new()),
limit: DEFAULT_ARCHIVE_LIMIT,
next_id: Cell::new(1),
unread_count: Signal::new(0),
version: Signal::new(0),
}
}
pub fn entries(&self) -> &ListModel<NotificationEntry> {
self.backend.model()
}
pub fn unread_count(&self) -> &Signal<usize> {
&self.unread_count
}
pub fn version_signal(&self) -> &Signal<u64> {
&self.version
}
pub fn limit(&self) -> usize {
self.limit
}
fn bump_version(&self) {
let v = self.version.get();
self.version.set(v.wrapping_add(1));
}
pub fn flush_now(&self) -> Result<(), SettingsFileError> {
self.backend.flush_now()
}
pub fn push(&self, mut entry: NotificationEntry) {
self.bump_version();
let model = self.backend.model();
if let Some(ref new_dedup) = entry.dedup_id {
let merge_idx = (0..model.len()).find(|&i| {
model
.with_item(i, |e| e.dedup_id.as_deref() == Some(new_dedup.as_str()))
.unwrap_or(false)
});
if let Some(idx) = merge_idx {
if let Some(mut existing) = model.with_item(idx, |e| e.clone()) {
let now = entry.timestamp;
let title_changed = existing.title != entry.title;
let body_changed = existing.body != entry.body;
existing
.updates
.push(crate::notification::NotificationUpdate {
timestamp: now,
title: if title_changed {
Some(entry.title.clone())
} else {
None
},
body: if body_changed {
entry.body.clone()
} else {
None
},
progress: None,
});
existing.title = entry.title;
existing.body = entry.body;
existing.read = false;
self.backend.update_in_place(existing);
self.bump_unread();
return;
}
}
}
let next = self.next_id.get();
entry.id = next;
self.next_id.set(next.wrapping_add(1));
let is_unread = !entry.read;
self.backend.upsert_front(entry);
if model.len() > self.limit {
let last = model.len() - 1;
if let Some(evicted) = model.with_item(last, |e| e.clone()) {
if !evicted.read {
let n = self.unread_count.get();
self.unread_count.set(n.saturating_sub(1));
}
self.backend.remove(evicted.id);
}
}
if is_unread {
self.bump_unread();
}
}
fn bump_unread(&self) {
let n = self.unread_count.get();
self.unread_count.set(n.saturating_add(1));
}
pub fn mark_read_where(&self, mut predicate: impl FnMut(&NotificationEntry) -> bool) {
let model = self.backend.model();
let ids: Vec<u64> = (0..model.len())
.filter_map(|i| {
model
.with_item(i, |e| (!e.read && predicate(e)).then_some(e.id))
.flatten()
})
.collect();
if ids.is_empty() {
return;
}
let mut mutated = false;
for id in ids {
if let Some((_, mut entry)) = self.backend.find_by_id(id) {
entry.read = true;
self.backend.update_in_place(entry);
mutated = true;
let n = self.unread_count.get();
self.unread_count.set(n.saturating_sub(1));
}
}
if mutated {
self.bump_version();
}
}
pub fn mark_all_read(&self) {
let model = self.backend.model();
let unread_ids: Vec<u64> = (0..model.len())
.filter_map(|i| model.with_item(i, |e| (!e.read).then_some(e.id)).flatten())
.collect();
let mut mutated = false;
for id in unread_ids {
if let Some((_, mut entry)) = self.backend.find_by_id(id) {
entry.read = true;
self.backend.update_in_place(entry);
mutated = true;
}
}
self.unread_count.set(0);
if mutated {
self.bump_version();
}
}
pub fn clear(&self) {
let was_empty = self.backend.model().is_empty();
self.backend.clear();
self.unread_count.set(0);
if !was_empty {
self.bump_version();
}
}
pub fn clear_where(&self, mut predicate: impl FnMut(&NotificationEntry) -> bool) {
let model = self.backend.model();
let matches: Vec<(u64, bool)> = (0..model.len())
.filter_map(|i| {
model
.with_item(i, |e| predicate(e).then_some((e.id, !e.read)))
.flatten()
})
.collect();
if matches.is_empty() {
return;
}
let mut removed_any = false;
for (id, was_unread) in matches {
if self.backend.remove(id) {
removed_any = true;
if was_unread {
let n = self.unread_count.get();
self.unread_count.set(n.saturating_sub(1));
}
}
}
if removed_any {
self.bump_version();
}
}
pub fn remove_by_id(&self, id: u64) {
let Some((_, entry)) = self.backend.find_by_id(id) else {
return;
};
let was_unread = !entry.read;
self.backend.remove(id);
if was_unread {
let n = self.unread_count.get();
self.unread_count.set(n.saturating_sub(1));
}
self.bump_version();
}
}
impl std::fmt::Debug for NotificationArchiveModel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NotificationArchiveModel")
.field("entries", &self.entries().len())
.field("limit", &self.limit)
.field("unread_count", &self.unread_count.get())
.field(
"backend",
&match &self.backend {
ArchiveBackend::InMemory(_) => "InMemory",
ArchiveBackend::Persistent(_) => "Persistent",
},
)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::notification::ArchivedActionStyle;
use crate::toast::ToastRoute;
use teksilo_core::styles::{BannerSeverity, ToastPriority};
fn entry(title: &str) -> NotificationEntry {
NotificationEntry {
id: 0, severity: BannerSeverity::Info,
priority: ToastPriority::Normal,
title: title.to_string(),
body: None,
actions: Vec::new(),
timestamp: jiff::Timestamp::UNIX_EPOCH,
group: None,
source: None,
read: false,
dedup_id: None,
updates: Vec::new(),
route: ToastRoute::Broadcast,
}
}
#[test]
fn in_memory_starts_empty() {
let m = NotificationArchiveModel::in_memory();
assert_eq!(m.entries().len(), 0);
assert_eq!(m.unread_count().get(), 0);
assert_eq!(m.limit(), DEFAULT_ARCHIVE_LIMIT);
}
#[test]
fn push_inserts_newest_first_and_bumps_unread() {
let m = NotificationArchiveModel::in_memory();
m.push(entry("first"));
m.push(entry("second"));
m.push(entry("third"));
assert_eq!(m.entries().len(), 3);
assert_eq!(m.unread_count().get(), 3);
assert_eq!(
m.entries().with_item(0, |e| e.title.clone()),
Some("third".to_string())
);
assert_eq!(
m.entries().with_item(2, |e| e.title.clone()),
Some("first".to_string())
);
}
#[test]
fn push_stamps_distinct_increasing_ids() {
let m = NotificationArchiveModel::in_memory();
m.push(entry("a"));
m.push(entry("b"));
m.push(entry("c"));
let id0 = m.entries().with_item(0, |e| e.id).unwrap();
let id1 = m.entries().with_item(1, |e| e.id).unwrap();
let id2 = m.entries().with_item(2, |e| e.id).unwrap();
assert!(id0 > id1);
assert!(id1 > id2);
}
#[test]
fn bounded_eviction_drops_oldest() {
let m = NotificationArchiveModel {
backend: ArchiveBackend::InMemory(ListModel::new()),
limit: 3,
next_id: Cell::new(1),
unread_count: Signal::new(0),
version: Signal::new(0),
};
for i in 0..5 {
m.push(entry(&format!("t{i}")));
}
assert_eq!(m.entries().len(), 3, "bounded to limit");
assert_eq!(
m.unread_count().get(),
3,
"unread count tracks live entries"
);
assert_eq!(
m.entries().with_item(0, |e| e.title.clone()),
Some("t4".into())
);
assert_eq!(
m.entries().with_item(1, |e| e.title.clone()),
Some("t3".into())
);
assert_eq!(
m.entries().with_item(2, |e| e.title.clone()),
Some("t2".into())
);
}
#[test]
fn mark_all_read_zeros_count_and_flips_entries() {
let m = NotificationArchiveModel::in_memory();
m.push(entry("a"));
m.push(entry("b"));
assert_eq!(m.unread_count().get(), 2);
m.mark_all_read();
assert_eq!(m.unread_count().get(), 0);
assert!(m.entries().with_item(0, |e| e.read).unwrap());
assert!(m.entries().with_item(1, |e| e.read).unwrap());
}
#[test]
fn clear_empties_and_zeros_count() {
let m = NotificationArchiveModel::in_memory();
m.push(entry("a"));
m.push(entry("b"));
m.clear();
assert_eq!(m.entries().len(), 0);
assert_eq!(m.unread_count().get(), 0);
}
#[test]
fn remove_by_id_unread_decrements_count() {
let m = NotificationArchiveModel::in_memory();
m.push(entry("a"));
m.push(entry("b"));
assert_eq!(m.unread_count().get(), 2);
let b_id = m.entries().with_item(0, |e| e.id).unwrap(); m.remove_by_id(b_id);
assert_eq!(m.entries().len(), 1);
assert_eq!(m.unread_count().get(), 1);
assert_eq!(
m.entries().with_item(0, |e| e.title.clone()),
Some("a".to_string())
);
}
#[test]
fn remove_by_id_read_does_not_change_count() {
let m = NotificationArchiveModel::in_memory();
m.push(entry("a"));
m.mark_all_read();
assert_eq!(m.unread_count().get(), 0);
let a_id = m.entries().with_item(0, |e| e.id).unwrap();
m.remove_by_id(a_id);
assert_eq!(m.unread_count().get(), 0);
assert!(m.entries().is_empty());
}
#[test]
fn remove_by_id_unknown_id_is_a_noop() {
let m = NotificationArchiveModel::in_memory();
m.push(entry("a"));
let v_before = m.version_signal().get();
m.remove_by_id(999_999);
assert_eq!(m.entries().len(), 1, "nothing removed");
assert_eq!(
v_before,
m.version_signal().get(),
"no version bump for a no-op"
);
}
#[test]
fn remove_by_id_removes_the_right_entry_after_a_concurrent_insert_shifts_indices() {
let m = NotificationArchiveModel::in_memory();
m.push(entry("a")); m.push(entry("b")); assert_eq!(
m.entries().with_item(1, |e| e.title.clone()),
Some("a".to_string()),
"precondition: a is at index 1"
);
let a_id = m
.entries()
.with_item(1, |e| e.id)
.expect("a's id at index 1");
m.entries().insert(0, entry("peer-inserted"));
assert_eq!(
m.entries().with_item(2, |e| e.title.clone()),
Some("a".to_string()),
"precondition: the insert shifted a to index 2"
);
m.remove_by_id(a_id);
assert_eq!(m.entries().len(), 2, "exactly one entry removed");
let remaining: Vec<String> = (0..m.entries().len())
.map(|i| m.entries().with_item(i, |e| e.title.clone()).unwrap())
.collect();
assert!(
remaining.contains(&"b".to_string()),
"b survives: {remaining:?}"
);
assert!(
remaining.contains(&"peer-inserted".to_string()),
"peer-inserted survives: {remaining:?}"
);
assert!(
!remaining.contains(&"a".to_string()),
"a — the one actually targeted by id — is gone: {remaining:?}"
);
}
#[test]
fn update_in_place_merges_by_dedup_id() {
let m = NotificationArchiveModel::in_memory();
let mut first = entry("Uploading 1 of 7");
first.dedup_id = Some("upload".to_string());
m.push(first);
assert_eq!(m.entries().len(), 1);
assert_eq!(m.unread_count().get(), 1);
m.mark_all_read();
assert_eq!(m.unread_count().get(), 0);
let mut second = entry("Uploading 4 of 7");
second.dedup_id = Some("upload".to_string());
m.push(second);
assert_eq!(m.entries().len(), 1, "update merges into existing row");
assert_eq!(
m.unread_count().get(),
1,
"in-place update is also new info"
);
let merged = m.entries().with_item(0, |e| e.clone()).unwrap();
assert_eq!(merged.title, "Uploading 4 of 7");
assert_eq!(merged.updates.len(), 1);
assert_eq!(merged.updates[0].title.as_deref(), Some("Uploading 4 of 7"));
assert!(!merged.read, "in-place update resets read state");
}
#[test]
fn update_in_place_only_merges_on_dedup_match() {
let m = NotificationArchiveModel::in_memory();
let mut a = entry("first");
a.dedup_id = Some("x".to_string());
m.push(a);
let mut b = entry("second");
b.dedup_id = Some("y".to_string());
m.push(b);
assert_eq!(m.entries().len(), 2);
m.push(entry("third"));
assert_eq!(m.entries().len(), 3);
}
#[test]
fn persistent_round_trip() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let archive = NotificationArchive::persistent("notifications_test");
{
let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
m.push(entry("first"));
m.push(entry("second"));
m.flush_now().unwrap();
assert_eq!(m.entries().len(), 2);
}
let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
assert_eq!(m.entries().len(), 2);
assert_eq!(
m.entries().with_item(0, |e| e.title.clone()),
Some("second".into())
);
assert_eq!(m.unread_count().get(), 2);
m.push(entry("third"));
let third_id = m.entries().with_item(0, |e| e.id).unwrap();
let second_id = m.entries().with_item(1, |e| e.id).unwrap();
assert!(
third_id > second_id,
"ids continue increasing across restarts (third {third_id} > second {second_id})"
);
}
#[test]
fn mark_all_read_persists_across_reopen() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let archive = NotificationArchive::persistent("mark_read_test");
{
let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
m.push(entry("a"));
m.push(entry("b"));
m.mark_all_read();
m.flush_now().unwrap();
}
let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
assert_eq!(
reopened.unread_count().get(),
0,
"read state must have been persisted, not just live-mutated"
);
assert!(reopened.entries().with_item(0, |e| e.read).unwrap());
assert!(reopened.entries().with_item(1, |e| e.read).unwrap());
}
#[test]
fn remove_by_id_persists_across_reopen() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let archive = NotificationArchive::persistent("remove_test");
let removed_title;
{
let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
m.push(entry("a"));
m.push(entry("b"));
let b_id = m.entries().with_item(0, |e| e.id).unwrap();
removed_title = m.entries().with_item(0, |e| e.title.clone()).unwrap();
m.remove_by_id(b_id);
m.flush_now().unwrap();
assert_eq!(m.entries().len(), 1);
}
let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
assert_eq!(
reopened.entries().len(),
1,
"the removal must have reached disk, not just the live model"
);
assert_eq!(
reopened.entries().with_item(0, |e| e.title.clone()),
Some("a".to_string())
);
assert_ne!(
reopened.entries().with_item(0, |e| e.title.clone()),
Some(removed_title)
);
}
#[test]
fn dedup_merge_update_in_place_persists_across_reopen() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let archive = NotificationArchive::persistent("dedup_test");
{
let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
let mut first = entry("Uploading 1 of 7");
first.dedup_id = Some("upload".to_string());
m.push(first);
let mut second = entry("Uploading 4 of 7");
second.dedup_id = Some("upload".to_string());
m.push(second);
m.flush_now().unwrap();
assert_eq!(m.entries().len(), 1, "merged into one row");
}
let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
assert_eq!(reopened.entries().len(), 1, "still one row after reopen");
let merged = reopened.entries().with_item(0, |e| e.clone()).unwrap();
assert_eq!(
merged.title, "Uploading 4 of 7",
"the in-place update's title must have persisted, not the original"
);
assert_eq!(
merged.updates.len(),
1,
"the appended NotificationUpdate must have persisted"
);
}
#[test]
fn clear_persists_across_reopen() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let archive = NotificationArchive::persistent("clear_test");
{
let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
m.push(entry("a"));
m.push(entry("b"));
m.clear();
m.flush_now().unwrap();
assert_eq!(m.entries().len(), 0);
}
let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
assert_eq!(
reopened.entries().len(),
0,
"the clear must have reached disk, not just the live model"
);
}
#[test]
fn bounded_eviction_persists_across_reopen() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let paths = AppPaths::for_testing(dir.path());
let archive = NotificationArchive::persistent_with_limit("eviction_test", 2);
{
let m = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
m.push(entry("t0"));
m.push(entry("t1"));
m.push(entry("t2")); m.flush_now().unwrap();
assert_eq!(m.entries().len(), 2);
}
let reopened = NotificationArchiveModel::open(&archive, &paths, Duration::ZERO).unwrap();
assert_eq!(
reopened.entries().len(),
2,
"the eviction must have reached disk, not just the live model"
);
let titles: Vec<String> = (0..reopened.entries().len())
.map(|i| {
reopened
.entries()
.with_item(i, |e| e.title.clone())
.unwrap()
})
.collect();
assert!(
!titles.contains(&"t0".to_string()),
"t0 was evicted: {titles:?}"
);
assert!(titles.contains(&"t1".to_string()));
assert!(titles.contains(&"t2".to_string()));
}
#[test]
fn version_signal_bumps_on_push_mark_clear_remove() {
let m = NotificationArchiveModel::in_memory();
let v0 = m.version_signal().get();
m.push(entry("a"));
let v1 = m.version_signal().get();
assert_ne!(v0, v1, "push bumps version");
m.push(entry("b"));
m.mark_all_read();
let v2 = m.version_signal().get();
assert_ne!(v1, v2, "mark_all_read bumps version");
let id0 = m.entries().with_item(0, |e| e.id).unwrap();
m.remove_by_id(id0);
let v3 = m.version_signal().get();
assert_ne!(v2, v3, "remove bumps version");
m.clear();
let v4 = m.version_signal().get();
assert_ne!(v3, v4, "clear bumps version");
}
#[test]
fn version_signal_does_not_bump_for_noops() {
let m = NotificationArchiveModel::in_memory();
m.push(entry("a"));
let v_before = m.version_signal().get();
m.mark_all_read();
let v_after_mark1 = m.version_signal().get();
m.mark_all_read();
let v_after_mark2 = m.version_signal().get();
assert_eq!(
v_after_mark1, v_after_mark2,
"second mark_all_read with nothing to flip is a no-op (no version bump)"
);
m.clear();
let v_after_clear1 = m.version_signal().get();
m.clear();
let v_after_clear2 = m.version_signal().get();
assert_eq!(v_after_clear1, v_after_clear2, "clear on empty is a no-op");
let _ = v_before;
}
#[test]
fn mark_read_where_only_flips_matching_unread_entries() {
use crate::toast::ToastAudience;
let m = NotificationArchiveModel::in_memory();
let mut a = entry("audience a");
a.route = ToastRoute::Audience(ToastAudience::new(1));
m.push(a);
let mut b = entry("audience b");
b.route = ToastRoute::Audience(ToastAudience::new(2));
m.push(b);
assert_eq!(m.unread_count().get(), 2);
m.mark_read_where(|e| e.route == ToastRoute::Audience(ToastAudience::new(1)));
assert_eq!(
m.unread_count().get(),
1,
"only audience 1's entry was marked read"
);
let a_read = m
.entries()
.with_item(1, |e| e.read)
.expect("audience a is the oldest, at index 1");
let b_read = m
.entries()
.with_item(0, |e| e.read)
.expect("audience b is newest, at index 0");
assert!(a_read, "audience a's entry is now read");
assert!(!b_read, "audience b's entry is untouched");
}
#[test]
fn clear_where_only_removes_matching_entries() {
use crate::toast::ToastAudience;
let m = NotificationArchiveModel::in_memory();
let mut a = entry("audience a");
a.route = ToastRoute::Audience(ToastAudience::new(1));
m.push(a);
let mut b = entry("audience b");
b.route = ToastRoute::Audience(ToastAudience::new(2));
m.push(b);
assert_eq!(m.entries().len(), 2);
assert_eq!(m.unread_count().get(), 2);
m.clear_where(|e| e.route == ToastRoute::Audience(ToastAudience::new(1)));
assert_eq!(m.entries().len(), 1, "only audience 1's entry is removed");
assert_eq!(
m.unread_count().get(),
1,
"unread_count decrements for the removed unread entry"
);
assert_eq!(
m.entries().with_item(0, |e| e.title.clone()),
Some("audience b".to_string()),
"audience b's entry survives"
);
}
#[test]
fn entry_serde_round_trip() {
let original = NotificationEntry {
id: 42,
severity: BannerSeverity::Warning,
priority: ToastPriority::High,
title: "Heads up".into(),
body: Some("Details here".into()),
actions: vec![crate::notification::ArchivedAction {
label: "Open".into(),
intent_name: Some("app.open".into()),
style: ArchivedActionStyle::PrimaryButton,
closes_on_invoke: true,
}],
timestamp: jiff::Timestamp::UNIX_EPOCH,
group: Some("build".into()),
source: Some("build.success".into()),
read: false,
dedup_id: Some("build-1".into()),
updates: vec![],
route: ToastRoute::Audience(crate::toast::ToastAudience::new(7)),
};
let wrapper = teksilo_settings::ListFile {
version: 1,
items: vec![original.clone()],
};
let serialized = toml::to_string(&wrapper).expect("serialize");
let parsed: teksilo_settings::ListFile<NotificationEntry> =
toml::from_str(&serialized).expect("deserialize");
assert_eq!(parsed.items.len(), 1);
assert_eq!(parsed.items[0], original);
}
#[test]
fn every_windows_binding_sees_an_archive_mutation() {
use teksilo_core::binding::{BindingLevel, BindingRegistry};
use teksilo_core::widget_id::WidgetId;
let m = NotificationArchiveModel::in_memory();
let bell: WidgetId = slotmap::KeyData::from_ffi(1).into();
let windows: Vec<BindingRegistry> = (0..3).map(|_| BindingRegistry::new()).collect();
for reg in &windows {
m.version_signal().bind_to(bell, reg, BindingLevel::Rebuild);
}
for reg in &windows {
assert!(!reg.any_dirty(), "a fresh binding starts clean");
}
m.push(entry("first"));
for (i, reg) in windows.iter().enumerate() {
assert!(
reg.any_dirty(),
"window {i} missed the archive mutation — asking window 0 \
must not have consumed it"
);
}
}
}