use std::time::{SystemTime, UNIX_EPOCH};
use crate::discord::ids::{Id, marker::MessageMarker};
use ratatui::{
Terminal,
backend::TestBackend,
layout::Rect,
style::{Color, Modifier, Style},
};
use unicode_width::UnicodeWidthStr;
use super::{
ACCENT, DIM, DISCORD_EPOCH_MILLIS, ImagePreview, ImagePreviewState, MENTION_ORANGE,
MemberEntry, READ_DIM, SELECTED_FORUM_POST_BORDER, SELECTED_MESSAGE_BORDER,
SNOWFLAKE_TIMESTAMP_SHIFT, UNREAD_BRIGHT, channel_action_menu_lines, channel_unread_decoration,
composer_content_line_count, composer_lines, composer_prompt_line_count, composer_text,
date_separator_line, debug_log_popup_lines, dm_presence_dot_span, emoji_reaction_picker_lines,
focus_pane_at, footer_hint, format_message_sent_time, format_unix_millis_with_offset,
forum_post_reaction_summary, forum_post_scrollbar_visible_count, forum_post_viewport_lines,
guild_action_menu_lines, inline_image_preview_area, inline_image_preview_row,
member_action_menu_lines, member_display_label, member_name_style, message_action_menu_lines,
message_author_style, message_item_lines, message_starts_new_day, message_viewport_lines,
new_messages_notice_line, options_popup_lines, poll_vote_picker_lines,
primary_activity_summary, reaction_users_popup_lines, reaction_users_visible_line_count,
render_channels, render_guilds, selected_avatar_x_offset, selected_message_card_width,
selected_message_content_x_offset, sync_view_heights, user_profile_popup_has_avatar,
user_profile_popup_lines, user_profile_popup_lines_with_activities,
user_profile_popup_text_geometry,
};
use crate::{
config::DisplayOptions,
discord::{
ActivityEmoji, ActivityInfo, ActivityKind, AppEvent, AttachmentInfo, ChannelInfo,
ChannelRecipientState, ChannelState, ChannelUnreadState, ChannelVisibilityStats, EmbedInfo,
FriendStatus, GuildMemberState, MemberInfo, MentionInfo, MessageInfo, MessageKind,
MessageSnapshotInfo, MessageState, MutualGuildInfo, PollAnswerInfo, PollInfo,
PresenceStatus, ReactionEmoji, ReactionInfo, ReactionUserInfo, ReactionUsersInfo,
ReadStateInfo, ReplyInfo, RoleInfo, UserProfileInfo,
},
tui::{
format::{TextHighlightKind, truncate_display_width, truncate_display_width_from},
message_format::{
MessageContentLine, format_message_content, format_message_content_lines,
lay_out_reaction_chips, mention_highlight_style, poll_box_border,
poll_card_inner_width, reaction_line_test_spans, wrap_text_lines,
},
state::{
ChannelActionItem, ChannelActionKind, ChannelThreadItem, DashboardState,
DisplayOptionItem, EmojiReactionItem, FocusPane, GuildActionItem, GuildActionKind,
MemberActionItem, MemberActionKind, MessageActionItem, MessageActionKind,
PollVotePickerItem,
},
},
};
#[test]
fn options_popup_lines_show_selected_toggle_state() {
let items = vec![
DisplayOptionItem {
label: "Disable all image previews",
enabled: false,
value: None,
effective: false,
description: "Master switch.",
},
DisplayOptionItem {
label: "Show avatars",
enabled: true,
value: None,
effective: true,
description: "Message and profile avatars.",
},
DisplayOptionItem {
label: "Image preview quality",
enabled: true,
value: Some("balanced"),
effective: true,
description: "Attachment and embed previews.",
},
];
let lines = options_popup_lines(&items, 1);
assert_eq!(lines[0].spans[1].content, "[ ] ");
assert_eq!(lines[1].spans[0].content, "› ");
assert_eq!(lines[1].spans[1].content, "[x] ");
assert_eq!(lines[2].spans[1].content, "[balanced] ");
assert!(
lines.last().expect("hint line").spans[0]
.content
.contains("config.toml")
);
}
#[test]
fn custom_emoji_markup_uses_id_fallback_when_disabled() {
let message = message_with_content(Some("hello <:wave:42>".to_owned()));
let state = DashboardState::new_with_display_options(DisplayOptions {
show_custom_emoji: false,
..DisplayOptions::default()
});
let lines = format_message_content_lines(&message, &state, 200);
assert_eq!(lines[0].text, "hello 42");
assert!(lines[0].image_slots.is_empty());
}
#[test]
fn focus_pane_at_maps_dashboard_regions() {
let area = Rect::new(0, 0, 120, 20);
assert_eq!(focus_pane_at(area, 1, 1), Some(FocusPane::Guilds));
assert_eq!(focus_pane_at(area, 21, 1), Some(FocusPane::Channels));
assert_eq!(focus_pane_at(area, 50, 1), Some(FocusPane::Messages));
assert_eq!(focus_pane_at(area, 100, 1), Some(FocusPane::Members));
}
#[test]
fn focus_pane_at_ignores_footer_and_outside_area() {
let area = Rect::new(0, 0, 120, 20);
assert_eq!(focus_pane_at(area, 1, 19), None);
assert_eq!(focus_pane_at(area, 120, 1), None);
assert_eq!(focus_pane_at(area, 1, 20), None);
}
#[test]
fn sync_view_heights_reserves_message_input_inside_messages_pane() {
let mut state = DashboardState::new();
sync_view_heights(Rect::new(0, 0, 100, 20), &mut state);
assert_eq!(state.message_view_height(), 13);
}
#[test]
fn sync_view_heights_reserves_multiline_message_input_inside_messages_pane() {
let mut state = DashboardState::new();
state.push_composer_char('a');
state.push_composer_char('\n');
state.push_composer_char('b');
state.push_composer_char('\n');
state.push_composer_char('c');
sync_view_heights(Rect::new(0, 0, 100, 20), &mut state);
assert_eq!(state.message_view_height(), 11);
}
#[test]
fn composer_height_accounts_for_soft_wrapping() {
let mut state = DashboardState::new();
for _ in 0..100 {
state.push_composer_char('x');
}
sync_view_heights(Rect::new(0, 0, 100, 20), &mut state);
assert!(state.message_view_height() < 14);
}
#[test]
fn composer_prompt_line_count_uses_display_width_for_wide_chars() {
assert_eq!(composer_prompt_line_count("漢字仮", 4), 2);
}
#[test]
fn reply_composer_text_uses_original_reply_target_after_selection_changes() {
let mut state = state_with_message();
state.open_selected_message_actions();
state.activate_selected_message_action();
push_message(&mut state, 2, "newer selected message");
assert_eq!(
state
.selected_message_state()
.and_then(|message| message.content.as_deref()),
Some("newer selected message")
);
assert_eq!(composer_text(&state, 80), "reply to hello\n> ");
}
#[test]
fn reply_composer_hint_line_is_dim() {
let mut state = state_with_message();
state.open_selected_message_actions();
state.activate_selected_message_action();
let lines = composer_lines(&state, 80);
assert_eq!(
line_texts_from_ratatui(&lines),
vec!["reply to hello", "> "]
);
assert_eq!(lines[0].spans[0].style.fg, Some(DIM));
assert_eq!(lines[1].spans[0].style.fg, None);
}
#[test]
fn one_to_one_dm_carries_presence_in_dot() {
let channel = channel_with_recipients("dm", &[PresenceStatus::DoNotDisturb]);
let dot = dm_presence_dot_span(&channel).expect("1-on-1 DM should produce a presence dot");
assert_eq!(dot.content.as_ref(), "● ");
assert_eq!(dot.style.fg, Some(Color::Red));
}
#[test]
fn channel_unread_decoration_seen_dims_inactive_label() {
let base = Style::default().fg(Color::White);
let (badge, style) = channel_unread_decoration(ChannelUnreadState::Seen, base, false);
assert!(badge.is_none());
assert!(!style.add_modifier.contains(Modifier::BOLD));
assert!(!style.add_modifier.contains(Modifier::DIM));
assert_eq!(style.fg, Some(READ_DIM));
}
#[test]
fn channel_unread_decoration_unread_brightens_and_bolds() {
let base = Style::default().fg(Color::White);
let (badge, style) = channel_unread_decoration(ChannelUnreadState::Unread, base, false);
assert!(badge.is_none());
assert!(style.add_modifier.contains(Modifier::BOLD));
assert_eq!(style.fg, Some(UNREAD_BRIGHT));
}
#[test]
fn channel_unread_decoration_mentioned_uses_orange_with_count_badge() {
let base = Style::default().fg(Color::White);
let (badge, style) = channel_unread_decoration(ChannelUnreadState::Mentioned(3), base, false);
let badge = badge.expect("mention should include a count badge");
assert_eq!(badge.content.as_ref(), "(3) ");
assert_eq!(badge.style.fg, Some(MENTION_ORANGE));
assert!(badge.style.add_modifier.contains(Modifier::BOLD));
assert_eq!(style.fg, Some(MENTION_ORANGE));
assert!(style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn channel_unread_decoration_notified_uses_bright_count_badge() {
let base = Style::default().fg(Color::White);
let (badge, style) = channel_unread_decoration(ChannelUnreadState::Notified(3), base, false);
let badge = badge.expect("notification should include a count badge");
assert_eq!(badge.content.as_ref(), "(3) ");
assert_eq!(badge.style.fg, Some(UNREAD_BRIGHT));
assert!(badge.style.add_modifier.contains(Modifier::BOLD));
assert_eq!(style.fg, Some(UNREAD_BRIGHT));
assert!(style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn channel_unread_decoration_active_skips_decoration() {
let base = Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD);
let (badge, style) = channel_unread_decoration(ChannelUnreadState::Mentioned(2), base, true);
assert!(badge.is_none());
assert_eq!(style, base);
}
#[test]
fn server_pane_shows_guild_mention_badge() {
let guild_id = Id::new(1);
let channel_id = Id::new(2);
let mut state = DashboardState::new();
state.push_event(AppEvent::GuildCreate {
guild_id,
name: "guild".to_owned(),
member_count: None,
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
channel_id,
parent_id: None,
position: None,
last_message_id: Some(Id::new(10)),
name: "general".to_owned(),
kind: "GuildText".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}],
members: Vec::new(),
presences: Vec::new(),
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
state.push_event(AppEvent::ReadStateInit {
entries: vec![ReadStateInfo {
channel_id,
last_acked_message_id: Some(Id::new(10)),
mention_count: 2,
}],
});
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).expect("test terminal should build");
terminal
.draw(|frame| {
sync_view_heights(frame.area(), &mut state);
super::render(frame, &state, Vec::new(), Vec::new(), Vec::new(), None);
})
.expect("draw should succeed");
let buffer = terminal.backend().buffer();
let server_rows = (0..buffer.area.height)
.map(|row| {
(0..20)
.map(|col| buffer[(col, row)].symbol().to_owned())
.collect::<String>()
})
.collect::<Vec<_>>();
assert!(server_rows.iter().any(|row| row.contains("(2)")));
}
#[test]
fn active_server_mention_badge_keeps_active_name_style() {
let guild_id = Id::new(1);
let channel_id = Id::new(2);
let mut state = DashboardState::new();
state.push_event(AppEvent::GuildCreate {
guild_id,
name: "guild".to_owned(),
member_count: None,
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
channel_id,
parent_id: None,
position: None,
last_message_id: Some(Id::new(10)),
name: "general".to_owned(),
kind: "GuildText".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}],
members: Vec::new(),
presences: Vec::new(),
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
state.set_guild_view_height(20);
assert!(state.select_visible_pane_row(FocusPane::Guilds, 1));
state.confirm_selected_guild();
state.focus_pane(FocusPane::Messages);
state.push_event(AppEvent::ReadStateInit {
entries: vec![ReadStateInfo {
channel_id,
last_acked_message_id: Some(Id::new(10)),
mention_count: 2,
}],
});
let backend = TestBackend::new(40, 6);
let mut terminal = Terminal::new(backend).expect("test terminal should build");
terminal
.draw(|frame| render_guilds(frame, frame.area(), &state))
.expect("draw should succeed");
let buffer = terminal.backend().buffer();
let mut checked = false;
for row in 0..buffer.area.height {
let text = (0..buffer.area.width)
.map(|col| buffer[(col, row)].symbol().to_owned())
.collect::<String>();
if let Some(badge_col) = text.find("(2)") {
let name_col = text[badge_col..]
.find('g')
.map(|offset| badge_col + offset)
.expect("guild name starts with g after mention badge");
assert_eq!(buffer[(badge_col as u16, row)].fg, MENTION_ORANGE);
assert_eq!(buffer[(name_col as u16, row)].fg, Color::Green);
assert!(
buffer[(name_col as u16, row)]
.modifier
.contains(Modifier::BOLD)
);
checked = true;
break;
}
}
assert!(
checked,
"active guild row should include mention badge and guild name"
);
}
#[test]
fn server_pane_shows_direct_message_unread_channel_count() {
let state = state_with_unread_direct_messages();
let backend = TestBackend::new(40, 6);
let mut terminal = Terminal::new(backend).expect("test terminal should build");
terminal
.draw(|frame| render_guilds(frame, frame.area(), &state))
.expect("draw should succeed");
let buffer = terminal.backend().buffer();
let server_rows = (0..buffer.area.height)
.map(|row| {
(0..buffer.area.width)
.map(|col| buffer[(col, row)].symbol().to_owned())
.collect::<String>()
})
.collect::<Vec<_>>();
assert!(server_rows.iter().any(|row| row.contains("(1)")));
}
#[test]
fn dm_channel_pane_shows_unread_channel_count_badge() {
let mut state = state_with_unread_direct_messages();
state.confirm_selected_guild();
let backend = TestBackend::new(40, 6);
let mut terminal = Terminal::new(backend).expect("test terminal should build");
terminal
.draw(|frame| render_channels(frame, frame.area(), &state))
.expect("draw should succeed");
let buffer = terminal.backend().buffer();
let channel_rows = (0..buffer.area.height)
.map(|row| {
(0..buffer.area.width)
.map(|col| buffer[(col, row)].symbol().to_owned())
.collect::<String>()
})
.collect::<Vec<_>>();
assert!(channel_rows.iter().any(|row| row.contains("(1) @ new")));
}
#[test]
fn dm_channel_pane_shows_loaded_unread_message_count_badge() {
let mut state = state_with_unread_direct_messages_with_loaded_unread_messages(5);
state.confirm_selected_guild();
let backend = TestBackend::new(40, 6);
let mut terminal = Terminal::new(backend).expect("test terminal should build");
terminal
.draw(|frame| render_channels(frame, frame.area(), &state))
.expect("draw should succeed");
let buffer = terminal.backend().buffer();
let channel_rows = (0..buffer.area.height)
.map(|row| {
(0..buffer.area.width)
.map(|col| buffer[(col, row)].symbol().to_owned())
.collect::<String>()
})
.collect::<Vec<_>>();
assert!(channel_rows.iter().any(|row| row.contains("(5) @ new")));
assert!(!channel_rows.iter().any(|row| row.contains("(1) @ new")));
}
#[test]
fn message_viewport_author_uses_resolved_role_color() {
let guild_id = Id::new(1);
let channel_id = Id::new(2);
let author_id = Id::new(99);
let role_id = Id::new(100);
let mut state = DashboardState::new();
state.push_event(AppEvent::GuildCreate {
guild_id,
name: "guild".to_owned(),
member_count: None,
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
channel_id,
parent_id: None,
position: None,
last_message_id: None,
name: "general".to_owned(),
kind: "GuildText".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}],
members: vec![MemberInfo {
user_id: author_id,
display_name: "neo".to_owned(),
username: None,
is_bot: false,
avatar_url: None,
role_ids: vec![role_id],
}],
presences: vec![(author_id, PresenceStatus::Online)],
roles: vec![RoleInfo {
id: role_id,
name: "Blue".to_owned(),
color: Some(0x3366CC),
position: 10,
hoist: false,
permissions: 0,
}],
emojis: Vec::new(),
owner_id: None,
});
state.confirm_selected_guild();
state.confirm_selected_channel();
state.push_event(AppEvent::MessageCreate {
guild_id: None,
channel_id,
message_id: Id::new(1),
author_id,
author: "fallback".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("hello".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
let messages = state.messages();
let lines = message_viewport_lines(
&messages,
None,
&state,
super::message_viewport_layout(40, 80, 80, 16, 3),
&[],
);
assert_eq!(
lines[1].spans[1].style.fg,
Some(Color::Rgb(0x33, 0x66, 0xCC))
);
}
#[test]
fn pinned_message_remains_selectable_for_unpin_action() {
let mut state = state_with_message();
state.push_event(AppEvent::PinnedMessagesLoaded {
channel_id: Id::new(2),
messages: vec![message_info(10, "mod", "important announcement", true)],
});
state.enter_pinned_message_view(Id::new(2));
state.jump_bottom();
assert_eq!(
state.selected_message_state().map(|message| message.pinned),
Some(true)
);
state.open_selected_message_actions();
assert!(state.selected_message_action_items().iter().any(|action| {
action.kind == MessageActionKind::SetPinned(false) && action.label == "Unpin message"
}));
}
#[test]
fn later_history_does_not_clear_loaded_pin_state() {
let mut state = state_with_message();
state.push_event(AppEvent::PinnedMessagesLoaded {
channel_id: Id::new(2),
messages: vec![message_info(10, "mod", "important announcement", true)],
});
assert!(
state
.messages()
.into_iter()
.all(|message| message.id != Id::new(10))
);
state.push_event(AppEvent::MessageHistoryLoaded {
channel_id: Id::new(2),
before: None,
messages: vec![message_info(10, "mod", "important announcement", false)],
});
assert_eq!(state.pinned_messages().len(), 1);
assert!(
state
.messages()
.into_iter()
.any(|message| message.id == Id::new(10) && message.pinned)
);
}
#[test]
fn forum_post_lines_render_title_author_and_preview() {
let post = ChannelThreadItem {
channel_id: Id::new(30),
section_label: Some("Active posts".to_owned()),
label: "A useful Rust crate".to_owned(),
archived: false,
locked: true,
pinned: true,
preview_author_id: Some(Id::new(99)),
preview_author: Some("neo".to_owned()),
preview_author_color: Some(0x3366CC),
preview_content: Some("This crate solves a small but annoying problem".to_owned()),
preview_reactions: vec![ReactionInfo {
emoji: ReactionEmoji::Unicode("👍".to_owned()),
count: 2,
me: true,
}],
comment_count: Some(4),
last_activity_message_id: Some(Id::new(30)),
};
let lines = forum_post_viewport_lines(&[post], Some(0), 80, false);
let texts = line_texts_from_ratatui(&lines);
assert_eq!(texts.len(), 6);
assert_eq!(texts[0].trim_end(), "Active posts");
assert!(texts[1].starts_with("› ╭"));
assert!(!texts[1].contains("Active posts"));
assert!(texts.iter().all(|text| text.width() == 80));
assert!(texts[2].contains("A useful Rust crate"));
assert!(texts[2].contains("PINNED"));
assert!(texts[3].contains("neo: This crate solves"));
assert!(texts[4].contains("4 comments"));
assert!(texts[4].contains("[👍 2]"));
assert!(!texts[4].contains("pinned"));
assert!(texts[4].contains("locked"));
assert!(texts[5].starts_with(" ╰"));
assert_eq!(lines[2].spans[2].style.fg, Some(Color::White));
assert_eq!(lines[2].spans[3].style.fg, Some(Color::Yellow));
assert_eq!(
lines[3].spans[2].style.fg,
Some(Color::Rgb(0x33, 0x66, 0xCC))
);
assert_eq!(lines[3].spans[4].style.fg, Some(Color::White));
assert_eq!(lines[4].spans[2].style.fg, Some(Color::White));
assert_eq!(lines[4].spans[4].style.fg, Some(Color::Yellow));
assert_eq!(lines[4].spans[6].style.fg, Some(Color::White));
assert_eq!(lines[1].spans[1].style.fg, Some(SELECTED_FORUM_POST_BORDER));
assert_eq!(lines[2].spans[1].style.fg, Some(SELECTED_FORUM_POST_BORDER));
assert!(
lines
.iter()
.flat_map(|line| line.spans.iter())
.all(|span| span.style.bg.is_none())
);
}
#[test]
fn forum_post_reaction_summary_reserves_custom_emoji_image_slot() {
let reactions = vec![ReactionInfo {
emoji: ReactionEmoji::Custom {
id: Id::new(42),
name: Some("party".to_owned()),
animated: false,
},
count: 1,
me: true,
}];
assert_eq!(
forum_post_reaction_summary(&reactions, 80).as_deref(),
Some("[ 1]")
);
}
#[test]
fn forum_post_scrollbar_visible_count_uses_rendered_rows() {
assert_eq!(forum_post_scrollbar_visible_count(10), 10);
assert_eq!(forum_post_scrollbar_visible_count(0), 1);
}
#[test]
fn forum_post_lines_can_reserve_scrollbar_column() {
let post = ChannelThreadItem {
channel_id: Id::new(30),
section_label: None,
label: "A useful Rust crate".to_owned(),
archived: false,
locked: false,
pinned: false,
preview_author_id: Some(Id::new(99)),
preview_author: Some("neo".to_owned()),
preview_author_color: None,
preview_content: Some("short preview".to_owned()),
preview_reactions: Vec::new(),
comment_count: Some(1),
last_activity_message_id: Some(Id::new(30)),
};
let lines = forum_post_viewport_lines(
&[post],
Some(0),
selected_message_card_width(80, true),
false,
);
let texts = line_texts_from_ratatui(&lines);
assert!(texts[0].starts_with("› ╭"));
assert!(texts[0].ends_with("╮"));
assert!(texts[1].ends_with("│"));
assert!(texts[4].ends_with("╯"));
assert!(texts.iter().all(|text| text.width() == 79));
}
#[test]
fn forum_post_render_shows_scrollbar_when_posts_exceed_visible_cards() {
let mut state = state_with_forum_posts(10);
let dump = render_dashboard_dump(100, 20, &mut state);
assert!(dump.iter().any(|line| line.contains('┃')));
}
#[test]
fn history_message_author_uses_channel_guild_for_role_color() {
let guild_id = Id::new(1);
let channel_id = Id::new(2);
let author_id = Id::new(99);
let role_id = Id::new(100);
let mut state = DashboardState::new();
state.push_event(AppEvent::GuildCreate {
guild_id,
name: "guild".to_owned(),
member_count: None,
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
channel_id,
parent_id: None,
position: None,
last_message_id: None,
name: "general".to_owned(),
kind: "GuildText".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}],
members: vec![MemberInfo {
user_id: author_id,
display_name: "neo".to_owned(),
username: None,
is_bot: false,
avatar_url: None,
role_ids: vec![role_id],
}],
presences: vec![(author_id, PresenceStatus::Online)],
roles: vec![RoleInfo {
id: role_id,
name: "Blue".to_owned(),
color: Some(0x3366CC),
position: 10,
hoist: false,
permissions: 0,
}],
emojis: Vec::new(),
owner_id: None,
});
state.confirm_selected_guild();
state.confirm_selected_channel();
state.push_event(AppEvent::MessageHistoryLoaded {
channel_id,
before: None,
messages: vec![MessageInfo {
guild_id: None,
channel_id,
message_id: Id::new(1),
author_id,
author: "fallback".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
pinned: false,
reactions: Vec::new(),
content: Some("hello".to_owned()),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
..MessageInfo::default()
}],
});
let messages = state.messages();
let lines = message_viewport_lines(
&messages,
None,
&state,
super::message_viewport_layout(40, 80, 80, 16, 3),
&[],
);
assert_eq!(
lines[1].spans[1].style.fg,
Some(Color::Rgb(0x33, 0x66, 0xCC))
);
}
#[test]
fn user_profile_popup_styles_name_by_status() {
let profile = user_profile_info(10, "neo");
let state = DashboardState::new();
let lines = user_profile_popup_lines(&profile, &state, 40, PresenceStatus::Idle);
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Rgb(180, 140, 0)));
assert!(
lines[0].spans[0]
.style
.add_modifier
.contains(Modifier::BOLD)
);
}
#[test]
fn user_profile_popup_does_not_show_dm_hint_for_current_user() {
let profile = user_profile_info(10, "neo");
let mut state = DashboardState::new();
state.push_event(AppEvent::Ready {
user: "neo".to_owned(),
user_id: Some(Id::new(10)),
});
let lines = user_profile_popup_lines(&profile, &state, 40, PresenceStatus::Online);
let texts = line_texts_from_ratatui(&lines);
assert!(texts.iter().any(|line| line == "j/k scroll · Esc close"));
assert!(!texts.iter().any(|line| line.contains("m send DM")));
}
#[test]
fn user_profile_popup_does_not_show_dm_hint_for_other_users() {
let profile = user_profile_info(10, "alice");
let mut state = DashboardState::new();
state.push_event(AppEvent::Ready {
user: "neo".to_owned(),
user_id: Some(Id::new(99)),
});
let lines = user_profile_popup_lines(&profile, &state, 40, PresenceStatus::Online);
let texts = line_texts_from_ratatui(&lines);
assert!(texts.iter().any(|line| line == "j/k scroll · Esc close"));
assert!(!texts.iter().any(|line| line.contains("m send DM")));
}
#[test]
fn user_profile_popup_avatar_gutter_matches_geometry_in_narrow_layouts() {
let narrow_area = Rect::new(0, 0, 10, 20);
let wide_area = Rect::new(0, 0, 80, 20);
assert!(!user_profile_popup_has_avatar(narrow_area, true));
assert_eq!(
user_profile_popup_text_geometry(narrow_area, false),
user_profile_popup_text_geometry(
narrow_area,
user_profile_popup_has_avatar(narrow_area, true),
)
);
assert!(user_profile_popup_has_avatar(wide_area, true));
assert_ne!(
user_profile_popup_text_geometry(wide_area, false),
user_profile_popup_text_geometry(wide_area, user_profile_popup_has_avatar(wide_area, true)),
);
}
#[test]
fn user_profile_popup_renders_activity_section() {
let profile = user_profile_info(10, "neo");
let state = DashboardState::new();
let activities = vec![
ActivityInfo {
kind: ActivityKind::Custom,
name: "Custom Status".to_owned(),
details: None,
state: Some("Coding hard".to_owned()),
url: None,
application_id: None,
emoji: Some(ActivityEmoji {
name: "🦀".to_owned(),
id: None,
animated: false,
}),
},
ActivityInfo {
kind: ActivityKind::Listening,
name: "Spotify".to_owned(),
details: Some("Bohemian Rhapsody".to_owned()),
state: Some("Queen".to_owned()),
url: None,
application_id: None,
emoji: None,
},
ActivityInfo {
kind: ActivityKind::Playing,
name: "Concord".to_owned(),
details: None,
state: None,
url: None,
application_id: None,
emoji: None,
},
];
let lines = user_profile_popup_lines_with_activities(
&profile,
&state,
60,
PresenceStatus::Online,
&activities,
);
let texts = line_texts_from_ratatui(&lines);
assert!(texts.iter().any(|line| line == "ACTIVITY"));
assert!(texts.iter().any(|line| line == "🦀 Coding hard"));
assert!(texts.iter().any(|line| line == "Listening to Spotify"));
assert!(texts.iter().any(|line| line == "Bohemian Rhapsody"));
assert!(texts.iter().any(|line| line == "by Queen"));
assert!(texts.iter().any(|line| line == "Playing Concord"));
}
#[test]
fn primary_activity_summary_picks_custom_status_first() {
let activities = vec![
ActivityInfo {
kind: ActivityKind::Playing,
name: "Concord".to_owned(),
details: None,
state: None,
url: None,
application_id: None,
emoji: None,
},
ActivityInfo {
kind: ActivityKind::Custom,
name: "Custom Status".to_owned(),
details: None,
state: Some("Coding hard".to_owned()),
url: None,
application_id: None,
emoji: Some(ActivityEmoji {
name: "🦀".to_owned(),
id: None,
animated: false,
}),
},
];
assert_eq!(
primary_activity_summary(&activities),
Some("🦀 Coding hard".to_owned())
);
}
#[test]
fn primary_activity_summary_listening_includes_track_and_artist() {
let activities = vec![ActivityInfo {
kind: ActivityKind::Listening,
name: "Spotify".to_owned(),
details: Some("Bohemian Rhapsody".to_owned()),
state: Some("Queen".to_owned()),
url: None,
application_id: None,
emoji: None,
}];
assert_eq!(
primary_activity_summary(&activities),
Some("Listening to Spotify — Bohemian Rhapsody by Queen".to_owned())
);
}
#[test]
fn user_profile_popup_omits_activity_section_when_empty() {
let profile = user_profile_info(10, "neo");
let state = DashboardState::new();
let lines =
user_profile_popup_lines_with_activities(&profile, &state, 60, PresenceStatus::Online, &[]);
let texts = line_texts_from_ratatui(&lines);
assert!(!texts.iter().any(|line| line == "ACTIVITY"));
}
#[test]
fn user_profile_popup_lists_mutual_servers_without_selection_marker() {
let mut profile = user_profile_info(10, "neo");
profile.mutual_guilds = (1_u64..=3)
.map(|id| MutualGuildInfo {
guild_id: Id::new(id),
nick: None,
})
.collect();
let state = DashboardState::new();
let lines = user_profile_popup_lines(&profile, &state, 40, PresenceStatus::Online);
let texts = line_texts_from_ratatui(&lines);
assert!(texts.iter().any(|line| line == " • guild-1"));
assert!(texts.iter().any(|line| line == " • guild-3"));
assert!(!texts.iter().any(|line| line.starts_with("› ")));
}
#[test]
fn unknown_dm_status_uses_dim_presence_dot() {
let channel = channel_with_recipients("dm", &[PresenceStatus::Unknown]);
let dot = dm_presence_dot_span(&channel).expect("DM should still produce a dot");
assert_eq!(dot.style.fg, Some(Color::DarkGray));
}
#[test]
fn group_dm_has_no_presence_dot() {
let channel = channel_with_recipients(
"group-dm",
&[PresenceStatus::Online, PresenceStatus::DoNotDisturb],
);
assert!(dm_presence_dot_span(&channel).is_none());
}
#[test]
fn reply_composer_line_count_includes_reply_hint() {
let mut state = state_with_message();
state.open_selected_message_actions();
state.activate_selected_message_action();
state.push_composer_char('h');
state.push_composer_char('\n');
state.push_composer_char('i');
assert_eq!(composer_content_line_count(&state, 80), 3);
}
#[test]
fn image_attachment_replaces_empty_message_placeholder() {
let message = message_with_attachment(Some(String::new()), image_attachment());
assert_eq!(
format_message_content(&message, 200),
"[image: cat.png] 640x480"
);
}
#[test]
fn attachment_summary_uses_own_accent_line_after_text_content() {
let message = message_with_attachment(Some("look".to_owned()), image_attachment());
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(line_texts(&lines), vec!["look", "[image: cat.png] 640x480"]);
assert_eq!(lines[1].style, Style::default().fg(ACCENT));
}
#[test]
fn edited_message_appends_dim_italic_marker_to_content() {
let mut message = message_with_content(Some("hello".to_owned()));
message.edited_timestamp = Some("2026-05-07T12:34:56.000000+00:00".to_owned());
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(line_texts(&lines), vec!["hello (edited)"]);
let marker = lines[0]
.spans()
.into_iter()
.find(|span| span.content == " (edited)")
.expect("edited marker span should be present");
assert_eq!(marker.style.fg, Some(DIM));
assert!(marker.style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn wrapped_edited_marker_keeps_dim_italic_style() {
let mut message = message_with_content(Some("hello".to_owned()));
message.edited_timestamp = Some("2026-05-07T12:34:56.000000+00:00".to_owned());
let lines = format_message_content_lines(&message, &DashboardState::new(), 5);
assert_eq!(line_texts(&lines), vec!["hello", "(edited)"]);
let marker = lines[1]
.spans()
.into_iter()
.next()
.expect("wrapped edited marker span should be present");
assert_eq!(marker.style.fg, Some(DIM));
assert!(marker.style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn attachment_summary_renders_multiple_attachments_one_per_line() {
let mut message = message_with_attachment(Some("look".to_owned()), image_attachment());
message.attachments.push(file_attachment());
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(
line_texts(&lines),
vec!["look", "[image: cat.png] 640x480", "[file: notes.txt]"]
);
assert_eq!(lines[1].style, Style::default().fg(ACCENT));
assert_eq!(lines[2].style, Style::default().fg(ACCENT));
}
#[test]
fn message_content_lines_render_discord_embed_preview() {
let mut message = message_with_content(Some(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_owned(),
));
message.embeds = vec![youtube_embed()];
let lines = format_message_content_lines(&message, &DashboardState::new(), 80);
assert_eq!(
line_texts(&lines),
vec![
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
" ▎ YouTube",
" ▎ Example Video",
]
);
assert_eq!(lines[1].style.fg, Some(DIM));
assert!(lines[2].style.add_modifier.contains(Modifier::BOLD));
assert_eq!(lines[2].style.fg, Some(Color::Blue));
let marker_spans = lines[1].spans();
assert_eq!(marker_spans[0].content.as_ref(), " ▎ ");
assert_eq!(marker_spans[0].style.fg, Some(Color::Rgb(255, 0, 0)));
assert!(
!marker_spans[0]
.style
.add_modifier
.contains(Modifier::UNDERLINED)
);
}
#[test]
fn message_embed_hides_media_and_player_urls() {
let mut message = message_with_content(Some("watch this".to_owned()));
let mut embed = youtube_embed();
embed.video_url = Some("https://www.youtube.com/embed/dQw4w9WgXcQ".to_owned());
message.embeds = vec![embed];
let lines = format_message_content_lines(&message, &DashboardState::new(), 80);
assert_eq!(
line_texts(&lines),
vec![
"watch this",
" ▎ YouTube",
" ▎ Example Video",
" ▎ https://www.youtube.com/watch?v=dQw4w9WgXcQ",
]
);
}
#[test]
fn message_embed_url_underline_skips_marker() {
let mut message = message_with_content(Some("watch this".to_owned()));
let mut embed = youtube_embed();
embed.description = None;
embed.image_url = None;
message.embeds = vec![embed];
let lines = format_message_content_lines(&message, &DashboardState::new(), 80);
let url_spans = lines[3].spans();
assert_eq!(
line_texts(&lines),
vec![
"watch this",
" ▎ YouTube",
" ▎ Example Video",
" ▎ https://www.youtube.com/watch?v=dQw4w9WgXcQ",
]
);
assert_eq!(url_spans[0].content.as_ref(), " ▎ ");
assert_eq!(url_spans[0].style.fg, Some(Color::Rgb(255, 0, 0)));
assert!(
!url_spans[0]
.style
.add_modifier
.contains(Modifier::UNDERLINED)
);
assert_eq!(
url_spans[1].content.as_ref(),
"https://www.youtube.com/watch?v=dQw4w9WgXcQ"
);
assert!(
url_spans[1]
.style
.add_modifier
.contains(Modifier::UNDERLINED)
);
}
#[test]
fn embed_text_emits_inline_emoji_slot_for_image_overlay() {
let mut message = message_with_content(Some("see embed".to_owned()));
let mut embed = youtube_embed();
embed.title = Some("look <:party:99>!".to_owned());
message.embeds = vec![embed];
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
let slots: Vec<_> = lines
.iter()
.flat_map(|line| line.image_slots.iter())
.collect();
assert!(!slots.is_empty());
assert!(
slots
.iter()
.any(|slot| slot.url == "https://cdn.discordapp.com/emojis/99.png")
);
}
#[test]
fn message_embed_does_not_repeat_body_url() {
let mut message = message_with_content(Some(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_owned(),
));
let mut embed = youtube_embed();
embed.title = None;
embed.description = None;
embed.image_url = None;
message.embeds = vec![embed];
let lines = format_message_content_lines(&message, &DashboardState::new(), 80);
assert_eq!(
line_texts(&lines),
vec!["https://www.youtube.com/watch?v=dQw4w9WgXcQ", " ▎ YouTube"]
);
}
#[test]
fn message_content_preserves_explicit_newlines() {
let message = message_with_content(Some("hello\nworld".to_owned()));
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(line_texts(&lines), vec!["hello", "world"]);
}
#[test]
fn message_content_wraps_long_lines_to_content_width() {
let message = message_with_content(Some("abcdefghijkl".to_owned()));
let lines = format_message_content_lines(&message, &DashboardState::new(), 5);
assert_eq!(line_texts(&lines), vec!["abcde", "fghij", "kl"]);
}
#[test]
fn message_content_wraps_wide_characters_by_terminal_width() {
let message = message_with_content(Some("漢字仮名交じ".to_owned()));
let lines = format_message_content_lines(&message, &DashboardState::new(), 10);
assert_eq!(line_texts(&lines), vec!["漢字仮名交", "じ"]);
}
#[test]
fn message_content_renders_known_user_mentions() {
let message = message_with_content(Some("hello <@10>".to_owned()));
let state = state_with_member(10, "alice");
let lines = format_message_content_lines(&message, &state, 200);
assert_eq!(line_texts(&lines), vec!["hello @alice"]);
}
#[test]
fn message_content_keeps_unknown_user_mentions_raw() {
let message = message_with_content(Some("hello <@10>".to_owned()));
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(line_texts(&lines), vec!["hello <@10>"]);
}
#[test]
fn message_content_renders_mentions_from_message_metadata() {
let mut message = message_with_content(Some("hello <@10>".to_owned()));
message.mentions = vec![mention_info(10, "alice")];
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(line_texts(&lines), vec!["hello @alice"]);
}
#[test]
fn message_content_highlights_current_user_mentions() {
let mut message = message_with_content(Some("hello <@10>".to_owned()));
message.mentions = vec![mention_info(10, "username")];
let mut state = state_with_member(10, "server alias");
state.push_event(AppEvent::Ready {
user: "server alias".to_owned(),
user_id: Some(Id::new(10)),
});
let lines = message_item_lines(
message.author.clone(),
message_author_style(None),
"00:00".to_owned(),
format_message_content_lines(&message, &state, 200),
40,
0,
None,
0,
);
assert_eq!(
line_texts_from_ratatui(&lines),
vec!["oo neo 00:00", " hello @server alias", ""]
);
assert_eq!(lines[1].spans[2].content.as_ref(), "@server alias");
assert_eq!(
lines[1].spans[2].style.bg,
mention_highlight_style(TextHighlightKind::SelfMention).bg
);
}
#[test]
fn message_content_highlights_other_user_mentions_with_softer_color() {
let mut message = message_with_content(Some("hello <@10>".to_owned()));
message.mentions = vec![mention_info(10, "alice")];
let mut state = DashboardState::new();
state.push_event(AppEvent::Ready {
user: "neo".to_owned(),
user_id: Some(Id::new(99)),
});
let lines = message_item_lines(
message.author.clone(),
message_author_style(None),
"00:00".to_owned(),
format_message_content_lines(&message, &state, 200),
40,
0,
None,
0,
);
assert_eq!(
line_texts_from_ratatui(&lines),
vec!["oo neo 00:00", " hello @alice", ""]
);
assert_eq!(lines[1].spans[2].content.as_ref(), "@alice");
assert_eq!(
lines[1].spans[2].style.bg,
mention_highlight_style(TextHighlightKind::OtherMention).bg
);
assert_ne!(
lines[1].spans[2].style.bg,
mention_highlight_style(TextHighlightKind::SelfMention).bg,
"other-user mentions must not look like a self-mention notification"
);
}
#[test]
fn message_content_highlights_everyone_mentions_for_current_user() {
let message = message_with_content(Some("ping @everyone".to_owned()));
let mut state = DashboardState::new();
state.push_event(AppEvent::Ready {
user: "neo".to_owned(),
user_id: Some(Id::new(99)),
});
let lines = message_item_lines(
message.author.clone(),
message_author_style(None),
"00:00".to_owned(),
format_message_content_lines(&message, &state, 200),
40,
0,
None,
0,
);
assert_eq!(
line_texts_from_ratatui(&lines),
vec!["oo neo 00:00", " ping @everyone", ""]
);
assert_eq!(lines[1].spans[2].content.as_ref(), "@everyone");
assert_eq!(
lines[1].spans[2].style.bg,
mention_highlight_style(TextHighlightKind::SelfMention).bg
);
}
#[test]
fn message_content_highlights_mixed_everyone_and_direct_mentions_in_order() {
let mut message = message_with_content(Some("@everyone hello <@10>".to_owned()));
message.mentions = vec![mention_info(10, "neo")];
let mut state = DashboardState::new();
state.push_event(AppEvent::Ready {
user: "neo".to_owned(),
user_id: Some(Id::new(10)),
});
let lines = message_item_lines(
message.author.clone(),
message_author_style(None),
"00:00".to_owned(),
format_message_content_lines(&message, &state, 200),
40,
0,
None,
0,
);
assert_eq!(
line_texts_from_ratatui(&lines),
vec!["oo neo 00:00", " @everyone hello @neo", ""]
);
assert_eq!(lines[1].spans[1].content.as_ref(), "@everyone");
assert_eq!(lines[1].spans[3].content.as_ref(), "@neo");
assert_eq!(
lines[1].spans[1].style.bg,
mention_highlight_style(TextHighlightKind::SelfMention).bg
);
assert_eq!(
lines[1].spans[3].style.bg,
mention_highlight_style(TextHighlightKind::SelfMention).bg
);
}
#[test]
fn message_content_highlights_here_mentions_for_current_user() {
let message = message_with_content(Some("ping @here".to_owned()));
let mut state = DashboardState::new();
state.push_event(AppEvent::Ready {
user: "neo".to_owned(),
user_id: Some(Id::new(99)),
});
let lines = message_item_lines(
message.author.clone(),
message_author_style(None),
"00:00".to_owned(),
format_message_content_lines(&message, &state, 200),
40,
0,
None,
0,
);
assert_eq!(
line_texts_from_ratatui(&lines),
vec!["oo neo 00:00", " ping @here", ""]
);
assert_eq!(lines[1].spans[2].content.as_ref(), "@here");
assert_eq!(
lines[1].spans[2].style.bg,
mention_highlight_style(TextHighlightKind::SelfMention).bg
);
}
#[test]
fn message_content_highlights_role_mentions_with_role_name() {
let message = message_with_content(Some("hello <@&10>".to_owned()));
let state = state_with_role(10, "moderators");
let lines = message_item_lines(
message.author.clone(),
message_author_style(None),
"00:00".to_owned(),
format_message_content_lines(&message, &state, 200),
40,
0,
None,
0,
);
assert_eq!(
line_texts_from_ratatui(&lines),
vec!["oo neo 00:00", " hello @moderators", ""]
);
assert_eq!(lines[1].spans[2].content.as_ref(), "@moderators");
assert_eq!(
lines[1].spans[2].style.bg,
mention_highlight_style(TextHighlightKind::OtherMention).bg
);
}
#[test]
fn message_content_keeps_role_mentions_raw_without_guild_context() {
let mut message = message_with_content(Some("hello <@&10>".to_owned()));
message.guild_id = None;
let state = state_with_role(10, "moderators");
let lines = format_message_content_lines(&message, &state, 200);
assert_eq!(line_texts(&lines), vec!["hello <@&10>"]);
}
#[test]
fn mention_like_display_name_does_not_duplicate_highlight_spans() {
let mut message = message_with_content(Some("hello <@10>".to_owned()));
message.mentions = vec![mention_info(10, "everyone")];
let mut state = DashboardState::new();
state.push_event(AppEvent::Ready {
user: "everyone".to_owned(),
user_id: Some(Id::new(10)),
});
let lines = message_item_lines(
message.author.clone(),
message_author_style(None),
"00:00".to_owned(),
format_message_content_lines(&message, &state, 200),
40,
0,
None,
0,
);
assert_eq!(
line_texts_from_ratatui(&lines),
vec!["oo neo 00:00", " hello @everyone", ""]
);
assert_eq!(lines[1].spans.len(), 3);
assert_eq!(lines[1].spans[2].content.as_ref(), "@everyone");
assert_eq!(
lines[1].spans[2].style.bg,
mention_highlight_style(TextHighlightKind::SelfMention).bg
);
}
#[test]
fn message_content_prefers_cached_member_alias_over_mention_metadata() {
let mut message = message_with_content(Some("hello <@10>".to_owned()));
message.mentions = vec![mention_info(10, "username")];
let state = state_with_member(10, "server alias");
let lines = format_message_content_lines(&message, &state, 200);
assert_eq!(line_texts(&lines), vec!["hello @server alias"]);
}
#[test]
fn message_content_prefers_message_mention_nick_over_cached_member_name() {
let mut message = message_with_content(Some("hello <@10>".to_owned()));
message.mentions = vec![mention_info_with_nick(10, "server alias")];
let state = state_with_member(10, "username");
let lines = format_message_content_lines(&message, &state, 200);
assert_eq!(line_texts(&lines), vec!["hello @server alias"]);
}
#[test]
fn message_content_does_not_split_grapheme_clusters() {
let lines = wrap_text_lines("👨👩👧👦", 7);
assert_eq!(lines, vec!["👨👩👧👦".to_owned()]);
}
#[test]
fn message_content_preserves_blank_lines() {
let message = message_with_content(Some("one\n\nthree".to_owned()));
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(line_texts(&lines), vec!["one", "", "three"]);
}
#[test]
fn video_attachment_is_labeled_as_video() {
let message = message_with_attachment(Some(String::new()), video_attachment());
assert_eq!(
format_message_content(&message, 200),
"[video: clip.mp4] 1920x1080"
);
}
#[test]
fn non_default_message_type_adds_dim_label_line() {
let mut message = message_with_attachment(Some("reply body".to_owned()), image_attachment());
message.message_kind = MessageKind::new(19);
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(
line_texts(&lines),
vec!["↳ Reply", "reply body", "[image: cat.png] 640x480"]
);
assert_eq!(lines[0].style, Style::default().fg(DIM));
}
#[test]
fn user_join_message_type_uses_join_label() {
let mut message = message_with_content(Some(String::new()));
message.message_kind = MessageKind::new(7);
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(line_texts(&lines), vec!["joined the server"]);
assert_eq!(lines[0].style, Style::default().fg(DIM));
}
#[test]
fn boost_message_types_use_discord_like_copy() {
for (kind, label) in [
(8, "neo boosted the server"),
(9, "neo boosted the server to Level 1"),
(10, "neo boosted the server to Level 2"),
(11, "neo boosted the server to Level 3"),
] {
let mut message = message_with_content(Some(String::new()));
message.message_kind = MessageKind::new(kind);
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(line_texts(&lines), vec![label]);
assert_eq!(lines[0].style, Style::default().fg(ACCENT));
}
}
#[test]
fn thread_created_message_uses_cached_thread_details() {
let mut message = message_with_content(Some("release notes".to_owned()));
message.message_kind = MessageKind::new(18);
message.id = snowflake_for_unix_ms(current_unix_millis().saturating_sub(10 * 60 * 1000));
let latest_thread_message_id =
snowflake_for_unix_ms(current_unix_millis().saturating_sub(2 * 60 * 1000));
let mut state = DashboardState::new();
state.push_event(AppEvent::ChannelUpsert(ChannelInfo {
guild_id: Some(Id::new(1)),
channel_id: Id::new(10),
parent_id: Some(message.channel_id),
position: None,
last_message_id: Some(latest_thread_message_id),
name: "release notes".to_owned(),
kind: "thread".to_owned(),
message_count: Some(12),
total_message_sent: Some(14),
thread_archived: Some(false),
thread_locked: Some(false),
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}));
let lines = format_message_content_lines(&message, &state, 200);
let texts = line_texts(&lines);
assert_eq!(texts[0], "neo started release notes thread.");
assert!(texts[1].starts_with(" ╭"));
assert!(texts[2].starts_with(" │ release notes"));
assert!(texts[2].contains("12 messages"));
assert!(texts[3].contains("2 minutes ago"));
assert!(texts[4].starts_with(" ╰"));
assert_eq!(lines[0].style, Style::default().fg(Color::White));
assert_eq!(lines[3].style, Style::default().fg(DIM));
}
#[test]
fn thread_created_message_uses_cached_thread_message_when_last_id_missing() {
let now = current_unix_millis();
let mut message = message_with_content(Some("release notes".to_owned()));
message.message_kind = MessageKind::new(18);
message.id = snowflake_for_unix_ms(now.saturating_sub(10 * 60 * 1000));
let latest_thread_message_id = snowflake_for_unix_ms(now.saturating_sub(2 * 60 * 1000));
let mut state = DashboardState::new();
state.push_event(AppEvent::ChannelUpsert(ChannelInfo {
guild_id: Some(Id::new(1)),
channel_id: Id::new(10),
parent_id: Some(message.channel_id),
position: None,
last_message_id: None,
name: "release notes".to_owned(),
kind: "thread".to_owned(),
message_count: Some(12),
total_message_sent: Some(14),
thread_archived: Some(false),
thread_locked: Some(false),
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}));
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(10),
message_id: latest_thread_message_id,
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("latest reply".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
let lines = format_message_content_lines(&message, &state, 200);
let texts = line_texts(&lines);
assert!(texts[2].contains("13 messages"));
assert!(texts[3].contains("neo latest reply 2 minutes ago"));
}
#[test]
fn thread_created_message_falls_back_to_system_message_time() {
let mut message = message_with_content(Some("release notes".to_owned()));
message.message_kind = MessageKind::new(18);
message.id = snowflake_for_unix_ms(current_unix_millis().saturating_sub(2 * 60 * 1000));
let mut state = DashboardState::new();
state.push_event(AppEvent::ChannelUpsert(ChannelInfo {
guild_id: Some(Id::new(1)),
channel_id: Id::new(10),
parent_id: Some(message.channel_id),
position: None,
last_message_id: None,
name: "release notes".to_owned(),
kind: "thread".to_owned(),
message_count: Some(12),
total_message_sent: Some(14),
thread_archived: Some(false),
thread_locked: Some(false),
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}));
let lines = format_message_content_lines(&message, &state, 200);
let texts = line_texts(&lines);
assert!(texts[2].contains("12 messages"));
assert!(texts[3].contains("2 minutes ago"));
}
#[test]
fn thread_created_message_keeps_archived_and_locked_metadata() {
let mut message = message_with_content(Some("release notes".to_owned()));
message.message_kind = MessageKind::new(18);
message.id = snowflake_for_unix_ms(current_unix_millis().saturating_sub(2 * 60 * 1000));
let mut state = DashboardState::new();
state.push_event(AppEvent::ChannelUpsert(ChannelInfo {
guild_id: Some(Id::new(1)),
channel_id: Id::new(10),
parent_id: Some(message.channel_id),
position: None,
last_message_id: None,
name: "release notes".to_owned(),
kind: "thread".to_owned(),
message_count: Some(12),
total_message_sent: Some(14),
thread_archived: Some(true),
thread_locked: Some(true),
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}));
let lines = format_message_content_lines(&message, &state, 200);
assert!(line_texts(&lines)[3].contains("archived · locked"));
}
#[test]
fn thread_starter_message_uses_referenced_message_card() {
let mut message = message_with_content(Some(String::new()));
message.message_kind = MessageKind::new(21);
message.reply = Some(ReplyInfo {
author: "alice".to_owned(),
content: Some("original topic".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
});
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(
line_texts(&lines),
vec!["Thread starter message", "╭─ alice : original topic"]
);
}
#[test]
fn poll_result_message_uses_result_card() {
let mut message = message_with_content(Some(String::new()));
message.message_kind = MessageKind::new(46);
message.poll = Some(PollInfo {
question: "What should we eat?".to_owned(),
answers: vec![PollAnswerInfo {
answer_id: 1,
text: "Soup".to_owned(),
vote_count: Some(5),
me_voted: false,
}],
allow_multiselect: false,
results_finalized: Some(true),
total_votes: Some(7),
});
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(
line_texts(&lines),
vec![
"Poll results",
"What should we eat?",
"Winner: Soup with 5 votes",
"7 total votes · Final results"
]
);
}
#[test]
fn reply_message_uses_preview_instead_of_type_label() {
let mut message = message_with_attachment(Some("message body".to_owned()), image_attachment());
message.message_kind = MessageKind::new(19);
message.reply = Some(ReplyInfo {
author: "casey".to_owned(),
content: Some("looks good".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
});
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(
line_texts(&lines),
vec![
"╭─ casey : looks good",
"message body",
"[image: cat.png] 640x480"
]
);
assert_eq!(lines[0].style, Style::default().fg(DIM));
}
#[test]
fn reply_preview_renders_known_user_mentions() {
let mut message = message_with_content(Some("asdf".to_owned()));
message.message_kind = MessageKind::new(19);
message.reply = Some(ReplyInfo {
author: "neo".to_owned(),
content: Some("hello <@10>".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
});
let state = state_with_member(10, "alice");
let lines = format_message_content_lines(&message, &state, 200);
assert_eq!(line_texts(&lines), vec!["╭─ neo : hello @alice", "asdf"]);
}
#[test]
fn reply_preview_renders_mentions_from_reply_metadata() {
let mut message = message_with_content(Some("asdf".to_owned()));
message.message_kind = MessageKind::new(19);
message.reply = Some(ReplyInfo {
author: "neo".to_owned(),
content: Some("hello <@10>".to_owned()),
sticker_names: Vec::new(),
mentions: vec![mention_info(10, "alice")],
});
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(line_texts(&lines), vec!["╭─ neo : hello @alice", "asdf"]);
}
#[test]
fn unsupported_message_type_uses_placeholder() {
let mut message = message_with_attachment(Some("body".to_owned()), image_attachment());
message.message_kind = MessageKind::new(255);
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(lines[0].text, "<unsupported message type>");
}
#[test]
fn poll_message_replaces_empty_message_placeholder() {
let mut message = message_with_content(Some(String::new()));
message.poll = Some(poll_info(false));
let width = 40;
let lines = format_message_content_lines(&message, &DashboardState::new(), width);
let texts = line_texts(&lines);
assert_eq!(texts[0], poll_box_border('╭', '╮', width));
assert_eq!(texts[1], poll_test_line("What should we eat?", width));
assert_eq!(texts[2], poll_test_line("Select one answer", width));
assert_eq!(texts[3], poll_test_line(" ◉ 1. Soup 2 votes 66%", width));
assert_eq!(
texts[4],
poll_test_line(" ◯ 2. Noodles 1 votes 33%", width)
);
assert_eq!(
texts[5],
poll_test_line("3 votes · Results may still change", width)
);
assert_eq!(texts[6], poll_box_border('╰', '╯', width));
}
#[test]
fn poll_message_notes_multiselect() {
let mut message = message_with_content(Some(String::new()));
message.poll = Some(poll_info(true));
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert!(lines[2].text.starts_with("│ Select one or more answers"));
assert_eq!(lines[2].style, Style::default().fg(DIM));
}
#[test]
fn poll_message_places_body_inside_box() {
let mut message = message_with_content(Some("Please vote <@10>".to_owned()));
message.poll = Some(poll_info(false));
let state = state_with_member(10, "alice");
let lines = format_message_content_lines(&message, &state, 40);
assert_eq!(lines[1].text, poll_test_line("What should we eat?", 40));
assert_eq!(lines[2].text, poll_test_line("Please vote @alice", 40));
assert!(lines[3].text.starts_with("│ Select one answer"));
}
#[test]
fn poll_message_body_highlights_mentions_inside_box() {
let mut message = message_with_content(Some("<@10> please vote".to_owned()));
message.mentions = vec![mention_info(10, "server alias")];
message.poll = Some(poll_info(false));
let mut state = state_with_member(10, "server alias");
state.push_event(AppEvent::Ready {
user: "server alias".to_owned(),
user_id: Some(Id::new(10)),
});
let lines = format_message_content_lines(&message, &state, 40);
let spans = lines[2].spans();
assert_eq!(spans[0].content.as_ref(), "│ ");
assert_eq!(spans[1].content.as_ref(), "@server alias");
assert_eq!(
spans[1].style.bg,
mention_highlight_style(TextHighlightKind::SelfMention).bg
);
}
#[test]
fn message_content_renders_reaction_chips_below_message() {
let mut message = message_with_content(Some("hello".to_owned()));
message.reactions = vec![ReactionInfo {
emoji: ReactionEmoji::Unicode("👍".to_owned()),
count: 3,
me: true,
}];
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(line_texts(&lines), vec!["hello", "[👍 3]"]);
let spans = lines[1].spans();
assert_eq!(spans[0].content.as_ref(), "[👍 3]");
assert_eq!(spans[0].style, Style::default().fg(Color::Yellow));
}
#[test]
fn lay_out_reaction_chips_unicode_only_emits_no_image_slots() {
let reactions = vec![
ReactionInfo {
emoji: ReactionEmoji::Unicode("👍".to_owned()),
count: 3,
me: true,
},
ReactionInfo {
emoji: ReactionEmoji::Unicode("❤".to_owned()),
count: 1,
me: false,
},
];
let layout = lay_out_reaction_chips(&reactions, 200);
assert_eq!(layout.lines, vec!["[👍 3] [❤ 1]"]);
assert_eq!(layout.self_ranges.len(), 1);
let spans = reaction_line_test_spans(&layout.lines[0], &layout.self_ranges, 0);
assert_eq!(spans[0].content.as_ref(), "[👍 3]");
assert_eq!(spans[0].style, Style::default().fg(Color::Yellow));
assert_eq!(spans[1].style, Style::default().fg(ACCENT));
assert!(layout.slots.is_empty());
}
#[test]
fn lay_out_reaction_chips_custom_emoji_reserves_image_slot() {
let reactions = vec![
ReactionInfo {
emoji: ReactionEmoji::Unicode("👍".to_owned()),
count: 2,
me: false,
},
ReactionInfo {
emoji: ReactionEmoji::Custom {
id: Id::new(42),
name: Some("party".to_owned()),
animated: false,
},
count: 1,
me: true,
},
];
let layout = lay_out_reaction_chips(&reactions, 200);
assert_eq!(layout.lines, vec!["[👍 2] [ 1]"]);
assert_eq!(layout.self_ranges.len(), 1);
assert_eq!(layout.slots.len(), 1);
let slot = &layout.slots[0];
assert_eq!(slot.line, 0);
assert_eq!(slot.col, 9);
assert!(slot.url.contains("42.png"));
}
#[test]
fn lay_out_reaction_chips_wraps_at_chip_boundary() {
let reactions = (0..3)
.map(|i| ReactionInfo {
emoji: ReactionEmoji::Custom {
id: Id::new(100 + i),
name: Some(format!("e{i}")),
animated: false,
},
count: i + 1,
me: false,
})
.collect::<Vec<_>>();
let layout = lay_out_reaction_chips(&reactions, 14);
assert_eq!(layout.lines.len(), 2);
assert_eq!(layout.slots.len(), 3);
assert_eq!(layout.slots[0].line, 0);
assert_eq!(layout.slots[1].line, 0);
assert_eq!(layout.slots[2].line, 1);
assert_eq!(layout.slots[2].col, 1);
}
#[test]
fn message_action_menu_marks_selected_and_disabled_actions() {
let actions = vec![
MessageActionItem {
kind: MessageActionKind::Reply,
label: "Reply".to_owned(),
enabled: true,
},
MessageActionItem {
kind: MessageActionKind::DownloadImage,
label: "Download image".to_owned(),
enabled: false,
},
];
let lines = message_action_menu_lines(&actions, 1);
assert_eq!(
line_texts_from_ratatui(&lines),
vec![
" [r] Reply",
"› [d] Download image (unavailable)",
"Shortcut/Enter select · Esc close"
]
);
}
#[test]
fn message_action_menu_uses_numbered_shortcuts_for_duplicate_preferred_keys() {
let actions = vec![
MessageActionItem {
kind: MessageActionKind::Delete,
label: "Delete message".to_owned(),
enabled: true,
},
MessageActionItem {
kind: MessageActionKind::DownloadImage,
label: "Download image".to_owned(),
enabled: true,
},
];
let lines = message_action_menu_lines(&actions, 0);
assert_eq!(
line_texts_from_ratatui(&lines),
vec![
"› [1] Delete message",
" [2] Download image",
"Shortcut/Enter select · Esc close"
]
);
}
#[test]
fn channel_action_menu_renders_pinned_and_thread_actions() {
let actions = vec![
ChannelActionItem {
kind: ChannelActionKind::LoadPinnedMessages,
label: "Show pinned messages".to_owned(),
enabled: true,
},
ChannelActionItem {
kind: ChannelActionKind::ShowThreads,
label: "Show threads (none)".to_owned(),
enabled: false,
},
];
let lines = channel_action_menu_lines(&actions, 0);
assert_eq!(
line_texts_from_ratatui(&lines),
vec![
"› [p] Show pinned messages",
" [t] Show threads (none)",
"Shortcut/Enter select · Esc close",
]
);
}
#[test]
fn guild_action_menu_renders_placeholder_action() {
let actions = vec![GuildActionItem {
kind: GuildActionKind::NoActionsYet,
label: "No server actions yet".to_owned(),
enabled: false,
}];
let lines = guild_action_menu_lines(&actions, 0);
assert_eq!(
line_texts_from_ratatui(&lines),
vec![
"› No server actions yet",
"Shortcut/Enter select · Esc close"
]
);
}
#[test]
fn member_action_menu_renders_profile_shortcut() {
let actions = vec![MemberActionItem {
kind: MemberActionKind::ShowProfile,
label: "Show profile".to_owned(),
enabled: true,
}];
let lines = member_action_menu_lines(&actions, 0);
assert_eq!(
line_texts_from_ratatui(&lines),
vec!["› [p] Show profile", "Shortcut/Enter select · Esc close"]
);
}
#[test]
fn emoji_reaction_picker_marks_selected_reaction() {
let reactions = vec![
EmojiReactionItem {
emoji: ReactionEmoji::Unicode("👍".to_owned()),
label: "Thumbs up".to_owned(),
},
EmojiReactionItem {
emoji: ReactionEmoji::Custom {
id: Id::new(42),
name: Some("party".to_owned()),
animated: false,
},
label: "Party".to_owned(),
},
];
let lines = emoji_reaction_picker_lines(&reactions, 1, 10, &[]);
assert_eq!(
line_texts_from_ratatui(&lines),
vec![
" [1] 👍 Thumbs up",
"› [2] :party: Party",
"Shortcut/Enter/Space react · Esc close"
]
);
}
#[test]
fn poll_vote_picker_marks_selected_and_checked_answers() {
let answers = vec![
PollVotePickerItem {
answer_id: 1,
label: "Soup".to_owned(),
selected: true,
},
PollVotePickerItem {
answer_id: 2,
label: "Noodles".to_owned(),
selected: false,
},
];
let lines = poll_vote_picker_lines(&answers, 1);
assert_eq!(
line_texts_from_ratatui(&lines),
vec![
" [1] [x] Soup",
"› [2] [ ] Noodles",
"Shortcut/Space toggle · Enter vote · Esc close",
]
);
}
#[test]
fn reaction_users_popup_groups_users_by_reaction() {
let lines = reaction_users_popup_lines(
&[
ReactionUsersInfo {
emoji: ReactionEmoji::Unicode("👍".to_owned()),
users: vec![
ReactionUserInfo {
user_id: Id::new(10),
display_name: "neo".to_owned(),
},
ReactionUserInfo {
user_id: Id::new(11),
display_name: "trinity".to_owned(),
},
],
},
ReactionUsersInfo {
emoji: ReactionEmoji::Custom {
id: Id::new(50),
name: Some("party".to_owned()),
animated: false,
},
users: Vec::new(),
},
],
0,
10,
56,
);
let trimmed = line_texts_from_ratatui(&lines)
.into_iter()
.map(|line| line.trim_end().to_owned())
.collect::<Vec<_>>();
assert_eq!(
trimmed,
vec![
"👍 · 2 users",
" neo",
" trinity",
":party: · 0 users",
" no users found",
"Esc close",
]
);
}
#[test]
fn reaction_users_popup_scrolls_long_lists() {
let reactions = vec![ReactionUsersInfo {
emoji: ReactionEmoji::Unicode("👍".to_owned()),
users: (1..=6)
.map(|id| ReactionUserInfo {
user_id: Id::new(id),
display_name: format!("user-{id}"),
})
.collect(),
}];
let lines = reaction_users_popup_lines(&reactions, 3, 3, 56);
let trimmed = line_texts_from_ratatui(&lines)
.into_iter()
.map(|line| line.trim_end().to_owned())
.collect::<Vec<_>>();
assert_eq!(
trimmed,
vec![
" user-3",
" user-4",
" user-5",
"j/k scroll · more above/below · Esc close",
]
);
}
#[test]
fn reaction_users_popup_buffer_renders_without_wrap_artifacts() {
let mut state = DashboardState::new();
state.push_event(AppEvent::ReactionUsersLoaded {
channel_id: Id::new(2),
message_id: Id::new(1),
reactions: vec![
ReactionUsersInfo {
emoji: ReactionEmoji::Unicode("👍".to_owned()),
users: vec![
ReactionUserInfo {
user_id: Id::new(1),
display_name: "갱생케가".to_owned(),
},
ReactionUserInfo {
user_id: Id::new(2),
display_name: "하나비".to_owned(),
},
ReactionUserInfo {
user_id: Id::new(3),
display_name: "슬기인뎅".to_owned(),
},
ReactionUserInfo {
user_id: Id::new(4),
display_name: "won".to_owned(),
},
],
},
ReactionUsersInfo {
emoji: ReactionEmoji::Unicode("❤️".to_owned()),
users: vec![ReactionUserInfo {
user_id: Id::new(5),
display_name: "파닥파닥( 40%..? )".to_owned(),
}],
},
],
});
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).expect("test terminal should build");
terminal
.draw(|frame| {
sync_view_heights(frame.area(), &mut state);
super::render(frame, &state, Vec::new(), Vec::new(), Vec::new(), None);
})
.expect("first draw");
for _ in 0..6 {
state.scroll_reaction_users_popup_down();
}
terminal
.draw(|frame| {
sync_view_heights(frame.area(), &mut state);
super::render(frame, &state, Vec::new(), Vec::new(), Vec::new(), None);
})
.expect("second draw");
for _ in 0..6 {
state.scroll_reaction_users_popup_up();
}
terminal
.draw(|frame| {
sync_view_heights(frame.area(), &mut state);
super::render(frame, &state, Vec::new(), Vec::new(), Vec::new(), None);
})
.expect("third draw");
let buffer = terminal.backend().buffer();
let dump = (0..buffer.area.height)
.map(|row| {
(0..buffer.area.width)
.map(|col| buffer[(col, row)].symbol().to_owned())
.collect::<String>()
})
.collect::<Vec<_>>();
let trailing_matches = dump.iter().filter(|line| line.contains("? )")).count();
assert!(
trailing_matches <= 1,
"popup buffer contained '? )' fragment on {trailing_matches} rows; expected at most 1.\nDump:\n{}",
dump.join("\n")
);
}
#[test]
fn reaction_users_popup_buffer_stays_clean_in_narrow_terminal() {
let mut state = DashboardState::new();
state.push_event(AppEvent::ReactionUsersLoaded {
channel_id: Id::new(2),
message_id: Id::new(1),
reactions: vec![ReactionUsersInfo {
emoji: ReactionEmoji::Unicode("👍".to_owned()),
users: vec![
ReactionUserInfo {
user_id: Id::new(1),
display_name: "won".to_owned(),
},
ReactionUserInfo {
user_id: Id::new(2),
display_name: "파닥파닥( 40%..? )".to_owned(),
},
],
}],
});
let dump = render_dashboard_dump(40, 25, &mut state);
let trailing_matches = dump.iter().filter(|line| line.contains("? )")).count();
assert!(
trailing_matches <= 1,
"popup buffer contained '? )' fragment on {trailing_matches} rows; expected at most 1.\nDump:\n{}",
dump.join("\n")
);
}
#[test]
fn reaction_users_popup_truncates_long_lines_to_fit_width() {
let reactions = vec![ReactionUsersInfo {
emoji: ReactionEmoji::Unicode("❤️".to_owned()),
users: vec![
ReactionUserInfo {
user_id: Id::new(1),
display_name: "won".to_owned(),
},
ReactionUserInfo {
user_id: Id::new(2),
display_name: "파닥파닥( 40%..? )".to_owned(),
},
],
}];
let lines = reaction_users_popup_lines(&reactions, 0, 4, 12);
for line in &lines {
assert!(
line.width() <= 12,
"line {:?} exceeded inner width",
line_texts_from_ratatui(std::slice::from_ref(line))
);
}
}
#[test]
fn reaction_users_popup_reserves_footer_space_in_short_areas() {
assert_eq!(reaction_users_visible_line_count(Rect::new(0, 0, 20, 5)), 0);
assert_eq!(reaction_users_visible_line_count(Rect::new(0, 0, 20, 6)), 1);
assert_eq!(
reaction_users_visible_line_count(Rect::new(0, 0, 20, 40)),
14
);
}
#[test]
fn emoji_reaction_picker_reserves_space_for_loaded_custom_image() {
let reactions = vec![EmojiReactionItem {
emoji: ReactionEmoji::Custom {
id: Id::new(42),
name: Some("party".to_owned()),
animated: false,
},
label: "Party".to_owned(),
}];
let lines = emoji_reaction_picker_lines(
&reactions,
0,
10,
&["https://cdn.discordapp.com/emojis/42.png".to_owned()],
);
assert_eq!(
line_texts_from_ratatui(&lines),
vec!["› [1] Party", "Shortcut/Enter/Space react · Esc close"]
);
}
#[test]
fn emoji_reaction_picker_windows_long_lists_around_selection() {
let reactions = (0..15)
.map(|index| EmojiReactionItem {
emoji: ReactionEmoji::Custom {
id: Id::new(100 + index),
name: Some(format!("emoji_{index}")),
animated: false,
},
label: format!("Emoji {index}"),
})
.collect::<Vec<_>>();
let lines = emoji_reaction_picker_lines(&reactions, 12, 5, &[]);
assert_eq!(
line_texts_from_ratatui(&lines),
vec![
" [9] :emoji_8: Emoji 8",
" [0] :emoji_9: Emoji 9",
" :emoji_10: Emoji 10",
" :emoji_11: Emoji 11",
"› :emoji_12: Emoji 12",
"Shortcut/Enter/Space react · Esc close"
]
);
}
#[test]
fn footer_hint_does_not_advertise_horizontal_scroll_for_messages() {
let mut state = state_with_message();
state.focus_pane(FocusPane::Messages);
assert!(!footer_hint(&state).contains("H/L scroll name"));
}
#[test]
fn footer_hint_switches_for_emoji_picker() {
let mut state = state_with_message();
state.open_selected_message_actions();
state.move_message_action_down();
state.activate_selected_message_action();
assert_eq!(
footer_hint(&state),
"j/k choose emoji | enter/space react | esc close"
);
}
#[test]
fn footer_hint_switches_for_image_viewer() {
let mut state = state_with_image_message();
state.open_selected_message_actions();
state.move_message_action_down();
state.activate_selected_message_action();
assert_eq!(
footer_hint(&state),
"h/← previous image | l/→ next image | enter/space actions | esc close"
);
}
#[test]
fn footer_hint_switches_for_debug_log_popup() {
let mut state = DashboardState::new();
state.toggle_debug_log_popup();
assert_eq!(footer_hint(&state), "`/esc close debug logs");
}
#[test]
fn debug_log_popup_shows_recent_errors() {
let lines = debug_log_popup_lines(
vec![
"1 [ERROR] first: old".to_owned(),
"2 [ERROR] second: recent".to_owned(),
],
ChannelVisibilityStats {
visible: 12,
hidden: 3,
},
1,
80,
);
assert_eq!(
line_texts_from_ratatui(&lines),
vec![
"Channels: 12 visible · 3 hidden by permissions",
"",
"2 [ERROR] second: recent",
"",
"Showing current-process ERROR logs only · ` / Esc close"
]
);
}
#[test]
fn debug_log_popup_has_empty_state() {
let lines = debug_log_popup_lines(Vec::new(), ChannelVisibilityStats::default(), 5, 80);
assert_eq!(
line_texts_from_ratatui(&lines),
vec![
"Channels: 0 visible · 0 hidden by permissions",
"",
"No errors recorded in this process.",
"",
"Showing current-process ERROR logs only · ` / Esc close"
]
);
}
#[test]
fn debug_log_popup_wraps_long_detail_lines() {
let lines = debug_log_popup_lines(
vec!["42 [ERROR] history: load message history failed: Discord HTTP request failed; detail=Discord returned HTTP 403; api_error=Missing Access; response_body_bytes=99".to_owned()],
ChannelVisibilityStats::default(),
4,
44,
);
let texts = line_texts_from_ratatui(&lines);
let joined = texts.join("");
assert!(
joined.contains("detail=Discord returned HTTP 403"),
"expected wrapped debug popup line to preserve HTTP detail: {texts:?}"
);
}
#[test]
fn forwarded_snapshot_replaces_empty_message_placeholder() {
let message =
message_with_forwarded_snapshot(forwarded_snapshot(Some("forwarded text"), Vec::new()));
assert_eq!(
format_message_content(&message, 200),
"↱ Forwarded │ forwarded text"
);
}
#[test]
fn forwarded_snapshot_attachment_replaces_empty_message_placeholder() {
let message =
message_with_forwarded_snapshot(forwarded_snapshot(Some(""), vec![image_attachment()]));
assert_eq!(
format_message_content(&message, 200),
"↱ Forwarded │ [image: cat.png] 640x480"
);
}
#[test]
fn forwarded_snapshot_content_appends_attachment_summary() {
let message = message_with_forwarded_snapshot(forwarded_snapshot(
Some("hello"),
vec![image_attachment()],
));
assert_eq!(
format_message_content(&message, 200),
"↱ Forwarded │ hello │ [image: cat.png] 640x480"
);
}
#[test]
fn forwarded_snapshot_content_wraps_after_prefix() {
let message =
message_with_forwarded_snapshot(forwarded_snapshot(Some("abcdefghijkl"), Vec::new()));
let lines = format_message_content_lines(&message, &DashboardState::new(), 7);
assert_eq!(
line_texts(&lines),
vec!["↱ Forwarded", "│ abcde", "│ fghij", "│ kl"]
);
}
#[test]
fn forwarded_snapshot_content_renders_known_user_mentions() {
let message =
message_with_forwarded_snapshot(forwarded_snapshot(Some("hello <@10>"), Vec::new()));
let state = state_with_member(10, "alice");
let lines = format_message_content_lines(&message, &state, 200);
assert_eq!(line_texts(&lines), vec!["↱ Forwarded", "│ hello <@10>"]);
}
#[test]
fn forwarded_snapshot_content_uses_source_channel_guild_for_mentions() {
let mut snapshot = forwarded_snapshot(Some("hello <@10>"), Vec::new());
snapshot.source_channel_id = Some(Id::new(9));
let message = message_with_forwarded_snapshot(snapshot);
let mut state = state_with_member(10, "outer");
state.push_event(AppEvent::ChannelUpsert(ChannelInfo {
guild_id: Some(Id::new(2)),
channel_id: Id::new(9),
parent_id: None,
position: None,
last_message_id: None,
name: "source".to_owned(),
kind: "GuildText".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}));
state.push_event(AppEvent::GuildMemberUpsert {
guild_id: Id::new(2),
member: member_info(10, "source"),
});
let lines = format_message_content_lines(&message, &state, 200);
assert_eq!(
line_texts(&lines),
vec!["↱ Forwarded", "│ hello @source", "│ #source"]
);
}
#[test]
fn forwarded_snapshot_content_renders_mentions_from_snapshot_metadata() {
let mut snapshot = forwarded_snapshot(Some("hello <@10>"), Vec::new());
snapshot.mentions = vec![mention_info(10, "alice")];
let message = message_with_forwarded_snapshot(snapshot);
let lines = format_message_content_lines(&message, &DashboardState::new(), 200);
assert_eq!(line_texts(&lines), vec!["↱ Forwarded", "│ hello @alice"]);
}
#[test]
fn forwarded_snapshot_content_wraps_wide_characters_after_prefix() {
let message =
message_with_forwarded_snapshot(forwarded_snapshot(Some("漢字仮名交じ"), Vec::new()));
let lines = format_message_content_lines(&message, &DashboardState::new(), 12);
assert_eq!(
line_texts(&lines),
vec!["↱ Forwarded", "│ 漢字仮名交", "│ じ"]
);
}
#[test]
fn forwarded_snapshot_lines_include_channel_and_time() {
let mut state = DashboardState::new();
state.push_event(crate::discord::AppEvent::ChannelUpsert(
crate::discord::ChannelInfo {
guild_id: Some(Id::new(1)),
channel_id: Id::new(9),
parent_id: None,
position: None,
last_message_id: None,
name: "general".to_owned(),
kind: "GuildText".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
},
));
let mut snapshot = forwarded_snapshot(Some("hello"), Vec::new());
snapshot.source_channel_id = Some(Id::new(9));
snapshot.timestamp = Some("2026-04-30T12:34:56.000000+00:00".to_owned());
let message = message_with_forwarded_snapshot(snapshot);
let lines = format_message_content_lines(&message, &state, 200);
assert_eq!(
line_texts(&lines),
vec!["↱ Forwarded", "│ hello", "│ #general · 12:34"]
);
assert_eq!(lines[2].style, Style::default().fg(DIM));
}
#[test]
fn forwarded_snapshot_renders_discord_embed_preview() {
let mut snapshot = forwarded_snapshot(
Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
Vec::new(),
);
snapshot.embeds = vec![youtube_embed()];
let message = message_with_forwarded_snapshot(snapshot);
let lines = format_message_content_lines(&message, &DashboardState::new(), 80);
assert_eq!(
line_texts(&lines),
vec![
"↱ Forwarded",
"│ https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"│ ▎ YouTube",
"│ ▎ Example Video",
]
);
let url_spans = lines[2].spans();
assert_eq!(url_spans[0].content.as_ref(), "│ ");
assert!(
!url_spans[0]
.style
.add_modifier
.contains(Modifier::UNDERLINED)
);
assert_eq!(url_spans[1].content.as_ref(), " ▎ ");
assert_eq!(url_spans[1].style.fg, Some(Color::Rgb(255, 0, 0)));
assert!(
!url_spans[1]
.style
.add_modifier
.contains(Modifier::UNDERLINED)
);
}
#[test]
fn image_preview_rows_are_part_of_the_message_item() {
let lines = message_item_lines(
"neo".to_owned(),
message_author_style(None),
"00:00".to_owned(),
vec![MessageContentLine::plain("look".to_owned())],
14,
3,
None,
0,
);
assert_eq!(lines.len(), 6);
}
#[test]
fn message_viewport_lines_reserve_rows_for_multiple_attachment_summaries() {
let mut message = message_with_attachment(Some("look".to_owned()), image_attachment());
message.attachments = image_attachments(2);
let messages = [&message];
let lines = message_viewport_lines(
&messages,
None,
&DashboardState::new(),
super::message_viewport_layout(200, 80, 80, 16, 3),
&[],
);
assert_eq!(lines.len(), 8);
}
#[test]
fn message_viewport_lines_put_reactions_below_image_preview_rows() {
let mut message = message_with_attachment(Some("look".to_owned()), image_attachment());
message.reactions = vec![ReactionInfo {
emoji: ReactionEmoji::Unicode("👍".to_owned()),
count: 3,
me: true,
}];
let messages = [&message];
let lines = message_viewport_lines(
&messages,
None,
&DashboardState::new(),
super::message_viewport_layout(200, 80, 80, 16, 3),
&[],
);
assert_eq!(lines.len(), 8);
assert_eq!(line_texts_from_ratatui(&lines)[6], " [👍 3]");
}
#[test]
fn message_viewport_lines_reserve_rows_for_three_image_album() {
let mut message = message_with_attachment(Some("look".to_owned()), image_attachment());
message.attachments = image_attachments(3);
let messages = [&message];
let lines = message_viewport_lines(
&messages,
None,
&DashboardState::new(),
super::message_viewport_layout(200, 80, 80, 16, 3),
&[],
);
assert_eq!(lines.len(), 9);
}
#[test]
fn message_viewport_lines_keep_four_image_album_bounded() {
let mut message = message_with_attachment(Some("look".to_owned()), image_attachment());
message.attachments = image_attachments(4);
let messages = [&message];
let lines = message_viewport_lines(
&messages,
None,
&DashboardState::new(),
super::message_viewport_layout(200, 80, 80, 16, 3),
&[],
);
assert_eq!(lines.len(), 10);
}
#[test]
fn message_viewport_lines_keep_five_image_album_bounded_to_four_tiles() {
let mut message = message_with_attachment(Some("look".to_owned()), image_attachment());
message.attachments = image_attachments(5);
let messages = [&message];
let lines = message_viewport_lines(
&messages,
None,
&DashboardState::new(),
super::message_viewport_layout(200, 80, 80, 16, 3),
&[],
);
assert_eq!(lines.len(), 12);
assert!(line_texts_from_ratatui(&lines).contains(&" +1 more images".to_owned()));
}
#[test]
fn embed_image_preview_rows_continue_embed_gutter() {
let lines = message_item_lines(
"neo".to_owned(),
message_author_style(None),
"00:00".to_owned(),
vec![MessageContentLine::plain("look".to_owned())],
14,
2,
Some(0xff0000),
0,
);
assert_eq!(line_texts_from_ratatui(&lines)[2], " ▎ ");
assert_eq!(lines[2].spans[1].style.fg, Some(Color::Rgb(255, 0, 0)));
}
#[test]
fn text_only_message_item_has_header_and_content_rows() {
let lines = message_item_lines(
"neo".to_owned(),
message_author_style(None),
"00:00".to_owned(),
vec![MessageContentLine::plain("look".to_owned())],
14,
0,
None,
0,
);
assert_eq!(
line_texts_from_ratatui(&lines),
vec!["oo neo 00:00", " look", ""]
);
}
#[test]
fn message_item_lines_can_start_after_line_offset() {
let lines = message_item_lines(
"neo".to_owned(),
message_author_style(None),
"00:00".to_owned(),
vec![
MessageContentLine::plain("first".to_owned()),
MessageContentLine::plain("second".to_owned()),
MessageContentLine::plain("third".to_owned()),
],
14,
0,
None,
2,
);
assert_eq!(
line_texts_from_ratatui(&lines),
vec![" second", " third", ""]
);
}
#[test]
fn message_item_header_uses_display_width_for_wide_author() {
let ascii = message_item_lines(
"bruised8".to_owned(),
message_author_style(None),
"00:00".to_owned(),
vec![MessageContentLine::plain("plain text".to_owned())],
14,
0,
None,
0,
);
let wide = message_item_lines(
"漢字名".to_owned(),
message_author_style(None),
"00:00".to_owned(),
vec![MessageContentLine::plain("plain text".to_owned())],
14,
0,
None,
0,
);
assert_eq!(line_texts_from_ratatui(&ascii)[0], "oo bruised8 00:00");
assert_eq!(line_texts_from_ratatui(&wide)[0], "oo 漢字名 00:00");
}
#[test]
fn shared_truncation_uses_display_width_for_wide_characters() {
let author = truncate_display_width("漢字仮名交じり", 8);
assert_eq!(author, "漢字...");
assert_eq!(author.width(), 7);
}
#[test]
fn member_label_truncates_by_display_width() {
let member = GuildMemberState {
user_id: Id::new(10),
display_name: "漢字仮名交じり文章".to_owned(),
username: None,
is_bot: false,
avatar_url: None,
role_ids: Vec::new(),
status: PresenceStatus::Online,
};
let label = member_display_label(MemberEntry::Guild(&member), 0, 12);
assert_eq!(label, "漢字仮名...");
assert!(label.width() <= 12);
}
#[test]
fn server_label_truncates_by_display_width() {
let label = truncate_display_width("漢字仮名交じりサーバー", 12);
assert_eq!(label, "漢字仮名...");
assert!(label.width() <= 12);
}
#[test]
fn horizontal_truncation_skips_display_width_offset() {
let label = truncate_display_width_from("abcdef", 2, 4);
assert_eq!(label, "cdef");
}
#[test]
fn horizontal_truncation_respects_wide_character_boundaries() {
let label = truncate_display_width_from("가나다abc", 2, 6);
assert_eq!(label, "나...");
assert!(label.width() <= 6);
}
#[test]
fn member_label_uses_horizontal_scroll_offset() {
let member = GuildMemberState {
user_id: Id::new(10),
display_name: "long-member-name".to_owned(),
username: None,
is_bot: false,
avatar_url: None,
role_ids: Vec::new(),
status: PresenceStatus::Online,
};
let label = member_display_label(MemberEntry::Guild(&member), 5, 8);
assert_eq!(label, "membe...");
}
#[test]
fn channel_label_truncates_by_display_width_after_prefixes() {
let branch_prefix = "├ ";
let channel_prefix = "# ";
let max_width = 14usize;
let label_width = max_width
.saturating_sub(branch_prefix.width())
.saturating_sub(channel_prefix.width());
let label = truncate_display_width("漢字仮名交じり", label_width);
assert_eq!(label, "漢字仮...");
assert!(branch_prefix.width() + channel_prefix.width() + label.width() <= max_width);
}
#[test]
fn offline_member_name_keeps_role_color_and_dims() {
let member = GuildMemberState {
user_id: Id::new(10),
display_name: "neo".to_owned(),
username: None,
is_bot: false,
avatar_url: None,
role_ids: Vec::new(),
status: PresenceStatus::Offline,
};
let style = member_name_style(MemberEntry::Guild(&member), Some(0x3366CC), false);
assert_eq!(style.fg, Some(Color::Rgb(0x33, 0x66, 0xCC)));
assert!(style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn no_role_member_name_stays_white_for_online_like_statuses() {
for status in [
PresenceStatus::Online,
PresenceStatus::Idle,
PresenceStatus::DoNotDisturb,
] {
let member = GuildMemberState {
user_id: Id::new(10),
display_name: "neo".to_owned(),
username: None,
is_bot: false,
avatar_url: None,
role_ids: Vec::new(),
status,
};
let style = member_name_style(MemberEntry::Guild(&member), None, false);
assert_eq!(style.fg, Some(Color::White));
assert!(!style.add_modifier.contains(Modifier::DIM));
}
}
#[test]
fn no_role_offline_member_name_is_white_and_dimmed() {
let member = GuildMemberState {
user_id: Id::new(10),
display_name: "neo".to_owned(),
username: None,
is_bot: false,
avatar_url: None,
role_ids: Vec::new(),
status: PresenceStatus::Offline,
};
let style = member_name_style(MemberEntry::Guild(&member), None, false);
assert_eq!(style.fg, Some(Color::White));
assert!(style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn selected_bot_member_name_preserves_role_color_and_selection_style() {
let member = GuildMemberState {
user_id: Id::new(10),
display_name: "bot".to_owned(),
username: None,
is_bot: true,
avatar_url: None,
role_ids: Vec::new(),
status: PresenceStatus::Online,
};
let style = member_name_style(MemberEntry::Guild(&member), Some(0x3366CC), true);
assert_eq!(style.fg, Some(Color::Rgb(0x33, 0x66, 0xCC)));
assert_eq!(style.bg, Some(Color::Rgb(24, 54, 65)));
assert!(style.add_modifier.contains(Modifier::BOLD));
assert!(style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn message_sent_time_formats_with_timezone_offset() {
let kst = chrono::FixedOffset::east_opt(9 * 60 * 60).expect("KST offset should be valid");
assert_eq!(
format_unix_millis_with_offset(DISCORD_EPOCH_MILLIS, kst),
Some("09:00".to_owned())
);
}
fn snowflake_for_unix_ms(unix_ms: u64) -> Id<MessageMarker> {
let raw = (unix_ms - DISCORD_EPOCH_MILLIS) << SNOWFLAKE_TIMESTAMP_SHIFT;
Id::new(raw.max(1))
}
fn current_unix_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_millis()
.try_into()
.expect("current unix millis should fit in u64")
}
#[test]
fn date_separator_appears_when_local_date_changes() {
let day_one = snowflake_for_unix_ms(1_743_465_600_000); let day_two = snowflake_for_unix_ms(1_743_465_600_000 + 24 * 60 * 60 * 1000);
assert!(message_starts_new_day(day_one, None));
assert!(!message_starts_new_day(day_one, Some(day_one)));
assert!(message_starts_new_day(day_two, Some(day_one)));
}
#[test]
fn date_separator_line_centers_label_within_full_width() {
let id = snowflake_for_unix_ms(1_743_508_800_000); let line = date_separator_line(id, 30);
let text = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(text.width(), 30);
assert!(text.contains(' '));
assert!(text.starts_with('─'));
assert!(text.ends_with('─'));
let label_chars = text.matches(char::is_numeric).count();
assert_eq!(label_chars, 8);
}
#[test]
fn new_messages_notice_line_centers_count_within_full_width() {
let line = new_messages_notice_line(3, 30);
let text = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(text.width(), 30);
assert!(text.contains("↓ 3 new messages"));
assert!(!text.contains('#'));
assert!(!text.contains('│'));
assert_eq!(line.spans[0].style.fg, Some(ACCENT));
assert_eq!(line.spans[0].style.bg, None);
assert!(line.spans[0].style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn render_messages_shows_new_messages_notice_at_bottom_of_message_pane() {
let mut state = state_with_message();
push_message(&mut state, 2, "older second");
push_message(&mut state, 3, "older third");
push_message(&mut state, 4, "older fourth");
state.focus_pane(FocusPane::Messages);
state.jump_top();
push_message(&mut state, 5, "first unread");
push_message(&mut state, 6, "second unread");
let dump = render_dashboard_dump(100, 24, &mut state);
assert_notice_floats_at_list_bottom_above_composer(&dump, "2 new messages");
}
#[test]
fn render_messages_shows_new_messages_notice_after_viewport_scrolls_up() {
let mut state = state_with_message();
for id in 2..=10 {
push_message(&mut state, id, &format!("older {id}"));
}
state.focus_pane(FocusPane::Messages);
state.set_message_view_height(5);
state.clamp_message_viewport_for_image_previews(80, 16, 3);
state.scroll_message_viewport_up();
state.scroll_message_viewport_up();
push_message(&mut state, 11, "new after viewport scroll");
let dump = render_dashboard_dump(100, 24, &mut state);
assert_notice_floats_at_list_bottom_above_composer(&dump, "1 new messages");
}
#[test]
fn new_messages_notice_does_not_reserve_message_list_height() {
let area = Rect::new(0, 0, 100, 24);
let mut state = state_with_message();
for id in 2..=10 {
push_message(&mut state, id, &format!("older {id}"));
}
state.focus_pane(FocusPane::Messages);
sync_view_heights(area, &mut state);
state.clamp_message_viewport_for_image_previews(80, 16, 3);
let height_without_notice = state.message_view_height();
state.scroll_message_viewport_up();
push_message(&mut state, 11, "first unread");
sync_view_heights(area, &mut state);
assert_eq!(state.new_messages_count(), 1);
assert_eq!(state.message_view_height(), height_without_notice);
}
fn assert_notice_floats_at_list_bottom_above_composer(dump: &[String], label: &str) {
let notice_row = dump
.iter()
.position(|line| line.contains(label))
.expect("new messages notice should render");
let composer_row = dump
.iter()
.position(|line| line.contains("Message Input"))
.expect("composer should render");
assert_eq!(
notice_row.saturating_add(1),
composer_row,
"new messages notice should float on the message-list bottom above composer:\n{}",
dump.join("\n")
);
}
#[test]
fn message_viewport_lines_keep_rows_from_tall_following_message() {
let mut selected = message_with_attachment(Some("selected".to_owned()), image_attachment());
selected.attachments.clear();
let mut tall_following = message_with_attachment(
Some("abcdefghijklmnopqrstuvwx".to_owned()),
image_attachment(),
);
tall_following.attachments.clear();
let messages = [&selected, &tall_following];
let visible_rows = message_viewport_lines(
&messages,
Some(0),
&DashboardState::new(),
super::message_viewport_layout(5, 80, 80, 16, 3),
&[],
)
.into_iter()
.take(5)
.collect::<Vec<_>>();
let visible_text = line_texts_from_ratatui(&visible_rows);
let sent_time = format_message_sent_time(Id::new(1));
assert!(visible_text[0].starts_with("╭─oo "));
assert!(visible_text[0].contains(&sent_time));
assert!(visible_text[1].contains("selected"));
assert!(visible_text[2].starts_with("╰"));
assert!(visible_text[3].starts_with("oo "));
assert!(visible_text[3].ends_with(&sent_time));
assert!(visible_text[4].ends_with("abcdefgh"));
}
#[test]
fn selected_message_uses_border_without_background() {
let message = message_with_content(Some("abcdefghijkl".to_owned()));
let messages = [&message];
let lines = message_viewport_lines(
&messages,
Some(0),
&DashboardState::new(),
super::message_viewport_layout(5, 80, 80, 16, 3),
&[],
);
let sent_time = format_message_sent_time(Id::new(1));
let texts = line_texts_from_ratatui(&lines);
assert_eq!(texts.len(), 3);
assert!(texts[0].starts_with(&format!("╭─oo neo {sent_time}")));
assert!(texts[0].ends_with("╮"));
assert!(texts[0].contains(" ─"));
assert!(texts[1].starts_with("│ abcdefghijkl"));
assert!(texts[1].ends_with(" │"));
assert!(texts[2].starts_with("╰"));
assert!(texts[2].ends_with("╯"));
assert!(texts.iter().all(|text| text.width() == 80));
assert_eq!(lines[0].spans[0].style.fg, Some(SELECTED_MESSAGE_BORDER));
assert_eq!(lines[1].spans[0].style.fg, Some(SELECTED_MESSAGE_BORDER));
assert!(
lines[1].spans[0]
.style
.add_modifier
.contains(Modifier::BOLD)
);
assert!(
lines
.iter()
.flat_map(|line| line.spans.iter())
.all(|span| span.style.bg.is_none())
);
}
#[test]
fn selected_message_right_border_can_reserve_scrollbar_column() {
let message = message_with_content(Some("a".repeat(73)));
let messages = [&message];
let texts = line_texts_from_ratatui(&message_viewport_lines(
&messages,
Some(0),
&DashboardState::new(),
super::message_viewport_layout(40, 80, selected_message_card_width(80, true), 16, 3),
&[],
));
assert!(texts[0].starts_with("╭─oo"));
assert!(texts[0].ends_with("╮"));
assert!(texts[1].ends_with("│"));
assert!(texts[2].ends_with("│"));
assert!(texts[3].ends_with("╯"));
assert!(texts.iter().all(|text| text.width() == 79));
}
#[test]
fn selected_message_avatar_moves_inside_border() {
assert_eq!(selected_avatar_x_offset(Some(0), 0), 2);
assert_eq!(selected_avatar_x_offset(Some(1), 0), 0);
}
#[test]
fn message_preview_rows_do_not_shrink_message_viewport() {
let mut state = DashboardState::new();
sync_view_heights(Rect::new(0, 0, 100, 20), &mut state);
assert_eq!(state.message_view_height(), 13);
}
#[test]
fn inline_image_preview_slot_follows_image_message_content() {
let area = Rect::new(10, 5, 80, 12);
assert_eq!(
inline_image_preview_area(area, 2, 0, 77, 4, None),
Some(Rect::new(13, 8, 77, 4))
);
}
#[test]
fn embed_image_preview_area_leaves_room_for_gutter() {
let area = Rect::new(10, 5, 80, 12);
assert_eq!(
inline_image_preview_area(area, 2, 0, 77, 4, Some(0xff0000)),
Some(Rect::new(17, 8, 73, 4))
);
}
#[test]
fn selected_inline_image_preview_area_follows_bordered_message_content() {
let area = Rect::new(10, 5, 80, 12);
let selected_offset = selected_message_content_x_offset(true);
assert_eq!(
inline_image_preview_area(area, 2, selected_offset, 77, 4, None),
Some(Rect::new(15, 8, 75, 4))
);
}
#[test]
fn later_image_preview_slot_accounts_for_prior_preview_rows() {
let area = Rect::new(10, 5, 80, 18);
let messages = [
message_with_attachment(Some("one".to_owned()), image_attachment()),
message_with_attachment(Some("two".to_owned()), image_attachment()),
message_with_attachment(Some("three".to_owned()), image_attachment()),
];
let messages = messages.iter().collect::<Vec<_>>();
let state = DashboardState::new();
let row = inline_image_preview_row(&messages, &state, 2, 200, 0, 4);
assert_eq!(row, 14);
assert_eq!(
inline_image_preview_area(area, row, 0, 77, 4, None),
Some(Rect::new(13, 20, 77, 3))
);
}
#[test]
fn second_inline_preview_slot_uses_album_column_offset() {
let area = Rect::new(10, 5, 80, 18);
let mut message = message_with_attachment(Some("one".to_owned()), image_attachment());
let mut second = image_attachment();
second.id = Id::new(4);
second.filename = "dog.png".to_owned();
second.url = "https://cdn.discordapp.com/dog.png".to_owned();
second.proxy_url = "https://media.discordapp.net/dog.png".to_owned();
message.attachments.push(second);
let messages = [&message];
let state = DashboardState::new();
let row = inline_image_preview_row(&messages, &state, 0, 200, 0, 0);
assert_eq!(row, 3);
assert_eq!(
inline_image_preview_area(area, row, 8, 8, 3, None),
Some(Rect::new(21, 9, 8, 3))
);
}
#[test]
fn inline_image_preview_row_ignores_reaction_footer_for_current_message() {
let mut message = message_with_attachment(Some("one".to_owned()), image_attachment());
message.reactions = vec![ReactionInfo {
emoji: ReactionEmoji::Unicode("👍".to_owned()),
count: 3,
me: true,
}];
let messages = [&message];
let state = DashboardState::new();
assert_eq!(inline_image_preview_row(&messages, &state, 0, 200, 0, 0), 2);
}
#[test]
fn forwarded_card_rows_push_inline_preview_slot_down() {
let mut snapshot = forwarded_snapshot(Some("hello"), vec![image_attachment()]);
snapshot.source_channel_id = Some(Id::new(9));
snapshot.timestamp = Some("2026-04-30T12:34:56.000000+00:00".to_owned());
let message = message_with_forwarded_snapshot(snapshot);
let messages = [&message];
let state = DashboardState::new();
assert_eq!(inline_image_preview_row(&messages, &state, 0, 200, 0, 0), 4);
}
#[test]
fn inline_image_preview_area_hides_preview_at_list_bottom() {
let area = Rect::new(10, 5, 80, 6);
assert_eq!(
inline_image_preview_area(area, 3, 0, 77, 4, None),
Some(Rect::new(13, 9, 77, 2))
);
}
#[test]
fn inline_image_preview_area_clips_preview_at_list_top() {
let area = Rect::new(10, 5, 80, 6);
assert_eq!(
inline_image_preview_area(area, -2, 0, 77, 4, None),
Some(Rect::new(13, 5, 77, 3))
);
}
#[test]
fn inline_image_preview_area_returns_none_when_preview_starts_below_list() {
let area = Rect::new(10, 5, 80, 6);
assert_eq!(inline_image_preview_area(area, 5, 0, 77, 4, None), None);
}
#[test]
fn inline_image_preview_area_returns_none_when_preview_ends_above_list() {
let area = Rect::new(10, 5, 80, 6);
assert_eq!(inline_image_preview_area(area, -5, 0, 77, 4, None), None);
}
#[test]
fn inline_album_overflow_marker_is_visible() {
let mut state = state_with_message();
let dump = render_dashboard_dump_with_previews(
120,
20,
&mut state,
vec![ImagePreview {
viewer: false,
message_index: 0,
preview_x_offset_columns: 0,
preview_y_offset_rows: 0,
preview_width: 16,
preview_height: 3,
preview_overflow_count: 2,
accent_color: None,
state: ImagePreviewState::Loading {
filename: "image-4.png".to_owned(),
},
}],
);
assert!(
dump.iter().any(|line| line.contains("+2")),
"dashboard dump did not contain overflow overlay marker:\n{}",
dump.join("\n")
);
}
#[test]
fn message_viewport_lines_render_overflow_marker_as_text_fallback() {
let mut message = message_with_attachment(Some("look".to_owned()), image_attachment());
message.attachments = image_attachments(6);
let messages = [&message];
let lines = message_viewport_lines(
&messages,
None,
&DashboardState::new(),
super::message_viewport_layout(200, 80, 80, 16, 3),
&[],
);
assert!(line_texts_from_ratatui(&lines).contains(&" +2 more images".to_owned()));
}
fn render_dashboard_dump(width: u16, height: u16, state: &mut DashboardState) -> Vec<String> {
render_dashboard_dump_with_previews(width, height, state, Vec::new())
}
fn render_dashboard_dump_with_previews(
width: u16,
height: u16,
state: &mut DashboardState,
image_previews: Vec<ImagePreview<'_>>,
) -> Vec<String> {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("test terminal should build");
terminal
.draw(|frame| {
sync_view_heights(frame.area(), state);
super::render(frame, state, image_previews, Vec::new(), Vec::new(), None);
})
.expect("draw");
let buffer = terminal.backend().buffer();
(0..buffer.area.height)
.map(|row| {
(0..buffer.area.width)
.map(|col| buffer[(col, row)].symbol().to_owned())
.collect::<String>()
})
.collect()
}
fn message_with_attachment(content: Option<String>, attachment: AttachmentInfo) -> MessageState {
MessageState {
id: Id::new(1),
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
pinned: false,
reactions: Vec::new(),
content,
mentions: Vec::new(),
attachments: vec![attachment],
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
..MessageState::default()
}
}
fn message_with_content(content: Option<String>) -> MessageState {
let mut message = message_with_attachment(content, image_attachment());
message.attachments.clear();
message
}
fn youtube_embed() -> EmbedInfo {
EmbedInfo {
color: Some(0xff0000),
provider_name: Some("YouTube".to_owned()),
author_name: None,
title: Some("Example Video".to_owned()),
description: Some("A video description".to_owned()),
fields: Vec::new(),
footer_text: None,
url: Some("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_owned()),
thumbnail_url: Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg".to_owned()),
thumbnail_proxy_url: None,
thumbnail_width: Some(480),
thumbnail_height: Some(360),
image_url: Some("https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg".to_owned()),
image_proxy_url: None,
image_width: Some(480),
image_height: Some(360),
video_url: None,
}
}
fn state_with_message() -> DashboardState {
let guild_id = Id::new(1);
let channel_id = Id::new(2);
let mut state = DashboardState::new();
state.push_event(AppEvent::GuildCreate {
guild_id,
name: "guild".to_owned(),
member_count: None,
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
channel_id,
parent_id: None,
position: None,
last_message_id: None,
name: "general".to_owned(),
kind: "GuildText".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}],
members: Vec::new(),
presences: Vec::new(),
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
state.confirm_selected_guild();
state.confirm_selected_channel();
state.focus_pane(FocusPane::Messages);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(guild_id),
channel_id,
message_id: Id::new(1),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some("hello".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
state
}
fn state_with_image_message() -> DashboardState {
let mut state = state_with_message();
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some(String::new()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: vec![image_attachment()],
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
state.jump_bottom();
state
}
fn state_with_forum_posts(post_count: usize) -> DashboardState {
let guild_id = Id::new(1);
let forum_id = Id::new(20);
let mut state = DashboardState::new();
state.push_event(AppEvent::GuildCreate {
guild_id,
name: "guild".to_owned(),
member_count: None,
channels: vec![ChannelInfo {
guild_id: Some(guild_id),
channel_id: forum_id,
parent_id: None,
position: None,
last_message_id: None,
name: "forum".to_owned(),
kind: "GuildForum".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}],
members: Vec::new(),
presences: Vec::new(),
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
state.confirm_selected_guild();
state.confirm_selected_channel();
state.focus_pane(FocusPane::Messages);
let posts: Vec<_> = (0..post_count)
.map(|index| {
let id = 100 + u64::try_from(index).expect("post index should fit u64");
ChannelInfo {
guild_id: Some(guild_id),
channel_id: Id::new(id),
parent_id: Some(forum_id),
position: None,
last_message_id: Some(Id::new(10_000 + id)),
name: format!("post {index}"),
kind: "GuildPublicThread".to_owned(),
message_count: Some(0),
total_message_sent: Some(1),
thread_archived: Some(false),
thread_locked: Some(false),
thread_pinned: Some(false),
recipients: None,
permission_overwrites: Vec::new(),
}
})
.collect();
state.push_event(AppEvent::ForumPostsLoaded {
channel_id: forum_id,
archive_state: crate::discord::ForumPostArchiveState::Active,
offset: 0,
next_offset: posts.len(),
posts,
preview_messages: Vec::new(),
has_more: false,
});
state
}
fn state_with_unread_direct_messages() -> DashboardState {
let mut state = DashboardState::new();
for (channel_id, name, last_message_id) in [
(Id::new(10), "old", Some(Id::new(100))),
(Id::new(20), "new", Some(Id::new(200))),
(Id::new(30), "empty", None),
] {
state.push_event(AppEvent::ChannelUpsert(ChannelInfo {
guild_id: None,
channel_id,
parent_id: None,
position: None,
last_message_id,
name: name.to_owned(),
kind: "dm".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}));
}
state.push_event(AppEvent::ReadStateInit {
entries: vec![
ReadStateInfo {
channel_id: Id::new(10),
last_acked_message_id: Some(Id::new(100)),
mention_count: 0,
},
ReadStateInfo {
channel_id: Id::new(20),
last_acked_message_id: Some(Id::new(100)),
mention_count: 0,
},
],
});
state
}
fn state_with_unread_direct_messages_with_loaded_unread_messages(count: u64) -> DashboardState {
let mut state = state_with_unread_direct_messages();
state.push_event(AppEvent::MessageHistoryLoaded {
channel_id: Id::new(20),
before: None,
messages: (0..count)
.map(|offset| MessageInfo {
guild_id: None,
channel_id: Id::new(20),
message_id: Id::new(101 + offset),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
pinned: false,
reactions: Vec::new(),
content: Some(format!("dm {offset}")),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
..MessageInfo::default()
})
.collect(),
});
state
}
fn push_message(state: &mut DashboardState, message_id: u64, content: &str) {
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(message_id),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
content: Some(content.to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
}
fn message_info(message_id: u64, author: &str, content: &str, pinned: bool) -> MessageInfo {
MessageInfo {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
message_id: Id::new(message_id),
author_id: Id::new(99),
author: author.to_owned(),
author_avatar_url: None,
author_role_ids: Vec::new(),
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
pinned,
reactions: Vec::new(),
content: Some(content.to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
..MessageInfo::default()
}
}
fn message_with_forwarded_snapshot(snapshot: MessageSnapshotInfo) -> MessageState {
MessageState {
id: Id::new(1),
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
author_id: Id::new(99),
author: "neo".to_owned(),
author_avatar_url: None,
message_kind: crate::discord::MessageKind::regular(),
reference: None,
reply: None,
poll: None,
pinned: false,
reactions: Vec::new(),
content: Some(String::new()),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: vec![snapshot],
..MessageState::default()
}
}
fn poll_info(allow_multiselect: bool) -> PollInfo {
PollInfo {
question: "What should we eat?".to_owned(),
answers: vec![
PollAnswerInfo {
answer_id: 1,
text: "Soup".to_owned(),
vote_count: Some(2),
me_voted: true,
},
PollAnswerInfo {
answer_id: 2,
text: "Noodles".to_owned(),
vote_count: Some(1),
me_voted: false,
},
],
allow_multiselect,
results_finalized: Some(false),
total_votes: Some(3),
}
}
fn forwarded_snapshot(
content: Option<&str>,
attachments: Vec<AttachmentInfo>,
) -> MessageSnapshotInfo {
MessageSnapshotInfo {
content: content.map(str::to_owned),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments,
embeds: Vec::new(),
source_channel_id: None,
timestamp: None,
}
}
fn state_with_member(user_id: u64, display_name: &str) -> DashboardState {
let mut state = DashboardState::new();
state.push_event(AppEvent::GuildCreate {
guild_id: Id::new(1),
name: "guild".to_owned(),
member_count: None,
channels: Vec::new(),
members: vec![member_info(user_id, display_name)],
presences: vec![(Id::new(user_id), PresenceStatus::Online)],
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
state
}
fn state_with_role(role_id: u64, name: &str) -> DashboardState {
let mut state = DashboardState::new();
state.push_event(AppEvent::GuildCreate {
guild_id: Id::new(1),
name: "guild".to_owned(),
member_count: None,
channels: Vec::new(),
members: Vec::new(),
presences: Vec::new(),
roles: vec![RoleInfo {
id: Id::new(role_id),
name: name.to_owned(),
color: None,
position: 1,
hoist: false,
permissions: 0,
}],
emojis: Vec::new(),
owner_id: None,
});
state
}
fn member_info(user_id: u64, display_name: &str) -> MemberInfo {
MemberInfo {
user_id: Id::new(user_id),
display_name: display_name.to_owned(),
username: None,
is_bot: false,
avatar_url: None,
role_ids: Vec::new(),
}
}
fn user_profile_info(user_id: u64, username: &str) -> UserProfileInfo {
UserProfileInfo {
user_id: Id::new(user_id),
username: username.to_owned(),
global_name: None,
guild_nick: None,
role_ids: Vec::new(),
avatar_url: None,
bio: None,
pronouns: None,
mutual_guilds: Vec::<MutualGuildInfo>::new(),
mutual_friends_count: 0,
friend_status: FriendStatus::None,
note: None,
}
}
fn mention_info(user_id: u64, display_name: &str) -> MentionInfo {
MentionInfo {
user_id: Id::new(user_id),
guild_nick: None,
display_name: display_name.to_owned(),
}
}
fn mention_info_with_nick(user_id: u64, nick: &str) -> MentionInfo {
MentionInfo {
user_id: Id::new(user_id),
guild_nick: Some(nick.to_owned()),
display_name: nick.to_owned(),
}
}
fn channel_with_recipients(kind: &str, statuses: &[PresenceStatus]) -> ChannelState {
ChannelState {
id: Id::new(10),
guild_id: None,
parent_id: None,
position: None,
last_message_id: None,
name: "alice".to_owned(),
kind: kind.to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: statuses
.iter()
.enumerate()
.map(|(index, status)| ChannelRecipientState {
user_id: Id::new(100 + u64::try_from(index).expect("index should fit u64")),
display_name: format!("recipient {index}"),
username: None,
is_bot: false,
avatar_url: None,
status: *status,
})
.collect(),
permission_overwrites: Vec::new(),
}
}
fn line_texts(lines: &[MessageContentLine]) -> Vec<&str> {
lines.iter().map(|line| line.text.as_str()).collect()
}
fn poll_test_line(text: &str, width: usize) -> String {
let inner_width = poll_card_inner_width(width);
let padding = inner_width.saturating_sub(text.width());
format!("│ {text}{} │", " ".repeat(padding))
}
fn line_texts_from_ratatui(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect()
}
fn image_attachment() -> AttachmentInfo {
AttachmentInfo {
id: Id::new(3),
filename: "cat.png".to_owned(),
url: "https://cdn.discordapp.com/cat.png".to_owned(),
proxy_url: "https://media.discordapp.net/cat.png".to_owned(),
content_type: Some("image/png".to_owned()),
size: 2048,
width: Some(640),
height: Some(480),
description: None,
}
}
fn image_attachments(count: u64) -> Vec<AttachmentInfo> {
(0..count)
.map(|index| {
let id = 3 + index;
let mut attachment = image_attachment();
attachment.id = Id::new(id);
attachment.filename = format!("image-{id}.png");
attachment.url = format!("https://cdn.discordapp.com/image-{id}.png");
attachment.proxy_url = format!("https://media.discordapp.net/image-{id}.png");
attachment
})
.collect()
}
fn video_attachment() -> AttachmentInfo {
AttachmentInfo {
id: Id::new(4),
filename: "clip.mp4".to_owned(),
url: "https://cdn.discordapp.com/clip.mp4".to_owned(),
proxy_url: "https://media.discordapp.net/clip.mp4".to_owned(),
content_type: Some("video/mp4".to_owned()),
size: 78_364_758,
width: Some(1920),
height: Some(1080),
description: None,
}
}
fn file_attachment() -> AttachmentInfo {
AttachmentInfo {
id: Id::new(5),
filename: "notes.txt".to_owned(),
url: "https://cdn.discordapp.com/notes.txt".to_owned(),
proxy_url: "https://media.discordapp.net/notes.txt".to_owned(),
content_type: Some("text/plain".to_owned()),
size: 42,
width: None,
height: None,
description: None,
}
}