use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use crate::discord::ids::Id;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use super::{MouseClickTracker, handle_key, handle_mouse, handle_mouse_event, handle_paste};
use crate::{
config::ImagePreviewQualityPreset,
discord::{
AppCommand, AppEvent, ChannelInfo, ChannelNotificationOverrideInfo, ChannelRecipientInfo,
CustomEmojiInfo, GuildFolder, GuildNotificationSettingsInfo, MemberInfo,
MessageReferenceInfo, NotificationLevel, PollAnswerInfo, PollInfo, PresenceStatus,
ReactionEmoji, ReactionUserInfo, ReactionUsersInfo,
},
tui::state::{ChannelPaneEntry, DashboardState, FocusPane, GuildPaneEntry, MessageActionKind},
};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn char_key(value: char) -> KeyEvent {
key(KeyCode::Char(value))
}
fn ctrl_key(value: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(value), KeyModifiers::CONTROL)
}
fn shift_enter() -> KeyEvent {
KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)
}
fn alt_key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::ALT)
}
fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
}
}
fn channel_row_point(row: u16) -> (u16, u16) {
(21, 2 + row)
}
fn composer_point() -> (u16, u16) {
(50, 16)
}
fn message_row_point(row: u16) -> (u16, u16) {
(50, 2 + row)
}
fn message_action_row_point(row: u16) -> (u16, u16) {
(46, 7 + row)
}
fn dashboard_area() -> Rect {
Rect::new(0, 0, 120, 20)
}
fn temp_upload_file(name: &str, contents: &[u8]) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is after unix epoch")
.as_nanos();
let directory = std::env::temp_dir().join(format!("concord-{unique}"));
fs::create_dir_all(&directory).expect("temp upload directory can be created");
let path = directory.join(name);
fs::write(&path, contents).expect("temp upload file can be written");
path
}
fn remove_temp_upload_file(path: &PathBuf) {
let directory = path.parent().map(std::path::Path::to_path_buf);
let _ = fs::remove_file(path);
if let Some(directory) = directory {
let _ = fs::remove_dir(directory);
}
}
#[test]
fn enter_toggles_selected_folder_and_space_opens_leader() {
let mut state = state_with_folder();
state.focus_pane(FocusPane::Guilds);
handle_key(&mut state, key(KeyCode::Enter));
assert_selected_folder_collapsed(&state, true);
handle_key(&mut state, char_key(' '));
assert!(state.is_leader_active());
assert_selected_folder_collapsed(&state, true);
}
#[test]
fn enter_toggles_selected_channel_category_and_space_opens_leader() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Enter));
assert_selected_channel_category_collapsed(&state, true);
handle_key(&mut state, char_key(' '));
assert!(state.is_leader_active());
assert_selected_channel_category_collapsed(&state, true);
}
#[test]
fn movement_waits_for_enter_to_activate_channel() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
assert_eq!(state.selected_channel_id(), None);
handle_key(&mut state, key(KeyCode::Down));
assert_eq!(state.selected_channel_id(), None);
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
command,
Some(AppCommand::SubscribeGuildChannel {
guild_id: Id::new(1),
channel_id: Id::new(11),
})
);
assert_eq!(state.selected_channel_id(), Some(Id::new(11)));
handle_key(&mut state, key(KeyCode::Down));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
command,
Some(AppCommand::SubscribeGuildChannel {
guild_id: Id::new(1),
channel_id: Id::new(12),
})
);
assert_eq!(state.selected_channel_id(), Some(Id::new(12)));
}
#[test]
fn enter_on_direct_message_kinds_subscribes_channel() {
for kind in ["dm", "group-dm"] {
let mut state = state_with_direct_message(kind);
state.focus_pane(FocusPane::Channels);
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(state.selected_channel_id(), Some(Id::new(20)));
assert_eq!(
command,
Some(AppCommand::SubscribeDirectMessage {
channel_id: Id::new(20),
})
);
}
}
#[test]
fn message_keys_use_scroll_controls() {
let mut state = state_with_messages(10);
state.focus_pane(FocusPane::Messages);
state.set_message_view_height(9);
handle_key(&mut state, ctrl_key('u'));
assert_eq!(state.selected_message(), 5);
assert!(!state.message_auto_follow());
handle_key(&mut state, key(KeyCode::PageUp));
assert_eq!(state.selected_message(), 1);
assert!(!state.message_auto_follow());
handle_key(&mut state, ctrl_key('d'));
assert_eq!(state.selected_message(), 5);
assert!(!state.message_auto_follow());
handle_key(&mut state, ctrl_key('d'));
assert_eq!(state.selected_message(), 9);
assert!(state.message_auto_follow());
}
#[test]
fn message_top_scroll_requests_older_history_once() {
let mut state = state_with_messages(3);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, char_key('g'));
let command = handle_key(&mut state, key(KeyCode::Up));
assert_eq!(
command,
Some(AppCommand::LoadMessageHistory {
channel_id: Id::new(2),
before: Some(Id::new(1)),
})
);
let duplicate = handle_key(&mut state, key(KeyCode::Up));
assert_eq!(duplicate, None);
}
#[test]
fn message_viewport_scroll_keys_do_not_change_selection_or_request_history() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
state.clamp_message_viewport_for_image_previews(2, 16, 3);
let selected = state.selected_message();
handle_key(&mut state, char_key('J'));
state.clamp_message_viewport_for_image_previews(2, 16, 3);
let command = handle_key(&mut state, char_key('K'));
assert_eq!(command, None);
assert_eq!(state.selected_message(), selected);
assert_eq!(state.message_line_scroll(), 0);
}
#[test]
fn message_home_end_scroll_viewport_without_changing_selection() {
let mut state = state_with_messages(10);
state.focus_pane(FocusPane::Messages);
state.set_message_view_height(5);
state.clamp_message_viewport_for_image_previews(200, 16, 3);
let selected = state.selected_message();
handle_key(&mut state, key(KeyCode::Home));
assert_eq!(state.selected_message(), selected);
assert_eq!(state.message_scroll(), 0);
handle_key(&mut state, key(KeyCode::End));
assert_eq!(state.selected_message(), selected);
assert!(state.message_scroll() > 0);
}
#[test]
fn page_keys_scroll_non_message_panes() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
state.set_channel_view_height(9);
handle_key(&mut state, key(KeyCode::PageDown));
assert_eq!(state.selected_channel(), 2);
handle_key(&mut state, key(KeyCode::PageUp));
assert_eq!(state.selected_channel(), 0);
}
#[test]
fn composer_requires_selected_channel() {
let mut state = DashboardState::new();
handle_key(&mut state, char_key('i'));
assert!(!state.is_composing());
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
assert!(state.is_composing());
assert_eq!(state.focus(), FocusPane::Messages);
}
#[test]
fn number_keys_focus_top_level_panes() {
let mut state = DashboardState::new();
handle_key(&mut state, char_key('2'));
assert_eq!(state.focus(), FocusPane::Channels);
handle_key(&mut state, char_key('3'));
assert_eq!(state.focus(), FocusPane::Messages);
handle_key(&mut state, char_key('4'));
assert_eq!(state.focus(), FocusPane::Members);
handle_key(&mut state, char_key('1'));
assert_eq!(state.focus(), FocusPane::Guilds);
}
#[test]
fn number_keys_show_hidden_panes_before_focusing() {
let mut state = DashboardState::new();
state.toggle_pane_visibility(FocusPane::Guilds);
state.toggle_pane_visibility(FocusPane::Channels);
state.toggle_pane_visibility(FocusPane::Members);
handle_key(&mut state, char_key('1'));
assert!(state.is_pane_visible(FocusPane::Guilds));
assert_eq!(state.focus(), FocusPane::Guilds);
handle_key(&mut state, char_key('2'));
assert!(state.is_pane_visible(FocusPane::Channels));
assert_eq!(state.focus(), FocusPane::Channels);
handle_key(&mut state, char_key('4'));
assert!(state.is_pane_visible(FocusPane::Members));
assert_eq!(state.focus(), FocusPane::Members);
}
#[test]
fn bare_m_no_longer_mutes_focused_channel() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
let command = handle_key(&mut state, char_key('m'));
assert_eq!(command, None);
}
#[test]
fn leader_channel_actions_offer_mute_duration_and_submit_command() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('a'));
handle_key(&mut state, char_key('u'));
let command = handle_key(&mut state, char_key('1'));
assert_eq!(
command,
Some(AppCommand::SetChannelMuted {
guild_id: Some(Id::new(1)),
channel_id: Id::new(11),
muted: true,
duration: Some(crate::discord::MuteDuration::Minutes(15)),
label: "#general".to_owned(),
})
);
}
#[test]
fn leader_channel_actions_unmute_when_already_muted() {
let mut state = state_with_channel_tree();
state.push_event(AppEvent::UserGuildNotificationSettingsInit {
settings: vec![GuildNotificationSettingsInfo {
guild_id: Some(Id::new(1)),
message_notifications: Some(NotificationLevel::OnlyMentions),
muted: false,
mute_end_time: None,
suppress_everyone: false,
suppress_roles: false,
channel_overrides: vec![ChannelNotificationOverrideInfo {
channel_id: Id::new(11),
message_notifications: None,
muted: true,
mute_end_time: None,
}],
}],
});
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('a'));
let command = handle_key(&mut state, char_key('u'));
assert_eq!(
command,
Some(AppCommand::SetChannelMuted {
guild_id: Some(Id::new(1)),
channel_id: Id::new(11),
muted: false,
duration: None,
label: "#general".to_owned(),
})
);
}
#[test]
fn leader_category_actions_offer_mute_duration_and_submit_command() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Up));
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('a'));
handle_key(&mut state, char_key('u'));
let command = handle_key(&mut state, char_key('1'));
assert_eq!(
command,
Some(AppCommand::SetChannelMuted {
guild_id: Some(Id::new(1)),
channel_id: Id::new(10),
muted: true,
duration: Some(crate::discord::MuteDuration::Minutes(15)),
label: "Text Channels".to_owned(),
})
);
}
#[test]
fn leader_server_actions_unmute_when_already_muted() {
let mut state = state_with_channel_tree();
state.push_event(AppEvent::UserGuildNotificationSettingsInit {
settings: vec![GuildNotificationSettingsInfo {
guild_id: Some(Id::new(1)),
message_notifications: Some(NotificationLevel::OnlyMentions),
muted: true,
mute_end_time: None,
suppress_everyone: false,
suppress_roles: false,
channel_overrides: Vec::new(),
}],
});
state.focus_pane(FocusPane::Guilds);
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('a'));
let command = handle_key(&mut state, char_key('u'));
assert_eq!(
command,
Some(AppCommand::SetGuildMuted {
guild_id: Id::new(1),
muted: false,
duration: None,
label: "guild".to_owned(),
})
);
}
#[test]
fn leader_number_keys_toggle_side_panes() {
let mut state = DashboardState::new();
state.focus_pane(FocusPane::Guilds);
handle_key(&mut state, char_key(' '));
assert!(state.is_leader_active());
handle_key(&mut state, char_key('1'));
assert!(!state.is_leader_active());
assert!(!state.is_pane_visible(FocusPane::Guilds));
assert_eq!(state.focus(), FocusPane::Messages);
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('2'));
assert!(!state.is_pane_visible(FocusPane::Channels));
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('4'));
assert!(!state.is_pane_visible(FocusPane::Members));
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('1'));
assert!(state.is_pane_visible(FocusPane::Guilds));
}
#[test]
fn alt_arrows_adjust_focused_side_pane_width() {
let mut state = DashboardState::new();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, alt_key(KeyCode::Right));
assert_eq!(state.pane_width(FocusPane::Channels), 25);
handle_key(&mut state, alt_key(KeyCode::Left));
assert_eq!(state.pane_width(FocusPane::Channels), 24);
assert_eq!(
state.take_display_options_save_request(),
Some(state.display_options())
);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, alt_key(KeyCode::Right));
assert_eq!(state.pane_width(FocusPane::Channels), 24);
assert_eq!(state.take_display_options_save_request(), None);
}
#[test]
fn alt_h_l_adjust_focused_side_pane_width() {
let mut state = DashboardState::new();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, alt_key(KeyCode::Char('l')));
assert_eq!(state.pane_width(FocusPane::Channels), 25);
handle_key(&mut state, alt_key(KeyCode::Char('h')));
assert_eq!(state.pane_width(FocusPane::Channels), 24);
}
#[test]
fn leader_esc_and_unknown_key_cancel_without_toggling_panes() {
let mut state = DashboardState::new();
handle_key(&mut state, char_key(' '));
handle_key(&mut state, key(KeyCode::Esc));
assert!(!state.is_leader_active());
assert!(state.is_pane_visible(FocusPane::Guilds));
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('x'));
assert!(!state.is_leader_active());
assert!(state.is_pane_visible(FocusPane::Channels));
}
#[test]
fn leader_leader_switcher_filters_and_opens_selected_channel() {
let mut state = state_with_channel_tree();
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key(' '));
assert!(!state.is_leader_active());
assert!(state.is_channel_switcher_open());
for ch in "rand".chars() {
handle_key(&mut state, char_key(ch));
}
let command = handle_key(&mut state, key(KeyCode::Enter));
assert!(!state.is_channel_switcher_open());
assert_eq!(state.selected_channel_id(), Some(Id::new(12)));
assert_eq!(
command,
Some(AppCommand::SubscribeGuildChannel {
guild_id: Id::new(1),
channel_id: Id::new(12),
})
);
}
#[test]
fn leader_leader_switcher_expands_collapsed_parent_category() {
let mut state = state_with_channel_tree();
state.toggle_selected_channel_category();
assert_selected_channel_category_collapsed(&state, true);
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key(' '));
for ch in "rand".chars() {
handle_key(&mut state, char_key(ch));
}
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_selected_channel_category_collapsed(&state, false);
assert_eq!(state.selected_channel_id(), Some(Id::new(12)));
assert!(matches!(
state.channel_pane_entries().get(state.selected_channel()),
Some(ChannelPaneEntry::Channel { state, .. }) if state.id == Id::new(12)
));
assert_eq!(
command,
Some(AppCommand::SubscribeGuildChannel {
guild_id: Id::new(1),
channel_id: Id::new(12),
})
);
}
#[test]
fn leader_leader_switcher_opens_direct_message() {
let mut state = state_with_direct_message("dm");
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key(' '));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(state.selected_channel_id(), Some(Id::new(20)));
assert_eq!(
command,
Some(AppCommand::SubscribeDirectMessage {
channel_id: Id::new(20),
})
);
}
#[test]
fn leader_leader_switcher_j_and_k_type_into_search() {
let mut state = state_with_channel_tree();
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('j'));
handle_key(&mut state, char_key('k'));
assert_eq!(state.channel_switcher_query(), Some("jk"));
assert_eq!(state.selected_channel_switcher_index(), Some(0));
}
#[test]
fn leader_leader_switcher_ctrl_n_and_ctrl_p_move_selection() {
let mut state = state_with_channel_tree();
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key(' '));
handle_key(&mut state, ctrl_key('n'));
assert_eq!(state.selected_channel_switcher_index(), Some(1));
handle_key(&mut state, ctrl_key('p'));
assert_eq!(state.selected_channel_switcher_index(), Some(0));
}
#[test]
fn leader_leader_switcher_left_right_move_search_cursor() {
let mut state = state_with_channel_tree();
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key(' '));
for ch in "raXndom".chars() {
handle_key(&mut state, char_key(ch));
}
for _ in 0..5 {
handle_key(&mut state, key(KeyCode::Left));
}
handle_key(&mut state, key(KeyCode::Right));
handle_key(&mut state, key(KeyCode::Backspace));
assert_eq!(state.channel_switcher_query(), Some("random"));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(state.selected_channel_id(), Some(Id::new(12)));
assert_eq!(
command,
Some(AppCommand::SubscribeGuildChannel {
guild_id: Id::new(1),
channel_id: Id::new(12),
})
);
}
#[test]
fn mouse_input_closes_leader_hint() {
let mut state = DashboardState::new();
handle_key(&mut state, char_key(' '));
assert!(state.is_leader_active());
handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 50, 1),
dashboard_area(),
);
assert!(!state.is_leader_active());
}
#[test]
fn tab_cycles_skip_hidden_panes() {
let mut state = DashboardState::new();
state.toggle_pane_visibility(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Tab));
assert_eq!(state.focus(), FocusPane::Messages);
state.toggle_pane_visibility(FocusPane::Members);
handle_key(&mut state, key(KeyCode::Tab));
assert_eq!(state.focus(), FocusPane::Guilds);
}
#[test]
fn tab_and_shift_tab_cycle_focus() {
let mut state = DashboardState::new();
handle_key(&mut state, key(KeyCode::Tab));
assert_eq!(state.focus(), FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Tab));
assert_eq!(state.focus(), FocusPane::Messages);
handle_key(&mut state, key(KeyCode::BackTab));
assert_eq!(state.focus(), FocusPane::Channels);
handle_key(&mut state, key(KeyCode::BackTab));
assert_eq!(state.focus(), FocusPane::Guilds);
handle_key(&mut state, key(KeyCode::BackTab));
assert_eq!(state.focus(), FocusPane::Members);
}
#[test]
fn left_click_focuses_top_level_pane() {
let mut state = DashboardState::new();
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 50, 1),
dashboard_area(),
));
assert_eq!(state.focus(), FocusPane::Messages);
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 100, 1),
dashboard_area(),
));
assert_eq!(state.focus(), FocusPane::Members);
}
#[test]
fn left_click_selects_visible_channel_row() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Messages);
let (column, row) = channel_row_point(1);
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
));
assert_eq!(state.focus(), FocusPane::Channels);
assert_eq!(state.selected_channel(), 1);
assert_eq!(state.selected_channel_id(), None);
}
#[test]
fn double_click_activates_selected_channel_like_enter() {
let mut state = state_with_channel_tree();
let mut clicks = MouseClickTracker::default();
let (column, row) = channel_row_point(1);
let first = handle_mouse_event(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
&mut clicks,
);
let second = handle_mouse_event(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
&mut clicks,
);
assert!(first.handled);
assert_eq!(first.command, None);
assert!(second.handled);
assert_eq!(state.selected_channel_id(), Some(Id::new(11)));
assert_eq!(
second.command,
Some(AppCommand::SubscribeGuildChannel {
guild_id: Id::new(1),
channel_id: Id::new(11),
})
);
}
#[test]
fn left_click_selects_channel_switcher_row() {
let mut state = state_with_channel_tree();
state.open_channel_switcher();
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 50, 7),
dashboard_area(),
));
assert!(state.is_channel_switcher_open());
assert_eq!(state.selected_channel_switcher_index(), Some(1));
assert_eq!(state.selected_channel_id(), None);
}
#[test]
fn double_click_activates_channel_switcher_row() {
let mut state = state_with_channel_tree();
state.open_channel_switcher();
let mut clicks = MouseClickTracker::default();
let event = mouse(MouseEventKind::Down(MouseButton::Left), 50, 7);
let first = handle_mouse_event(&mut state, event, dashboard_area(), &mut clicks);
let second = handle_mouse_event(&mut state, event, dashboard_area(), &mut clicks);
assert!(first.handled);
assert_eq!(first.command, None);
assert!(second.handled);
assert!(!state.is_channel_switcher_open());
assert_eq!(state.selected_channel_id(), Some(Id::new(12)));
assert_eq!(
second.command,
Some(AppCommand::SubscribeGuildChannel {
guild_id: Id::new(1),
channel_id: Id::new(12),
})
);
}
#[test]
fn channel_switcher_absorbs_backdrop_clicks() {
let mut state = state_with_channel_tree();
state.open_channel_switcher();
state.focus_pane(FocusPane::Messages);
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 21, 2),
dashboard_area(),
));
assert!(state.is_channel_switcher_open());
assert_eq!(state.focus(), FocusPane::Messages);
assert_eq!(state.selected_channel(), 0);
}
#[test]
fn wheel_moves_channel_switcher_selection() {
let mut state = state_with_channel_tree();
state.open_channel_switcher();
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::ScrollDown, 50, 7),
dashboard_area(),
));
assert_eq!(state.selected_channel_switcher_index(), Some(1));
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::ScrollUp, 50, 7),
dashboard_area(),
));
assert_eq!(state.selected_channel_switcher_index(), Some(0));
}
#[test]
fn terminal_click_release_sequence_still_double_clicks_like_enter() {
let mut state = state_with_channel_tree();
let mut clicks = MouseClickTracker::default();
let (column, row) = channel_row_point(1);
let first = handle_mouse_event(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
&mut clicks,
);
let release = handle_mouse_event(
&mut state,
mouse(MouseEventKind::Up(MouseButton::Left), column, row),
dashboard_area(),
&mut clicks,
);
let second = handle_mouse_event(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
&mut clicks,
);
assert!(first.handled);
assert!(release.handled);
assert!(second.handled);
assert_eq!(
second.command,
Some(AppCommand::SubscribeGuildChannel {
guild_id: Id::new(1),
channel_id: Id::new(11),
})
);
}
#[test]
fn scroll_between_clicks_prevents_stale_double_click_activation() {
let mut state = state_with_channel_tree();
let mut clicks = MouseClickTracker::default();
let (column, row) = channel_row_point(1);
let first = handle_mouse_event(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
&mut clicks,
);
let scroll = handle_mouse_event(
&mut state,
mouse(MouseEventKind::ScrollDown, column, row),
dashboard_area(),
&mut clicks,
);
let second = handle_mouse_event(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
&mut clicks,
);
assert!(first.handled);
assert!(scroll.handled);
assert!(second.handled);
assert_eq!(second.command, None);
assert_eq!(state.selected_channel_id(), None);
}
#[test]
fn forum_blank_bottom_rows_do_not_select_hidden_posts() {
let mut state = state_with_forum_channel_posts();
state.push_event(AppEvent::ForumPostsLoaded {
channel_id: Id::new(20),
archive_state: crate::discord::ForumPostArchiveState::Active,
offset: 2,
next_offset: 3,
posts: vec![ChannelInfo {
guild_id: Some(Id::new(1)),
channel_id: Id::new(29),
parent_id: Some(Id::new(20)),
position: Some(2),
last_message_id: None,
name: "hidden by remainder rows".to_owned(),
kind: "GuildPublicThread".to_owned(),
message_count: Some(1),
total_message_sent: Some(1),
thread_archived: Some(false),
thread_locked: Some(false),
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
}],
preview_messages: Vec::new(),
has_more: false,
});
state.focus_pane(FocusPane::Messages);
state.set_message_view_height(14);
let (column, row) = message_row_point(11);
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
));
assert_eq!(state.selected_forum_post(), 0);
}
#[test]
fn left_click_on_message_input_starts_composer() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
let (column, row) = composer_point();
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
));
assert!(state.is_composing());
assert_eq!(state.focus(), FocusPane::Messages);
}
#[test]
fn mouse_click_outside_dashboard_panes_does_not_change_focus() {
let mut state = DashboardState::new();
state.focus_pane(FocusPane::Messages);
assert!(!handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 10, 19),
dashboard_area(),
));
assert_eq!(state.focus(), FocusPane::Messages);
assert!(!handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Right), 1, 1),
dashboard_area(),
));
assert_eq!(state.focus(), FocusPane::Messages);
}
#[test]
fn mouse_click_outside_composer_blurs_and_focuses_clicked_pane() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
handle_key(&mut state, char_key('d'));
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 100, 1),
dashboard_area(),
));
assert_eq!(state.focus(), FocusPane::Members);
assert!(!state.is_composing());
assert_eq!(state.composer_input(), "");
}
#[test]
fn mouse_click_outside_composer_blurs_and_selects_clicked_row() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, key(KeyCode::Up));
state.start_composer();
let (column, row) = channel_row_point(1);
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
));
assert!(!state.is_composing());
assert_eq!(state.focus(), FocusPane::Channels);
assert_eq!(state.selected_channel(), 1);
}
#[test]
fn mouse_scroll_outside_composer_does_not_clear_draft() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
handle_key(&mut state, char_key('d'));
assert!(!handle_mouse(
&mut state,
mouse(MouseEventKind::ScrollDown, 100, 1),
dashboard_area(),
));
assert!(state.is_composing());
assert_eq!(state.composer_input(), "d");
}
#[test]
fn mouse_wheel_scrolls_hovered_channel_viewport_without_moving_selection() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Messages);
state.set_channel_view_height(2);
let selected = state.selected_channel();
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::ScrollDown, 21, 1),
dashboard_area(),
));
assert_eq!(state.focus(), FocusPane::Channels);
assert_eq!(state.selected_channel(), selected);
assert_eq!(state.channel_scroll(), 1);
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::ScrollUp, 21, 1),
dashboard_area(),
));
assert_eq!(state.selected_channel(), selected);
assert_eq!(state.channel_scroll(), 0);
}
#[test]
fn mouse_wheel_scrolls_hovered_member_viewport_without_moving_selection() {
let mut state = state_with_members(10);
state.focus_pane(FocusPane::Messages);
state.set_member_view_height(4);
let selected = state.selected_member();
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::ScrollDown, 100, 1),
dashboard_area(),
));
assert_eq!(state.focus(), FocusPane::Members);
assert_eq!(state.selected_member(), selected);
assert_eq!(state.member_scroll(), 1);
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::ScrollUp, 100, 1),
dashboard_area(),
));
assert_eq!(state.selected_member(), selected);
assert_eq!(state.member_scroll(), 0);
}
#[test]
fn mouse_wheel_scrolls_message_viewport_without_changing_selection() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
state.clamp_message_viewport_for_image_previews(2, 16, 3);
let selected = state.selected_message();
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::ScrollDown, 50, 1),
dashboard_area(),
));
state.clamp_message_viewport_for_image_previews(2, 16, 3);
assert_eq!(state.focus(), FocusPane::Messages);
assert_eq!(state.selected_message(), selected);
assert!(state.message_line_scroll() > 0);
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::ScrollUp, 50, 1),
dashboard_area(),
));
assert_eq!(state.selected_message(), selected);
assert_eq!(state.message_line_scroll(), 0);
}
#[test]
fn user_profile_popup_absorbs_left_clicks_only_inside_popup() {
let mut state = DashboardState::new();
state.focus_pane(FocusPane::Messages);
state.open_user_profile_popup(Id::new(10), None);
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 60, 10),
dashboard_area(),
));
assert_eq!(state.focus(), FocusPane::Messages);
assert!(state.is_user_profile_popup_open());
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), 100, 1),
dashboard_area(),
));
assert_eq!(state.focus(), FocusPane::Members);
assert!(state.is_user_profile_popup_open());
}
#[test]
fn number_keys_type_digits_while_composing() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
handle_key(&mut state, char_key('4'));
assert_eq!(state.focus(), FocusPane::Messages);
assert_eq!(state.composer_input(), "4");
}
#[test]
fn backtick_toggles_debug_log_popup() {
let mut state = DashboardState::new();
handle_key(&mut state, char_key('`'));
assert!(state.is_debug_log_popup_open());
handle_key(&mut state, char_key('`'));
assert!(!state.is_debug_log_popup_open());
}
#[test]
fn esc_closes_debug_log_popup_modally() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
state.toggle_debug_log_popup();
handle_key(&mut state, key(KeyCode::Esc));
assert!(!state.is_debug_log_popup_open());
assert_eq!(state.focus(), FocusPane::Messages);
}
#[test]
fn backtick_types_while_composing() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
handle_key(&mut state, char_key('`'));
assert!(state.is_composing());
assert!(!state.is_debug_log_popup_open());
assert_eq!(state.composer_input(), "`");
}
#[test]
fn shift_enter_inserts_newline_while_composing() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
handle_key(&mut state, char_key('h'));
handle_key(&mut state, shift_enter());
handle_key(&mut state, char_key('i'));
assert!(state.is_composing());
assert_eq!(state.composer_input(), "h\ni");
}
#[test]
fn composer_cursor_edits_in_middle() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
for value in "abcd".chars() {
handle_key(&mut state, char_key(value));
}
handle_key(&mut state, key(KeyCode::Left));
handle_key(&mut state, key(KeyCode::Left));
handle_key(&mut state, char_key('X'));
assert_eq!(state.composer_input(), "abXcd");
assert_eq!(state.composer_cursor_byte_index(), 3);
handle_key(&mut state, key(KeyCode::Backspace));
assert_eq!(state.composer_input(), "abcd");
assert_eq!(state.composer_cursor_byte_index(), 2);
handle_key(&mut state, key(KeyCode::Delete));
assert_eq!(state.composer_input(), "abd");
assert_eq!(state.composer_cursor_byte_index(), 2);
handle_key(&mut state, key(KeyCode::Home));
handle_key(&mut state, char_key('>'));
handle_key(&mut state, key(KeyCode::End));
handle_key(&mut state, char_key('!'));
assert_eq!(state.composer_input(), ">abd!");
assert_eq!(
state.composer_cursor_byte_index(),
state.composer_input().len()
);
}
#[test]
fn paste_inserts_text_while_composing() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
assert!(handle_paste(&mut state, "hello\r\nworld"));
assert_eq!(state.composer_input(), "hello\nworld");
}
#[test]
fn paste_inserts_text_at_composer_cursor() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
for value in "helloworld".chars() {
handle_key(&mut state, char_key(value));
}
for _ in 0..5 {
handle_key(&mut state, key(KeyCode::Left));
}
assert!(handle_paste(&mut state, " "));
assert_eq!(state.composer_input(), "hello world");
assert_eq!(state.composer_cursor_byte_index(), "hello ".len());
}
#[test]
fn paste_file_path_adds_pending_attachment() {
let path = temp_upload_file("paste path.txt", b"hello");
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
assert!(handle_paste(
&mut state,
path.to_str().expect("temp path is valid unicode")
));
assert_eq!(state.composer_input(), "");
assert_eq!(state.pending_composer_attachments().len(), 1);
assert_eq!(state.pending_composer_attachments()[0].path, path);
assert_eq!(
state.pending_composer_attachments()[0].filename,
"paste path.txt"
);
assert_eq!(state.pending_composer_attachments()[0].size_bytes, 5);
remove_temp_upload_file(&path);
}
#[test]
fn paste_single_quoted_file_path_adds_pending_attachment() {
let path = temp_upload_file("quoted path.txt", b"quoted");
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
let pasted = format!("'{}'", path.to_str().expect("temp path is valid unicode"));
assert!(handle_paste(&mut state, &pasted));
assert_eq!(state.composer_input(), "");
assert_eq!(state.pending_composer_attachments().len(), 1);
assert_eq!(state.pending_composer_attachments()[0].path, path);
assert_eq!(
state.pending_composer_attachments()[0].filename,
"quoted path.txt"
);
remove_temp_upload_file(&path);
}
#[test]
fn paste_backslash_escaped_file_path_adds_pending_attachment() {
let path = temp_upload_file("escaped path.txt", b"escaped");
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
let pasted = path
.to_str()
.expect("temp path is valid unicode")
.replace(' ', "\\ ");
assert!(handle_paste(&mut state, &pasted));
assert_eq!(state.composer_input(), "");
assert_eq!(state.pending_composer_attachments().len(), 1);
assert_eq!(state.pending_composer_attachments()[0].path, path);
assert_eq!(
state.pending_composer_attachments()[0].filename,
"escaped path.txt"
);
remove_temp_upload_file(&path);
}
#[test]
fn paste_file_uri_list_can_submit_attachment_only_message() {
let path = temp_upload_file("uri path.txt", b"upload");
let uri = format!(
"x-special/gnome-copied-files\ncopy\nfile://{}",
path.to_string_lossy().replace(' ', "%20")
);
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
assert!(handle_paste(&mut state, &uri));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(state.pending_composer_attachments(), &[]);
assert_eq!(
command,
Some(AppCommand::SendMessage {
channel_id: Id::new(11),
content: String::new(),
reply_to: None,
attachments: vec![crate::discord::MessageAttachmentUpload {
path: path.clone(),
filename: "uri path.txt".to_owned(),
size_bytes: 6,
}],
})
);
remove_temp_upload_file(&path);
}
#[test]
fn ctrl_backspace_removes_last_pending_attachment() {
let first = temp_upload_file("first.txt", b"first");
let second = temp_upload_file("second.txt", b"second");
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
handle_key(&mut state, char_key('x'));
let pasted = format!(
"{}\n{}",
first.to_str().expect("temp path is valid unicode"),
second.to_str().expect("temp path is valid unicode")
);
assert!(handle_paste(&mut state, &pasted));
handle_key(
&mut state,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL),
);
assert_eq!(state.composer_input(), "x");
assert_eq!(state.pending_composer_attachments().len(), 1);
assert_eq!(state.pending_composer_attachments()[0].path, first);
remove_temp_upload_file(&first);
remove_temp_upload_file(&second);
}
#[test]
fn paste_file_path_while_editing_inserts_text_instead_of_attachment() {
let path = temp_upload_file("edit paste.txt", b"no attach");
let mut state = state_with_own_message();
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('e'));
assert!(handle_paste(
&mut state,
path.to_str().expect("temp path is valid unicode")
));
assert!(state.pending_composer_attachments().is_empty());
assert!(
state
.composer_input()
.contains(path.to_str().expect("temp path is valid unicode"))
);
remove_temp_upload_file(&path);
}
#[test]
fn paste_is_ignored_when_not_composing() {
let mut state = state_with_channel_tree();
assert!(!handle_paste(&mut state, "hello"));
assert_eq!(state.composer_input(), "");
}
#[test]
fn enter_submits_multiline_composer() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
handle_key(&mut state, char_key('h'));
handle_key(&mut state, shift_enter());
handle_key(&mut state, char_key('i'));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert!(state.is_composing());
assert_eq!(state.composer_input(), "");
assert_eq!(
command,
Some(crate::discord::AppCommand::SendMessage {
channel_id: Id::new(11),
content: "h\ni".to_owned(),
reply_to: None,
attachments: Vec::new(),
})
);
}
#[test]
fn enter_confirms_emoji_picker_before_submit() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
for ch in ":heart".chars() {
handle_key(&mut state, char_key(ch));
}
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(command, None);
assert_eq!(state.composer_input(), "❤️ ");
assert!(state.is_composing());
}
#[test]
fn enter_submits_no_match_emoji_query_without_hidden_picker() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
for ch in ":qq".chars() {
handle_key(&mut state, char_key(ch));
}
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
command,
Some(AppCommand::SendMessage {
channel_id: Id::new(11),
content: ":qq".to_owned(),
reply_to: None,
attachments: Vec::new(),
})
);
}
#[test]
fn tab_confirms_emoji_picker() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
for ch in ":heart".chars() {
handle_key(&mut state, char_key(ch));
}
handle_key(&mut state, key(KeyCode::Tab));
assert_eq!(state.composer_input(), "❤️ ");
}
#[test]
fn emoji_picker_escape_returns_to_composer_text() {
let mut state = state_with_channel_tree();
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('i'));
for ch in ":he".chars() {
handle_key(&mut state, char_key(ch));
}
handle_key(&mut state, key(KeyCode::Esc));
assert_eq!(state.composer_input(), ":he");
assert!(state.is_composing());
}
#[test]
fn options_popup_toggles_selected_setting() {
let mut state = state_with_messages(1);
state.open_options_popup();
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
assert!(state.is_options_popup_open());
assert!(!state.display_options().show_avatars);
assert_eq!(
state.take_display_options_save_request(),
Some(state.display_options())
);
}
#[test]
fn options_popup_cycles_image_preview_quality() {
let mut state = state_with_messages(1);
state.open_options_popup();
for _ in 0..3 {
handle_key(&mut state, key(KeyCode::Down));
}
handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
state.display_options().image_preview_quality,
ImagePreviewQualityPreset::High
);
assert_eq!(
state.take_display_options_save_request(),
Some(state.display_options())
);
}
#[test]
fn options_popup_esc_closes_popup() {
let mut state = state_with_messages(1);
state.open_options_popup();
handle_key(&mut state, key(KeyCode::Esc));
assert!(!state.is_options_popup_open());
}
#[test]
fn uppercase_h_l_scroll_focused_side_panes_horizontally() {
let mut state = state_with_messages(1);
handle_key(&mut state, char_key('L'));
assert_eq!(state.guild_horizontal_scroll(), 1);
handle_key(&mut state, char_key('H'));
handle_key(&mut state, char_key('H'));
assert_eq!(state.guild_horizontal_scroll(), 0);
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, char_key('L'));
assert_eq!(state.channel_horizontal_scroll(), 1);
let mut state = state_with_members(1);
state.focus_pane(FocusPane::Members);
handle_key(&mut state, char_key('L'));
assert_eq!(state.member_horizontal_scroll(), 1);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, char_key('L'));
assert_eq!(state.member_horizontal_scroll(), 1);
}
#[test]
fn enter_opens_message_action_menu_and_space_opens_leader() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
assert!(state.is_message_action_menu_open());
state.close_message_action_menu();
handle_key(&mut state, char_key(' '));
assert!(state.is_leader_active());
assert!(!state.is_message_action_menu_open());
}
#[test]
fn mouse_click_selects_message_action_row() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
let (column, row) = message_action_row_point(1);
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
));
assert_eq!(state.selected_message_action_index(), Some(1));
}
#[test]
fn mouse_double_click_activates_message_action_row_like_enter() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
let mut clicks = MouseClickTracker::default();
let (column, row) = message_action_row_point(1);
handle_mouse_event(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
&mut clicks,
);
handle_mouse_event(
&mut state,
mouse(MouseEventKind::Up(MouseButton::Left), column, row),
dashboard_area(),
&mut clicks,
);
let second = handle_mouse_event(
&mut state,
mouse(MouseEventKind::Down(MouseButton::Left), column, row),
dashboard_area(),
&mut clicks,
);
assert_eq!(second.command, None);
assert!(!state.is_message_action_menu_open());
assert!(state.is_emoji_reaction_picker_open());
}
#[test]
fn mouse_wheel_moves_message_action_selection() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
let (column, row) = message_action_row_point(0);
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::ScrollDown, column, row),
dashboard_area(),
));
assert_eq!(state.selected_message_action_index(), Some(1));
assert!(handle_mouse(
&mut state,
mouse(MouseEventKind::ScrollUp, column, row),
dashboard_area(),
));
assert_eq!(state.selected_message_action_index(), Some(0));
}
#[test]
fn a_key_no_longer_opens_actions_directly() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, char_key('a'));
assert!(!state.is_message_action_menu_open());
assert!(!state.is_channel_action_menu_open());
}
#[test]
fn leader_a_p_loads_pinned_messages_from_channel_pane() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('a'));
let command = handle_key(&mut state, char_key('p'));
assert_eq!(
command,
Some(AppCommand::LoadPinnedMessages {
channel_id: Id::new(2),
})
);
assert!(state.is_pinned_message_view());
assert!(!state.is_leader_active());
}
#[test]
fn leader_a_opens_selected_channel_actions_from_channel_pane() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Channels);
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('a'));
assert!(state.is_leader_action_mode());
assert!(state.is_channel_action_menu_open());
}
#[test]
fn leader_a_opens_message_actions_from_message_pane() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('a'));
assert!(state.is_leader_action_mode());
assert!(state.is_message_action_menu_open());
assert!(!state.is_channel_action_menu_open());
}
#[test]
fn leader_a_opens_server_actions_from_guild_pane() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Guilds);
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('a'));
assert!(state.is_leader_action_mode());
assert!(state.is_guild_action_menu_open());
}
#[test]
fn leader_a_opens_member_actions_from_member_pane() {
let mut state = state_with_members(1);
state.focus_pane(FocusPane::Members);
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('a'));
assert!(state.is_leader_action_mode());
assert!(state.is_member_action_menu_open());
let actions = state.selected_member_action_items();
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].label, "Show profile");
assert!(actions[0].enabled);
}
#[test]
fn leader_a_p_opens_member_profile() {
let mut state = state_with_members(1);
state.focus_pane(FocusPane::Members);
handle_key(&mut state, char_key(' '));
handle_key(&mut state, char_key('a'));
let command = handle_key(&mut state, char_key('p'));
assert_eq!(
command,
Some(AppCommand::LoadUserProfile {
user_id: Id::new(1),
guild_id: Some(Id::new(1)),
})
);
assert!(state.is_user_profile_popup_open());
assert!(!state.is_leader_active());
}
#[test]
fn enter_opens_selected_forum_post_from_message_pane() {
let mut state = state_with_forum_channel_posts();
state.focus_pane(FocusPane::Messages);
state.move_down();
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(state.selected_channel_id(), Some(Id::new(30)));
assert_eq!(
command,
Some(AppCommand::SubscribeGuildChannel {
guild_id: Id::new(1),
channel_id: Id::new(30),
})
);
}
#[test]
fn message_action_menu_navigation_is_modal() {
let mut state = state_with_messages(2);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, key(KeyCode::Down));
assert_eq!(state.selected_message(), 1);
assert_eq!(
state.selected_message_action().map(|action| action.kind),
Some(MessageActionKind::AddReaction)
);
handle_key(&mut state, key(KeyCode::Esc));
assert!(!state.is_message_action_menu_open());
}
#[test]
fn esc_returns_from_message_opened_thread() {
let mut state = state_with_thread_created_message();
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(state.selected_channel_id(), Some(Id::new(10)));
handle_key(&mut state, key(KeyCode::Esc));
assert_eq!(state.selected_channel_id(), Some(Id::new(2)));
assert_eq!(state.focus(), FocusPane::Messages);
}
#[test]
fn esc_returns_from_pinned_message_view() {
let mut state = state_with_messages(3);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Up));
let expected_selected = state.selected_message();
state.push_event(AppEvent::MessagePinnedUpdate {
channel_id: Id::new(2),
message_id: Id::new(2),
pinned: true,
});
state.enter_pinned_message_view(Id::new(2));
assert!(state.is_pinned_message_view());
handle_key(&mut state, key(KeyCode::Esc));
assert!(!state.is_pinned_message_view());
assert_eq!(state.selected_channel_id(), Some(Id::new(2)));
assert_eq!(state.selected_message(), expected_selected);
assert_eq!(state.focus(), FocusPane::Messages);
}
#[test]
fn esc_closes_modal_before_returning_from_opened_thread() {
let mut state = state_with_thread_created_message();
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(state.selected_channel_id(), Some(Id::new(10)));
handle_key(&mut state, char_key('`'));
handle_key(&mut state, key(KeyCode::Esc));
assert!(!state.is_debug_log_popup_open());
assert_eq!(state.selected_channel_id(), Some(Id::new(10)));
handle_key(&mut state, key(KeyCode::Esc));
assert_eq!(state.selected_channel_id(), Some(Id::new(2)));
}
#[test]
fn message_action_menu_reply_opens_composer() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(command, None);
assert!(!state.is_message_action_menu_open());
assert!(state.is_composing());
assert_eq!(state.composer_input(), "");
handle_key(&mut state, char_key('h'));
handle_key(&mut state, char_key('i'));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
command,
Some(AppCommand::SendMessage {
channel_id: Id::new(2),
content: "hi".to_owned(),
reply_to: Some(Id::new(1)),
attachments: Vec::new(),
})
);
}
#[test]
fn message_action_shortcuts_edit_and_delete_own_message() {
let mut edit_state = state_with_own_message();
edit_state.focus_pane(FocusPane::Messages);
handle_key(&mut edit_state, key(KeyCode::Enter));
let command = handle_key(&mut edit_state, char_key('e'));
assert_eq!(command, None);
assert!(!edit_state.is_message_action_menu_open());
assert!(edit_state.is_composing());
let mut delete_state = state_with_own_message();
delete_state.focus_pane(FocusPane::Messages);
handle_key(&mut delete_state, key(KeyCode::Enter));
let command = handle_key(&mut delete_state, char_key('d'));
assert_eq!(
command,
Some(AppCommand::DeleteMessage {
channel_id: Id::new(2),
message_id: Id::new(1),
})
);
assert!(!delete_state.is_message_action_menu_open());
}
#[test]
fn message_action_shortcuts_ignore_control_modified_keys() {
let mut state = state_with_own_message();
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
let command = handle_key(&mut state, ctrl_key('d'));
assert_eq!(command, None);
assert!(state.is_message_action_menu_open());
assert_eq!(state.selected_message_action_index(), Some(0));
}
#[test]
fn canceling_reply_composer_clears_reply_target() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, key(KeyCode::Esc));
handle_key(&mut state, char_key('i'));
handle_key(&mut state, char_key('n'));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
command,
Some(AppCommand::SendMessage {
channel_id: Id::new(2),
content: "n".to_owned(),
reply_to: None,
attachments: Vec::new(),
})
);
}
#[test]
fn message_action_menu_view_image_opens_viewer_and_esc_closes_nested_menu_first() {
let mut state = state_with_image_message();
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, key(KeyCode::Down));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(command, None);
assert!(!state.is_message_action_menu_open());
assert!(state.is_image_viewer_open());
assert_eq!(
state.selected_image_viewer_item().map(|item| item.index),
Some(1)
);
handle_key(&mut state, char_key('l'));
assert_eq!(
state.selected_image_viewer_item().map(|item| item.index),
Some(2)
);
handle_key(&mut state, char_key('j'));
assert_eq!(
state.selected_image_viewer_item().map(|item| item.index),
Some(2)
);
handle_key(&mut state, char_key('k'));
assert_eq!(
state.selected_image_viewer_item().map(|item| item.index),
Some(2)
);
handle_key(&mut state, key(KeyCode::Left));
assert_eq!(
state.selected_image_viewer_item().map(|item| item.index),
Some(1)
);
handle_key(&mut state, key(KeyCode::Right));
assert_eq!(
state.selected_image_viewer_item().map(|item| item.index),
Some(2)
);
handle_key(&mut state, char_key('h'));
assert_eq!(
state.selected_image_viewer_item().map(|item| item.index),
Some(1)
);
handle_key(&mut state, key(KeyCode::Enter));
assert!(state.is_image_viewer_action_menu_open());
handle_key(&mut state, key(KeyCode::Esc));
assert!(!state.is_image_viewer_action_menu_open());
assert!(state.is_image_viewer_open());
handle_key(&mut state, key(KeyCode::Esc));
assert!(!state.is_image_viewer_open());
}
#[test]
fn image_viewer_action_shortcut_downloads_image() {
let mut state = state_with_image_message();
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('v'));
handle_key(&mut state, key(KeyCode::Enter));
let command = handle_key(&mut state, char_key('d'));
assert_eq!(
command,
Some(AppCommand::DownloadAttachment {
url: "https://cdn.discordapp.com/cat.png".to_owned(),
filename: "cat.png".to_owned(),
})
);
assert!(!state.is_image_viewer_action_menu_open());
}
#[test]
fn message_action_menu_add_reaction_opens_emoji_picker() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, key(KeyCode::Down));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(command, None);
assert!(!state.is_message_action_menu_open());
assert!(state.is_emoji_reaction_picker_open());
assert_eq!(
state.selected_emoji_reaction().map(|item| item.emoji),
Some(ReactionEmoji::Unicode("👍".to_owned()))
);
}
#[test]
fn emoji_picker_selection_returns_reaction_command() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
open_emoji_picker(&mut state);
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, key(KeyCode::Down));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
command,
Some(AppCommand::AddReaction {
channel_id: Id::new(2),
message_id: Id::new(1),
emoji: ReactionEmoji::Unicode("🎉".to_owned()),
})
);
assert!(!state.is_emoji_reaction_picker_open());
}
#[test]
fn emoji_picker_selection_removes_existing_own_reaction() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
state.push_event(AppEvent::CurrentUserReactionAdd {
channel_id: Id::new(2),
message_id: Id::new(1),
emoji: ReactionEmoji::Unicode("👍".to_owned()),
});
open_emoji_picker(&mut state);
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
command,
Some(AppCommand::RemoveReaction {
channel_id: Id::new(2),
message_id: Id::new(1),
emoji: ReactionEmoji::Unicode("👍".to_owned()),
})
);
assert!(!state.is_emoji_reaction_picker_open());
}
#[test]
fn emoji_picker_number_shortcut_selects_reaction() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
open_emoji_picker(&mut state);
let command = handle_key(&mut state, char_key('2'));
assert_eq!(
command,
Some(AppCommand::AddReaction {
channel_id: Id::new(2),
message_id: Id::new(1),
emoji: ReactionEmoji::Unicode("❤️".to_owned()),
})
);
assert!(!state.is_emoji_reaction_picker_open());
}
#[test]
fn emoji_picker_slash_filter_matches_name_and_implementation_case_insensitively() {
let mut state = state_with_custom_emoji_message();
state.focus_pane(FocusPane::Messages);
open_emoji_picker(&mut state);
handle_key(&mut state, char_key('/'));
handle_key(&mut state, char_key('T'));
handle_key(&mut state, char_key('s'));
assert_eq!(state.emoji_reaction_filter(), Some("Ts"));
assert_eq!(
state.selected_emoji_reaction().map(|item| item.emoji),
Some(ReactionEmoji::Custom {
id: Id::new(51),
name: Some("this".to_owned()),
animated: false,
})
);
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
command,
Some(AppCommand::AddReaction {
channel_id: Id::new(2),
message_id: Id::new(1),
emoji: ReactionEmoji::Custom {
id: Id::new(51),
name: Some("this".to_owned()),
animated: false,
},
})
);
}
#[test]
fn emoji_picker_filter_treats_vim_keys_as_text() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
open_emoji_picker(&mut state);
handle_key(&mut state, char_key('/'));
handle_key(&mut state, char_key('j'));
assert_eq!(state.emoji_reaction_filter(), Some("j"));
assert_ne!(
state.selected_emoji_reaction().map(|item| item.emoji),
Some(ReactionEmoji::Unicode("❤️".to_owned()))
);
}
#[test]
fn emoji_picker_filter_matches_remaining_unicode_emojis() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
open_emoji_picker(&mut state);
handle_key(&mut state, char_key('/'));
for value in "rocket".chars() {
handle_key(&mut state, char_key(value));
}
assert_eq!(state.emoji_reaction_filter(), Some("rocket"));
assert_eq!(
state.selected_emoji_reaction().map(|item| item.emoji),
Some(ReactionEmoji::Unicode("🚀".to_owned()))
);
}
#[test]
fn emoji_picker_selection_returns_custom_reaction_command() {
let mut state = state_with_custom_emoji_message();
state.focus_pane(FocusPane::Messages);
open_emoji_picker(&mut state);
for _ in 0..8 {
handle_key(&mut state, key(KeyCode::Down));
}
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
command,
Some(AppCommand::AddReaction {
channel_id: Id::new(2),
message_id: Id::new(1),
emoji: ReactionEmoji::Custom {
id: Id::new(50),
name: Some("party".to_owned()),
animated: false,
},
})
);
}
#[test]
fn emoji_picker_vim_and_arrow_keys_move_selection() {
let mut state = state_with_messages(1);
state.focus_pane(FocusPane::Messages);
open_emoji_picker(&mut state);
handle_key(&mut state, char_key('j'));
assert_eq!(
state.selected_emoji_reaction().map(|item| item.emoji),
Some(ReactionEmoji::Unicode("❤️".to_owned()))
);
handle_key(&mut state, char_key('j'));
assert_eq!(
state.selected_emoji_reaction().map(|item| item.emoji),
Some(ReactionEmoji::Unicode("😂".to_owned()))
);
handle_key(&mut state, char_key('k'));
assert_eq!(
state.selected_emoji_reaction().map(|item| item.emoji),
Some(ReactionEmoji::Unicode("❤️".to_owned()))
);
handle_key(&mut state, key(KeyCode::Up));
assert_eq!(
state.selected_emoji_reaction().map(|item| item.emoji),
Some(ReactionEmoji::Unicode("👍".to_owned()))
);
handle_key(&mut state, ctrl_key('n'));
assert_eq!(
state.selected_emoji_reaction().map(|item| item.emoji),
Some(ReactionEmoji::Unicode("❤️".to_owned()))
);
handle_key(&mut state, ctrl_key('p'));
assert_eq!(
state.selected_emoji_reaction().map(|item| item.emoji),
Some(ReactionEmoji::Unicode("👍".to_owned()))
);
}
#[test]
fn escape_closes_emoji_picker_without_reacting() {
let mut state = state_with_messages(2);
state.focus_pane(FocusPane::Messages);
open_emoji_picker(&mut state);
handle_key(&mut state, key(KeyCode::Down));
let command = handle_key(&mut state, key(KeyCode::Esc));
assert_eq!(command, None);
assert!(!state.is_emoji_reaction_picker_open());
assert_eq!(state.selected_message(), 1);
}
#[test]
fn reaction_users_popup_is_modal_and_escape_closes_it() {
let mut state = state_with_messages(2);
state.focus_pane(FocusPane::Messages);
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(10),
display_name: "neo".to_owned(),
}],
}],
});
handle_key(&mut state, key(KeyCode::Down));
assert_eq!(state.selected_message(), 1);
assert!(state.is_reaction_users_popup_open());
assert_eq!(
state.reaction_users_popup().map(|popup| popup.scroll()),
Some(1)
);
let command = handle_key(&mut state, key(KeyCode::Esc));
assert_eq!(command, None);
assert!(!state.is_reaction_users_popup_open());
}
#[test]
fn multiselect_poll_picker_toggles_and_submits_selected_answers() {
let mut state = state_with_multiselect_poll();
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
for _ in 0..5 {
handle_key(&mut state, key(KeyCode::Down));
}
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(command, None);
assert!(state.is_poll_vote_picker_open());
handle_key(&mut state, key(KeyCode::Down));
handle_key(&mut state, char_key(' '));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
command,
Some(AppCommand::VotePoll {
channel_id: Id::new(2),
message_id: Id::new(1),
answer_ids: vec![1, 2],
})
);
assert!(!state.is_poll_vote_picker_open());
}
#[test]
fn poll_picker_number_shortcut_toggles_answer() {
let mut state = state_with_multiselect_poll();
state.focus_pane(FocusPane::Messages);
handle_key(&mut state, key(KeyCode::Enter));
handle_key(&mut state, char_key('c'));
handle_key(&mut state, char_key('2'));
let command = handle_key(&mut state, key(KeyCode::Enter));
assert_eq!(
command,
Some(AppCommand::VotePoll {
channel_id: Id::new(2),
message_id: Id::new(1),
answer_ids: vec![1, 2],
})
);
}
fn state_with_folder() -> DashboardState {
let first_guild = Id::new(1);
let second_guild = Id::new(2);
let mut state = DashboardState::new();
for (guild_id, name) in [(first_guild, "first"), (second_guild, "second")] {
state.push_event(AppEvent::GuildCreate {
guild_id,
name: name.to_owned(),
member_count: None,
channels: Vec::new(),
members: Vec::new(),
presences: Vec::new(),
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
}
state.push_event(AppEvent::GuildFoldersUpdate {
folders: vec![GuildFolder {
id: Some(42),
name: Some("folder".to_owned()),
color: None,
guild_ids: vec![first_guild, second_guild],
}],
});
state
}
fn assert_selected_folder_collapsed(state: &DashboardState, expected: bool) {
let entries = state.guild_pane_entries();
assert!(matches!(
entries[1],
GuildPaneEntry::FolderHeader { collapsed, .. } if collapsed == expected
));
}
fn assert_selected_channel_category_collapsed(state: &DashboardState, expected: bool) {
let entries = state.channel_pane_entries();
assert!(matches!(
entries[0],
ChannelPaneEntry::CategoryHeader { collapsed, .. } if collapsed == expected
));
}
#[test]
fn h_l_and_left_right_open_close_tree_nodes() {
let mut guild_state = state_with_folder();
guild_state.focus_pane(FocusPane::Guilds);
handle_key(&mut guild_state, char_key('h'));
assert_selected_folder_collapsed(&guild_state, true);
handle_key(&mut guild_state, char_key('l'));
assert_selected_folder_collapsed(&guild_state, false);
handle_key(&mut guild_state, key(KeyCode::Left));
assert_selected_folder_collapsed(&guild_state, true);
handle_key(&mut guild_state, key(KeyCode::Right));
assert_selected_folder_collapsed(&guild_state, false);
let mut channel_state = state_with_channel_tree();
channel_state.focus_pane(FocusPane::Channels);
handle_key(&mut channel_state, char_key('h'));
assert_selected_channel_category_collapsed(&channel_state, true);
handle_key(&mut channel_state, char_key('l'));
assert_selected_channel_category_collapsed(&channel_state, false);
handle_key(&mut channel_state, key(KeyCode::Left));
assert_selected_channel_category_collapsed(&channel_state, true);
handle_key(&mut channel_state, key(KeyCode::Right));
assert_selected_channel_category_collapsed(&channel_state, false);
}
fn state_with_channel_tree() -> DashboardState {
let guild_id = Id::new(1);
let category_id = Id::new(10);
let general_id = Id::new(11);
let random_id = Id::new(12);
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: category_id,
parent_id: None,
position: Some(0),
last_message_id: None,
name: "Text Channels".to_owned(),
kind: "category".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
},
ChannelInfo {
guild_id: Some(guild_id),
channel_id: general_id,
parent_id: Some(category_id),
position: Some(0),
last_message_id: None,
name: "general".to_owned(),
kind: "text".to_owned(),
message_count: None,
total_message_sent: None,
thread_archived: None,
thread_locked: None,
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
},
ChannelInfo {
guild_id: Some(guild_id),
channel_id: random_id,
parent_id: Some(category_id),
position: Some(1),
last_message_id: None,
name: "random".to_owned(),
kind: "text".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
}
fn state_with_direct_message(kind: &str) -> DashboardState {
let channel_id = Id::new(20);
let mut state = DashboardState::new();
state.push_event(AppEvent::ChannelUpsert(ChannelInfo {
guild_id: None,
channel_id,
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: Some(vec![ChannelRecipientInfo {
user_id: Id::new(30),
display_name: "alice".to_owned(),
username: None,
is_bot: false,
avatar_url: None,
status: Some(PresenceStatus::Online),
}]),
permission_overwrites: Vec::new(),
}));
state.confirm_selected_guild();
state
}
fn state_with_messages(count: u64) -> 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();
for id in 1..=count {
state.push_event(AppEvent::MessageCreate {
guild_id: Some(guild_id),
channel_id,
message_id: Id::new(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(format!("msg {id}")),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
}
state
}
fn state_with_own_message() -> DashboardState {
let mut state = state_with_messages(1);
state.push_event(AppEvent::Ready {
user: "neo".to_owned(),
user_id: Some(Id::new(99)),
});
state
}
fn state_with_members(count: u64) -> DashboardState {
let guild_id = Id::new(1);
let channel_id = Id::new(2);
let mut state = DashboardState::new();
let members = (1..=count)
.map(|id| MemberInfo {
user_id: Id::new(id),
display_name: format!("member {id}"),
username: None,
is_bot: false,
avatar_url: None,
role_ids: Vec::new(),
})
.collect();
let presences = (1..=count)
.map(|id| (Id::new(id), PresenceStatus::Online))
.collect();
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,
presences,
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
state.confirm_selected_guild();
state
}
fn state_with_thread_created_message() -> DashboardState {
let guild_id = Id::new(1);
let parent_id = Id::new(2);
let thread_id = Id::new(10);
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,
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(),
},
ChannelInfo {
guild_id: Some(guild_id),
channel_id: thread_id,
parent_id: Some(parent_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(),
},
],
members: Vec::new(),
presences: Vec::new(),
roles: Vec::new(),
emojis: Vec::new(),
owner_id: None,
});
state.confirm_selected_guild();
state.confirm_selected_channel();
state.push_event(AppEvent::MessageCreate {
guild_id: Some(guild_id),
channel_id: parent_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::new(18),
reference: Some(MessageReferenceInfo {
guild_id: Some(guild_id),
channel_id: Some(thread_id),
message_id: None,
}),
reply: None,
poll: None,
content: Some("release notes".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
state
}
fn state_with_multiselect_poll() -> DashboardState {
let mut state = state_with_messages(1);
state.push_event(AppEvent::MessageCreate {
guild_id: Some(Id::new(1)),
channel_id: Id::new(2),
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: Some(PollInfo {
question: "Pick foods".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: true,
results_finalized: Some(false),
total_votes: Some(3),
}),
content: Some("msg 1".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
state
}
fn state_with_custom_emoji_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![
CustomEmojiInfo {
id: Id::new(50),
name: "party".to_owned(),
animated: false,
available: true,
},
CustomEmojiInfo {
id: Id::new(51),
name: "this".to_owned(),
animated: false,
available: true,
},
],
owner_id: None,
});
state.confirm_selected_guild();
state.confirm_selected_channel();
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("msg 1".to_owned()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: Vec::new(),
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
state
}
fn state_with_forum_channel_posts() -> 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: Some(0),
last_message_id: None,
name: "announcements".to_owned(),
kind: "forum".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.push_event(AppEvent::ForumPostsLoaded {
channel_id: forum_id,
archive_state: crate::discord::ForumPostArchiveState::Active,
offset: 0,
next_offset: 2,
posts: vec![
ChannelInfo {
guild_id: Some(guild_id),
channel_id: Id::new(31),
parent_id: Some(forum_id),
position: Some(1),
last_message_id: None,
name: "release notes".to_owned(),
kind: "GuildPublicThread".to_owned(),
message_count: Some(2),
total_message_sent: Some(2),
thread_archived: Some(false),
thread_locked: Some(false),
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
},
ChannelInfo {
guild_id: Some(guild_id),
channel_id: Id::new(30),
parent_id: Some(forum_id),
position: Some(0),
last_message_id: None,
name: "welcome".to_owned(),
kind: "GuildPublicThread".to_owned(),
message_count: Some(1),
total_message_sent: Some(1),
thread_archived: Some(false),
thread_locked: Some(false),
thread_pinned: None,
recipients: None,
permission_overwrites: Vec::new(),
},
],
preview_messages: Vec::new(),
has_more: false,
});
state
}
fn state_with_image_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.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(String::new()),
sticker_names: Vec::new(),
mentions: Vec::new(),
attachments: vec![
crate::discord::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?format=webp&width=160&height=90"
.to_owned(),
content_type: Some("image/png".to_owned()),
size: 2048,
width: Some(640),
height: Some(480),
description: None,
},
crate::discord::AttachmentInfo {
id: Id::new(4),
filename: "dog.png".to_owned(),
url: "https://cdn.discordapp.com/dog.png".to_owned(),
proxy_url: "https://media.discordapp.net/dog.png".to_owned(),
content_type: Some("image/png".to_owned()),
size: 2048,
width: Some(640),
height: Some(480),
description: None,
},
],
embeds: Vec::new(),
forwarded_snapshots: Vec::new(),
});
state
}
fn open_emoji_picker(state: &mut DashboardState) {
handle_key(state, key(KeyCode::Enter));
handle_key(state, key(KeyCode::Down));
handle_key(state, key(KeyCode::Enter));
assert!(state.is_emoji_reaction_picker_open());
}