use super::*;
use crate::discord::notification::READ_STATE_MENTION_LOW_IMPORTANCE;
fn channel_with_last_message(channel_id: Id<ChannelMarker>, last_message_id: u64) -> ChannelInfo {
ChannelInfo {
last_message_id: Some(Id::new(last_message_id)),
guild_id: Some(Id::new(1)),
name: "general".to_owned(),
..channel_info(channel_id, "GuildText", Vec::new())
}
}
#[test]
fn channel_unread_state_follows_ack_pointer() {
let cases = [
(100, None, ChannelUnreadState::Unread),
(200, Some(150), ChannelUnreadState::Unread),
(200, Some(200), ChannelUnreadState::Seen),
];
for (latest_message_id, last_acked_message_id, expected) in cases {
let channel_id = Id::new(7);
let mut state = DiscordState::default();
state.apply_event(&AppEvent::ChannelUpsert(channel_with_last_message(
channel_id,
latest_message_id,
)));
if let Some(last_acked_message_id) = last_acked_message_id {
state.apply_event(&AppEvent::ReadStateInit {
entries: vec![read_state_info(
channel_id,
Some(Id::new(last_acked_message_id)),
0,
)],
});
}
assert_eq!(state.channel_unread(channel_id), expected);
}
}
#[test]
fn typed_read_states_and_ack_metadata_remain_distinct_across_snapshots() {
let guild_id = Id::new(1);
let channel_id = Id::new(7);
let thread_id = Id::new(8);
let mut state = DiscordState::default();
state.apply_event(&guild_create_event(GuildCreateFixture {
guild_id,
channels: vec![
channel_with_last_message(channel_id, 100),
guild_thread_channel(guild_id, thread_id, channel_id, "thread"),
],
..GuildCreateFixture::new(guild_id)
}));
state.apply_event(&AppEvent::ReadStateInit {
entries: vec![
ReadStateInfo {
last_acked_message_id: Some(Id::new(90)),
last_pin_timestamp: Some("2026-07-24T00:00:00.000Z".to_owned()),
flags: 5,
last_viewed: Some(2_000),
..ReadStateInfo::test(channel_id)
},
ReadStateInfo {
read_state_type: 1,
last_acked_message_id: Some(Id::new(80)),
badge_count: 3,
..ReadStateInfo::test(channel_id)
},
],
});
let (channel_flags, last_viewed) = state.channel_ack_metadata(channel_id);
let (thread_flags, _) = state.channel_ack_metadata(thread_id);
assert_eq!(channel_flags, Some(1));
assert_eq!(thread_flags, Some(3));
assert!(last_viewed > 0);
let restored = state
.snapshot(crate::discord::SnapshotRevision::default())
.to_state();
let channel_read = restored
.notifications
.read_states
.get(&channel_id)
.expect("channel read state should survive");
let non_channel_read = restored
.notifications
.non_channel_read_states
.get(&(1, channel_id.get()))
.expect("typed non-channel state should survive");
assert_eq!(channel_read.flags, 5);
assert_eq!(channel_read.last_viewed, Some(2_000));
assert_eq!(
channel_read.last_pin_timestamp.as_deref(),
Some("2026-07-24T00:00:00.000Z")
);
assert_eq!(non_channel_read.last_acked_id, Some(80));
assert_eq!(non_channel_read.badge_count, 3);
}
#[test]
fn current_user_message_create_keeps_channel_seen() {
let channel_id = Id::new(7);
let current_user_id = Id::new(10);
let mut state = DiscordState::default();
state.apply_event(&AppEvent::Ready {
user: "me".to_owned(),
user_id: Some(current_user_id),
});
state.apply_event(&AppEvent::ChannelUpsert(channel_with_last_message(
channel_id, 100,
)));
state.apply_event(&AppEvent::ReadStateInit {
entries: vec![read_state_info(channel_id, Some(Id::new(100)), 0)],
});
{
let read = state
.notifications_mut()
.read_states
.get_mut(&channel_id)
.expect("read state exists");
read.mention_count = 3;
read.notification_count = 2;
read.flags |= READ_STATE_MENTION_LOW_IMPORTANCE;
}
state.apply_event(&message_create_event(MessageCreateFixture {
guild_id: Some(Id::new(1)),
channel_id,
message_id: Id::new(200),
author_id: current_user_id,
author: "me".to_owned(),
content: Some("sent from this account".to_owned()),
..MessageCreateFixture::test_fixture_default()
}));
assert_eq!(state.channel_unread(channel_id), ChannelUnreadState::Seen);
assert_eq!(state.channel_ack_target(channel_id), None);
assert_eq!(state.channel_unread_message_count(channel_id), 0);
let read = state
.notifications
.read_states
.get(&channel_id)
.expect("read state exists");
assert_eq!(read.mention_count, 0);
assert_eq!(read.notification_count, 0);
assert_eq!(read.flags & READ_STATE_MENTION_LOW_IMPORTANCE, 0);
}
#[test]
fn message_ack_without_mention_count_retains_mentions_and_flags() {
let channel_id = Id::new(7);
let mut state = DiscordState::default();
state.apply_event(&AppEvent::ReadStateInit {
entries: vec![ReadStateInfo {
last_acked_message_id: Some(Id::new(100)),
mention_count: 3,
flags: READ_STATE_MENTION_LOW_IMPORTANCE,
..ReadStateInfo::test(channel_id)
}],
});
state.apply_event(&AppEvent::MessageAck {
channel_id,
message_id: Id::new(200),
mention_count: None,
flags: None,
last_viewed: None,
});
let read = state
.notifications
.read_states
.get(&channel_id)
.expect("read state exists");
assert_eq!(read.last_acked_message_id, Some(Id::new(200)));
assert_eq!(read.mention_count, 3);
assert_eq!(
read.flags & READ_STATE_MENTION_LOW_IMPORTANCE,
READ_STATE_MENTION_LOW_IMPORTANCE
);
}
#[test]
fn channel_with_pending_mentions_reports_mention_count() {
let channel_id = Id::new(7);
let mut state = DiscordState::default();
state.apply_event(&AppEvent::ChannelUpsert(channel_with_last_message(
channel_id, 200,
)));
state.apply_event(&AppEvent::ReadStateInit {
entries: vec![read_state_info(channel_id, Some(Id::new(200)), 3)],
});
assert_eq!(
state.channel_unread(channel_id),
ChannelUnreadState::Mentioned(3)
);
}
#[test]
fn guild_unread_sums_channel_mentions_before_plain_unread() {
let first_channel_id = Id::new(7);
let second_channel_id = Id::new(8);
let third_channel_id = Id::new(9);
let mut state = DiscordState::default();
state.apply_event(&AppEvent::ChannelUpsert(channel_with_last_message(
first_channel_id,
200,
)));
state.apply_event(&AppEvent::ChannelUpsert(channel_with_last_message(
second_channel_id,
300,
)));
state.apply_event(&AppEvent::ChannelUpsert(channel_with_last_message(
third_channel_id,
400,
)));
state.apply_event(&AppEvent::ReadStateInit {
entries: vec![
read_state_info(first_channel_id, Some(Id::new(200)), 2),
read_state_info(second_channel_id, Some(Id::new(300)), 3),
read_state_info(third_channel_id, Some(Id::new(100)), 0),
],
});
assert_eq!(
state.guild_unread(Id::new(1)),
ChannelUnreadState::Mentioned(5)
);
}
#[test]
fn message_ack_clears_outstanding_mentions_and_advances_pointer() {
let channel_id = Id::new(7);
let mut state = DiscordState::default();
state.apply_event(&AppEvent::ChannelUpsert(channel_with_last_message(
channel_id, 500,
)));
state.apply_event(&AppEvent::ReadStateInit {
entries: vec![read_state_info(channel_id, Some(Id::new(100)), 5)],
});
assert_eq!(
state.channel_unread(channel_id),
ChannelUnreadState::Mentioned(5)
);
state.apply_event(&message_ack_event(MessageAckFixture {
channel_id,
message_id: Id::new(500),
..MessageAckFixture::new()
}));
assert_eq!(state.channel_unread(channel_id), ChannelUnreadState::Seen);
assert_eq!(
state.channel_ack_target(channel_id),
None,
"fully-acked channels need no further ack"
);
}
#[test]
fn stale_message_ack_does_not_reopen_unread_state() {
let guild_id = Id::new(1);
let channel_id = Id::new(7);
let mut state = DiscordState::default();
state.apply_event(&AppEvent::Ready {
user: "me".to_owned(),
user_id: Some(Id::new(10)),
});
state.apply_event(&AppEvent::ChannelUpsert(channel_with_last_message(
channel_id, 500,
)));
state.apply_event(&user_guild_settings_init(vec![notification_settings(
guild_id,
NotificationLevel::AllMessages,
)]));
state.apply_event(&latest_history_loaded(
channel_id,
(101..=105)
.map(|message_id| MessageInfo {
guild_id: Some(guild_id),
..message_info(channel_id, message_id, "hello")
})
.collect(),
));
state.apply_event(&AppEvent::ReadStateInit {
entries: vec![read_state_info(channel_id, Some(Id::new(100)), 0)],
});
assert_eq!(state.channel_unread_message_count(channel_id), 5);
state.apply_event(&message_ack_event(MessageAckFixture {
channel_id,
message_id: Id::new(500),
..MessageAckFixture::new()
}));
state.apply_event(&message_ack_event(MessageAckFixture {
channel_id,
message_id: Id::new(100),
mention_count: 5,
}));
assert_eq!(state.channel_unread(channel_id), ChannelUnreadState::Seen);
assert_eq!(state.channel_unread_message_count(channel_id), 0);
assert_eq!(state.channel_ack_target(channel_id), None);
}
#[test]
fn channel_ack_target_returns_latest_when_unread() {
let channel_id = Id::new(7);
let mut state = DiscordState::default();
state.apply_event(&AppEvent::ChannelUpsert(channel_with_last_message(
channel_id, 500,
)));
assert_eq!(state.channel_ack_target(channel_id), Some(Id::new(500)));
}