use std::fs;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
use ratatui::style::{Color, Modifier};
use crate::domain::subscriptions::SubscriptionCategory;
use crate::infra::bootstrap::BootstrapState;
use crate::infra::config::{IMAP_INBOX_MAILBOX, RuntimeConfig, UiKeymap};
use crate::infra::db;
use crate::infra::db::DatabaseState;
use crate::infra::mail_parser;
use crate::infra::mail_store::{self, IncomingMail, SyncBatch, ThreadRow};
use crate::infra::reply_store::{self, ReplySendStatus};
use crate::infra::sendmail::{SendOutcome, SendRequest, SendStatus};
use crate::infra::ui_state::{self, UiState};
use super::palette::run_palette_sync;
use super::preview::preview_warning_message;
use super::reply::ReplyIdentity;
use super::{
AppState, CodeEditMode, CodePaneFocus, ExternalEditorProcessResult, LoopAction,
MIN_MAIL_PREVIEW_WIDTH, MIN_MAIL_SUBSCRIPTIONS_WIDTH, MY_INBOX_LABEL, MailPaneLayout,
ManualSyncOrigin, ManualSyncRequestOutcome, ManualSyncState, Pane, ReplyEditMode, ReplySection,
StartupSyncEvent, StartupSyncMailboxStatus, StartupSyncState, SubscriptionItem, UiPage,
catch_sync_panic, code_edit_cursor_position, draw, extract_mail_body_preview,
extract_mail_preview, handle_key_event, is_palette_open_shortcut, is_palette_toggle,
load_source_file_preview, mail_page_panes, matching_commands, pick_external_editor,
resolve_palette_local_workdir, run_external_editor_session_with, sanitize_inline_ui_text,
subscription_line, thread_line,
};
fn temp_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
let path = std::env::temp_dir().join(format!("criew-ui-{label}-{nonce}"));
fs::create_dir_all(&path).expect("create temp dir");
path
}
fn sample_thread(subject: &str, message_id: &str, depth: u16) -> ThreadRow {
ThreadRow {
thread_id: 1,
mail_id: 1,
depth,
subject: subject.to_string(),
from_addr: "alice@example.com".to_string(),
message_id: message_id.to_string(),
in_reply_to: None,
date: None,
raw_path: None,
}
}
fn sample_thread_with_raw(
subject: &str,
message_id: &str,
depth: u16,
raw_path: PathBuf,
) -> ThreadRow {
ThreadRow {
thread_id: 1,
mail_id: 1,
depth,
subject: subject.to_string(),
from_addr: "alice@example.com".to_string(),
message_id: message_id.to_string(),
in_reply_to: None,
date: None,
raw_path: Some(raw_path),
}
}
fn sample_threads(count: usize) -> Vec<ThreadRow> {
(0..count)
.map(|index| {
let subject = format!("t{index}");
let message_id = format!("{index}@example.com");
sample_thread(&subject, &message_id, index as u16)
})
.collect()
}
fn sample_thread_in_thread(
thread_id: i64,
mail_id: i64,
subject: &str,
message_id: &str,
depth: u16,
) -> ThreadRow {
ThreadRow {
thread_id,
mail_id,
depth,
subject: subject.to_string(),
from_addr: "alice@example.com".to_string(),
message_id: message_id.to_string(),
in_reply_to: None,
date: None,
raw_path: None,
}
}
fn rendered_row_text(terminal: &Terminal<TestBackend>, row: u16) -> String {
let buffer = terminal.backend().buffer();
let width = buffer.area().width as usize;
let start = row as usize * width;
let end = start + width;
buffer.content()[start..end]
.iter()
.map(|cell| cell.symbol())
.collect::<String>()
}
fn rendered_cell_style_for_substring(
terminal: &Terminal<TestBackend>,
needle: &str,
) -> Option<(Color, Color, Modifier)> {
let buffer = terminal.backend().buffer();
let width = buffer.area().width as usize;
for row in 0..buffer.area().height {
let row_text = rendered_row_text(terminal, row);
if let Some(column) = row_text.find(needle) {
let cell = &buffer.content()[row as usize * width + column];
return Some((cell.fg, cell.bg, cell.modifier));
}
}
None
}
fn test_runtime_in(root: PathBuf) -> RuntimeConfig {
RuntimeConfig {
config_path: root.join("config.toml"),
data_dir: root.join("data"),
database_path: root.join("data/criew.db"),
raw_mail_dir: root.join("data/raw"),
patch_dir: root.join("data/patches"),
log_dir: root.join("data/logs"),
b4_path: None,
log_filter: "info".to_string(),
source_mailbox: "inbox".to_string(),
imap: crate::infra::config::ImapConfig::default(),
lore_base_url: "https://lore.kernel.org".to_string(),
startup_sync: true,
ui_keymap: UiKeymap::Default,
ui_keymap_base: crate::infra::config::UiKeymapBase::Default,
ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(),
inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS,
kernel_trees: Vec::new(),
}
}
fn subscription_category_rank(category: Option<SubscriptionCategory>) -> u8 {
category.map_or(0, SubscriptionCategory::sort_rank)
}
fn subscription_sort_key(item: &SubscriptionItem) -> (u8, &str, &str) {
(
subscription_category_rank(item.category),
item.label.as_str(),
item.mailbox.as_str(),
)
}
fn test_runtime() -> RuntimeConfig {
test_runtime_in(PathBuf::from("/tmp/criew-ui-test"))
}
fn test_runtime_with_kernel_tree(tree: PathBuf) -> RuntimeConfig {
let mut runtime = test_runtime();
runtime.kernel_trees = vec![tree];
runtime
}
fn test_runtime_with_imap_in(root: PathBuf) -> RuntimeConfig {
let mut runtime = test_runtime_in(root);
runtime.imap = crate::infra::config::ImapConfig {
email: Some("me@example.com".to_string()),
user: Some("imap-user".to_string()),
pass: Some("imap-pass".to_string()),
server: Some("imap.example.com".to_string()),
server_port: Some(993),
encryption: Some(crate::infra::config::ImapEncryption::Tls),
proxy: None,
};
runtime
}
fn test_runtime_with_imap() -> RuntimeConfig {
test_runtime_with_imap_in(PathBuf::from("/tmp/criew-ui-test"))
}
fn seed_mailbox_thread(db_path: &Path, mailbox: &str, uid: u32, message_id: &str, subject: &str) {
fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent");
db::initialize(db_path).expect("initialize db");
let batch = SyncBatch {
mailbox: mailbox.to_string(),
uidvalidity: 1,
highest_uid: uid,
highest_modseq: Some(uid as u64),
mails: vec![IncomingMail {
mailbox: mailbox.to_string(),
uid,
modseq: Some(uid as u64),
flags: vec!["Seen".to_string()],
raw_path: PathBuf::from(format!("/tmp/{mailbox}-{uid}.eml")),
parsed: mail_parser::parse_headers(
format!(
"Message-ID: <{message_id}>\nSubject: {subject}\nFrom: Alice <alice@example.com>\n\nbody\n"
)
.as_bytes(),
format!("synthetic-{mailbox}-{uid}@local"),
),
}],
};
mail_store::apply_sync_batch(db_path, batch).expect("apply mailbox sync batch");
}
fn startup_sync_state(mailboxes: &[(&str, StartupSyncMailboxStatus)]) -> StartupSyncState {
let (_sender, receiver) = mpsc::channel();
StartupSyncState {
receiver,
mailbox_order: mailboxes
.iter()
.map(|(mailbox, _)| (*mailbox).to_string())
.collect(),
mailboxes: mailboxes
.iter()
.map(|(mailbox, status)| ((*mailbox).to_string(), *status))
.collect(),
total: mailboxes.len(),
completed: mailboxes
.iter()
.filter(|(_, status)| {
matches!(
status,
StartupSyncMailboxStatus::Finished | StartupSyncMailboxStatus::Failed
)
})
.count(),
succeeded: mailboxes
.iter()
.filter(|(_, status)| matches!(status, StartupSyncMailboxStatus::Finished))
.count(),
failed: mailboxes
.iter()
.filter(|(_, status)| matches!(status, StartupSyncMailboxStatus::Failed))
.count(),
}
}
fn manual_sync_state(mailboxes: &[(&str, StartupSyncMailboxStatus)]) -> ManualSyncState {
let (_sender, receiver) = mpsc::channel();
ManualSyncState {
receiver,
mailbox_order: mailboxes
.iter()
.map(|(mailbox, _)| (*mailbox).to_string())
.collect(),
mailboxes: mailboxes
.iter()
.map(|(mailbox, status)| ((*mailbox).to_string(), *status))
.collect(),
total: mailboxes.len(),
completed: mailboxes
.iter()
.filter(|(_, status)| {
matches!(
status,
StartupSyncMailboxStatus::Finished | StartupSyncMailboxStatus::Failed
)
})
.count(),
succeeded: mailboxes
.iter()
.filter(|(_, status)| matches!(status, StartupSyncMailboxStatus::Finished))
.count(),
failed: mailboxes
.iter()
.filter(|(_, status)| matches!(status, StartupSyncMailboxStatus::Failed))
.count(),
total_fetched: 0,
total_inserted: 0,
total_updated: 0,
first_error: None,
}
}
fn test_bootstrap(runtime: &RuntimeConfig) -> BootstrapState {
BootstrapState {
db: DatabaseState {
path: runtime.database_path.clone(),
schema_version: 1,
created: false,
applied_migrations: vec![],
},
}
}
#[test]
fn startup_sync_is_not_started_when_disabled_in_config() {
let mut runtime = test_runtime();
runtime.startup_sync = false;
let mut state = AppState::new(vec![], runtime);
state.start_startup_sync_if_enabled();
assert!(state.startup_sync.is_none());
}
#[test]
fn startup_sync_progress_summary_renders_counts_and_running_mailbox() {
let sync_state = startup_sync_state(&[
("INBOX", StartupSyncMailboxStatus::InFlight),
("io-uring", StartupSyncMailboxStatus::Pending),
("kvm", StartupSyncMailboxStatus::Finished),
]);
assert_eq!(
sync_state.progress_summary(),
"1/3 ok=1 fail=0 queued=1 running=INBOX"
);
assert_eq!(
sync_state.mailbox_states_display(),
"INBOX:syncing io-uring:queued kvm:done"
);
}
#[test]
fn inbox_auto_sync_starts_when_due_for_enabled_my_inbox() {
let mut state = AppState::new(vec![], test_runtime_with_imap());
state.mailbox_sync_spawner = mailbox_sync_spawner_stub;
state
.inbox_auto_sync
.as_mut()
.expect("inbox auto-sync state")
.next_due_at = Instant::now() - Duration::from_secs(1);
state.maybe_start_inbox_auto_sync();
assert!(
state
.inbox_auto_sync
.as_ref()
.and_then(|sync| sync.receiver.as_ref())
.is_some()
);
}
#[test]
fn inbox_auto_sync_waits_for_startup_sync_to_finish() {
let mut state = AppState::new(vec![], test_runtime_with_imap());
state.mailbox_sync_spawner = mailbox_sync_spawner_stub;
state.startup_sync = Some(startup_sync_state(&[(
IMAP_INBOX_MAILBOX,
StartupSyncMailboxStatus::InFlight,
)]));
state
.inbox_auto_sync
.as_mut()
.expect("inbox auto-sync state")
.next_due_at = Instant::now() - Duration::from_secs(1);
state.maybe_start_inbox_auto_sync();
assert!(
state
.inbox_auto_sync
.as_ref()
.and_then(|sync| sync.receiver.as_ref())
.is_none()
);
}
#[test]
fn inbox_auto_sync_waits_for_manual_sync_to_finish() {
let mut state = AppState::new(vec![], test_runtime_with_imap());
state.mailbox_sync_spawner = mailbox_sync_spawner_stub;
state.manual_sync = Some(manual_sync_state(&[(
IMAP_INBOX_MAILBOX,
StartupSyncMailboxStatus::InFlight,
)]));
state
.inbox_auto_sync
.as_mut()
.expect("inbox auto-sync state")
.next_due_at = Instant::now() - Duration::from_secs(1);
state.maybe_start_inbox_auto_sync();
assert!(
state
.inbox_auto_sync
.as_ref()
.and_then(|sync| sync.receiver.as_ref())
.is_none()
);
}
#[test]
fn subscription_auto_sync_starts_when_due_for_enabled_linux_subscription() {
let mut state = AppState::new(vec![], test_runtime());
state.mailbox_sync_spawner = mailbox_sync_spawner_stub;
let io_uring_index = state
.subscriptions
.iter()
.position(|item| item.mailbox == "io-uring")
.expect("io-uring subscription exists");
state.subscriptions[io_uring_index].enabled = true;
state.reconcile_subscription_auto_sync();
state
.subscription_auto_sync
.as_mut()
.expect("subscription auto-sync state")
.next_due_at = Instant::now() - Duration::from_secs(1);
state.maybe_start_subscription_auto_sync();
assert!(
state
.subscription_auto_sync
.as_ref()
.and_then(|sync| sync.receiver.as_ref())
.is_some()
);
}
#[test]
fn subscription_auto_sync_waits_for_startup_sync_to_finish() {
let mut state = AppState::new(vec![], test_runtime());
state.mailbox_sync_spawner = mailbox_sync_spawner_stub;
let qemu_devel_index = state
.subscriptions
.iter()
.position(|item| item.mailbox == "qemu-devel")
.expect("qemu-devel subscription exists");
state.subscriptions[qemu_devel_index].enabled = true;
state.reconcile_subscription_auto_sync();
state.startup_sync = Some(startup_sync_state(&[(
"qemu-devel",
StartupSyncMailboxStatus::InFlight,
)]));
state
.subscription_auto_sync
.as_mut()
.expect("subscription auto-sync state")
.next_due_at = Instant::now() - Duration::from_secs(1);
state.maybe_start_subscription_auto_sync();
assert!(
state
.subscription_auto_sync
.as_ref()
.and_then(|sync| sync.receiver.as_ref())
.is_none()
);
}
#[test]
fn subscription_auto_sync_waits_for_manual_sync_to_finish() {
let mut state = AppState::new(vec![], test_runtime());
state.mailbox_sync_spawner = mailbox_sync_spawner_stub;
let qemu_devel_index = state
.subscriptions
.iter()
.position(|item| item.mailbox == "qemu-devel")
.expect("qemu-devel subscription exists");
state.subscriptions[qemu_devel_index].enabled = true;
state.reconcile_subscription_auto_sync();
state.manual_sync = Some(manual_sync_state(&[(
"qemu-devel",
StartupSyncMailboxStatus::InFlight,
)]));
state
.subscription_auto_sync
.as_mut()
.expect("subscription auto-sync state")
.next_due_at = Instant::now() - Duration::from_secs(1);
state.maybe_start_subscription_auto_sync();
assert!(
state
.subscription_auto_sync
.as_ref()
.and_then(|sync| sync.receiver.as_ref())
.is_none()
);
}
fn external_editor_mock_success(
_editor: &str,
file_path: &Path,
) -> std::result::Result<ExternalEditorProcessResult, String> {
fs::write(file_path, "externally edited\n")
.map_err(|error| format!("failed to write fixture: {error}"))?;
Ok(ExternalEditorProcessResult {
success: true,
exit_code: Some(0),
})
}
fn external_editor_mock_failure(
_editor: &str,
_file_path: &Path,
) -> std::result::Result<ExternalEditorProcessResult, String> {
Err("mock launch failure".to_string())
}
fn reply_identity_mock() -> std::result::Result<ReplyIdentity, String> {
Ok(ReplyIdentity {
display: "CRIEW Test <criew@example.com>".to_string(),
email: "criew@example.com".to_string(),
})
}
fn reply_send_mock_success(_runtime: &RuntimeConfig, _request: &SendRequest) -> SendOutcome {
SendOutcome {
transport: "git-send-email".to_string(),
message_id: "sent@example.com".to_string(),
command_line: Some("git send-email reply.eml".to_string()),
draft_path: None,
exit_code: Some(0),
timed_out: false,
stdout: "sent".to_string(),
stderr: String::new(),
error_summary: None,
started_at: "2026-03-07T10:00:01Z".to_string(),
finished_at: "2026-03-07T10:00:02Z".to_string(),
status: SendStatus::Sent,
}
}
fn reply_send_mock_failure(_runtime: &RuntimeConfig, _request: &SendRequest) -> SendOutcome {
SendOutcome {
transport: "git-send-email".to_string(),
message_id: "failed@example.com".to_string(),
command_line: Some("git send-email reply.eml".to_string()),
draft_path: Some(PathBuf::from("/tmp/reply-failed.eml")),
exit_code: Some(1),
timed_out: false,
stdout: String::new(),
stderr: "smtp auth failed".to_string(),
error_summary: Some("smtp auth failed".to_string()),
started_at: "2026-03-07T10:00:01Z".to_string(),
finished_at: "2026-03-07T10:00:02Z".to_string(),
status: SendStatus::Failed,
}
}
fn mailbox_sync_spawner_stub(
_runtime: RuntimeConfig,
_mailboxes: Vec<String>,
) -> mpsc::Receiver<StartupSyncEvent> {
let (_sender, receiver) = mpsc::channel();
receiver
}
fn manual_sync_spawner_idle(
_runtime: RuntimeConfig,
_mailboxes: Vec<String>,
) -> mpsc::Receiver<StartupSyncEvent> {
let (_sender, receiver) = mpsc::channel();
receiver
}
fn manual_sync_spawner_seed_success(
runtime: RuntimeConfig,
mailboxes: Vec<String>,
) -> mpsc::Receiver<StartupSyncEvent> {
let (sender, receiver) = mpsc::channel();
let total = mailboxes.len();
for (index, mailbox) in mailboxes.into_iter().enumerate() {
sender
.send(StartupSyncEvent::MailboxStarted {
mailbox: mailbox.clone(),
index: index + 1,
total,
})
.expect("send mailbox started");
seed_mailbox_thread(
&runtime.database_path,
&mailbox,
index as u32 + 1,
&format!("{mailbox}-{index}@example.com"),
&format!("{mailbox} thread"),
);
sender
.send(StartupSyncEvent::MailboxFinished {
mailbox,
fetched: 1,
inserted: 1,
updated: 0,
})
.expect("send mailbox finished");
}
sender
.send(StartupSyncEvent::WorkerCompleted)
.expect("send worker completed");
receiver
}
fn type_text(state: &mut AppState, text: &str) {
for character in text.chars() {
let _ = handle_key_event(
state,
KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE),
);
}
}
#[test]
fn empty_query_returns_all_palette_commands() {
let all = matching_commands("");
assert_eq!(all.len(), 8);
assert_eq!(all[0].name, "config");
assert_eq!(all[1].name, "exit");
assert_eq!(all[2].name, "help");
assert_eq!(all[3].name, "keymap");
assert_eq!(all[4].name, "quit");
assert_eq!(all[5].name, "restart");
assert_eq!(all[6].name, "sync");
assert_eq!(all[7].name, "vim");
}
#[test]
fn prefix_matches_rank_before_fuzzy_matches() {
let commands = matching_commands("ex");
assert_eq!(commands[0].name, "exit");
}
#[test]
fn bang_mode_is_not_matched_as_builtin_command() {
let commands = matching_commands("!pwd");
assert!(commands.is_empty());
}
#[test]
fn external_editor_selection_prefers_visual_then_editor_then_vim() {
assert_eq!(
pick_external_editor(Some("nvim"), Some("vim")),
"nvim".to_string()
);
assert_eq!(
pick_external_editor(Some(" "), Some("hx")),
"hx".to_string()
);
assert_eq!(pick_external_editor(None, Some("nano")), "nano".to_string());
assert_eq!(pick_external_editor(None, None), "vim".to_string());
}
#[test]
fn external_editor_session_restores_terminal_after_editor_exit() {
use std::cell::RefCell;
use std::rc::Rc;
let steps: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
let result = run_external_editor_session_with(
"vim",
Path::new("/tmp/demo.rs"),
{
let steps = steps.clone();
move || {
steps.borrow_mut().push("disable_raw");
Ok(())
}
},
{
let steps = steps.clone();
move || {
steps.borrow_mut().push("leave_alt");
Ok(())
}
},
{
let steps = steps.clone();
move |_, _| {
steps.borrow_mut().push("launch");
Ok(ExternalEditorProcessResult {
success: true,
exit_code: Some(0),
})
}
},
{
let steps = steps.clone();
move || {
steps.borrow_mut().push("enter_alt");
Ok(())
}
},
{
let steps = steps.clone();
move || {
steps.borrow_mut().push("enable_raw");
Ok(())
}
},
)
.expect("external editor session should succeed");
assert!(result.success);
assert_eq!(
*steps.borrow(),
vec![
"disable_raw",
"leave_alt",
"launch",
"enter_alt",
"enable_raw"
]
);
}
#[test]
fn mail_page_layout_keeps_preview_at_fixed_90_columns() {
let panes = mail_page_panes(Rect::new(0, 0, 180, 20), MailPaneLayout::default());
assert_eq!(panes[2].width, 90);
assert_eq!(panes[2].x, 90);
assert_eq!(panes[0].width, 23);
assert_eq!(panes[1].width, 67);
assert_eq!(panes[0].width + panes[1].width + panes[2].width, 180);
}
#[test]
fn mail_page_layout_falls_back_to_available_width_when_terminal_is_narrow() {
let panes = mail_page_panes(Rect::new(0, 0, 60, 20), MailPaneLayout::default());
assert_eq!(panes[2].width, 60);
assert_eq!(panes[0].width, 0);
assert_eq!(panes[1].width, 0);
}
#[test]
fn mail_page_layout_uses_persisted_fixed_mail_pane_widths() {
let panes = mail_page_panes(
Rect::new(0, 0, 180, 20),
MailPaneLayout {
subscriptions_width: 31,
preview_width: 84,
},
);
assert_eq!(panes[0].width, 31);
assert_eq!(panes[1].width, 65);
assert_eq!(panes[2].width, 84);
}
#[test]
fn subscription_line_shows_marker_and_mailbox_name_only() {
let enabled = SubscriptionItem {
mailbox: "io-uring".to_string(),
label: "io-uring".to_string(),
enabled: true,
category: Some(SubscriptionCategory::LinuxSubsystem),
};
let disabled = SubscriptionItem {
mailbox: "linux-mm".to_string(),
label: "linux-mm".to_string(),
enabled: false,
category: Some(SubscriptionCategory::LinuxSubsystem),
};
assert_eq!(subscription_line(&enabled, None), "[y] io-uring");
assert_eq!(subscription_line(&disabled, None), "[n] linux-mm");
}
#[test]
fn subscription_line_shows_sync_suffix_when_progress_is_active() {
let enabled = SubscriptionItem {
mailbox: "INBOX".to_string(),
label: "My Inbox".to_string(),
enabled: true,
category: None,
};
assert_eq!(
subscription_line(&enabled, Some(StartupSyncMailboxStatus::Pending)),
"[y] My Inbox [queued]"
);
assert_eq!(
subscription_line(&enabled, Some(StartupSyncMailboxStatus::InFlight)),
"[y] My Inbox [sync]"
);
}
#[test]
fn thread_line_hides_sender() {
let row = sample_thread("thread subject", "x@example.com", 0);
let line = thread_line(&row, 120);
assert_eq!(line, "thread subject");
assert!(!line.contains("alice@example.com"));
}
#[test]
fn thread_line_truncates_by_max_chars_and_available_width() {
let long_subject = "x".repeat(240);
let row = sample_thread(&long_subject, "x@example.com", 0);
let line_capped_at_120 = thread_line(&row, 200);
assert_eq!(
line_capped_at_120.chars().count(),
super::THREAD_LINE_MAX_CHARS
);
assert!(line_capped_at_120.ends_with("..."));
let line_capped_by_width = thread_line(&row, 30);
assert_eq!(line_capped_by_width.chars().count(), 30);
assert!(line_capped_by_width.ends_with("..."));
}
#[test]
fn command_palette_quit_exits_application() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "quit".to_string();
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Exit));
}
#[test]
fn command_palette_restart_requests_tui_restart() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "restart".to_string();
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Restart));
}
#[test]
fn command_palette_help_includes_keyboard_shortcuts() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "help".to_string();
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Continue));
assert!(state.status.contains("keys:"));
assert!(state.status.contains("j/l focus"));
assert!(state.status.contains("i/k move"));
assert!(state.status.contains("[ ] expand pane"));
assert!(state.status.contains("{ } shrink pane"));
assert!(state.status.contains("-/= preview switch"));
assert!(state.status.contains("y/n enable"));
assert!(state.status.contains("a apply"));
assert!(state.status.contains("d download"));
assert!(state.status.contains("u undo apply"));
}
#[test]
fn command_palette_help_uses_vim_keymap_labels() {
let mut runtime = test_runtime();
runtime.ui_keymap = UiKeymap::Vim;
let mut state = AppState::new(vec![], runtime);
state.palette.open = true;
state.palette.input = "help".to_string();
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Continue));
assert!(state.status.contains("h/l focus"));
assert!(state.status.contains("j/k move"));
}
#[test]
fn keymap_palette_command_opens_keymap_editor() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "keymap".to_string();
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Continue));
assert!(state.keymap_editor.open);
assert!(!state.palette.open);
assert_eq!(state.status, "keymap editor opened");
}
#[test]
fn config_palette_get_and_set_roundtrip() {
let root = temp_dir("palette-config");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[source]
mailbox = "inbox"
"#,
)
.expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path.clone();
let mut state = AppState::new(vec![], runtime);
state.palette.open = true;
state.palette.input = "config get source.mailbox".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.status.contains("source.mailbox"));
assert!(state.status.contains("inbox"));
state.palette.open = true;
state.palette.input = "config set source.mailbox io-uring".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.status.contains("config updated"));
assert_eq!(state.runtime.source_mailbox, "io-uring");
let persisted = fs::read_to_string(&config_path).expect("read config file");
assert!(persisted.contains("mailbox = \"io-uring\""));
state.palette.open = true;
state.palette.input = "config get source.mailbox".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.status.contains("io-uring"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn config_palette_set_keymap_updates_navigation_immediately() {
let root = temp_dir("palette-keymap");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[ui]
keymap = "default"
"#,
)
.expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path.clone();
let stale_runtime = runtime.clone();
let mut state = AppState::new(
vec![
sample_thread("t0", "a@example.com", 0),
sample_thread("t1", "b@example.com", 1),
],
runtime,
);
state.palette.open = true;
state.palette.input = "config set ui.keymap vim".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert_eq!(state.runtime.ui_keymap, UiKeymap::Vim);
let persisted = fs::read_to_string(&config_path).expect("read config file");
assert!(persisted.contains("keymap = \"vim\""));
state.palette.open = false;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Threads));
assert_eq!(state.thread_index, 1);
let bootstrap = test_bootstrap(&stale_runtime);
let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &stale_runtime, &bootstrap))
.expect("draw updated keymap footer");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("h/l focus | j/k move"));
assert!(!rendered.contains("j/l focus | i/k move"));
assert!(!rendered.contains("gg/G jump"));
assert!(!rendered.contains("qq quit"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn keymap_editor_custom_binding_updates_navigation_immediately() {
let root = temp_dir("keymap-editor-custom");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[ui]
keymap = "custom"
keymap_base = "default"
"#,
)
.expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path.clone();
runtime.ui_keymap = UiKeymap::Custom;
let mut state = AppState::new(
vec![
sample_thread("t0", "a@example.com", 0),
sample_thread("t1", "b@example.com", 1),
],
runtime,
);
state.palette.open = true;
state.palette.input = "keymap".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.keymap_editor.open);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(
state.keymap_editor.mode,
super::keymap::KeymapEditorMode::Capture
));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(
state.keymap_editor.mode,
super::keymap::KeymapEditorMode::Browse
));
assert_eq!(
state.runtime.ui_custom_keymap.focus_prev,
Some(vec!["h".to_string()])
);
let persisted = fs::read_to_string(&config_path).expect("read config file");
assert!(persisted.contains("focus_prev = [\"h\"]"));
state.keymap_editor.open = false;
state.focus = Pane::Threads;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Subscriptions));
state.focus = Pane::Threads;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Threads));
let _ = fs::remove_dir_all(root);
}
#[test]
fn custom_bindings_do_not_leak_into_default_or_vim_schemes() {
let root = temp_dir("keymap-scheme-isolation");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[ui]
keymap = "default"
keymap_base = "default"
[ui.custom_keymap]
focus_prev = ["x"]
"#,
)
.expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path.clone();
let mut state = AppState::new(
vec![
sample_thread("t0", "a@example.com", 0),
sample_thread("t1", "b@example.com", 1),
],
runtime,
);
state.palette.open = true;
state.palette.input = "keymap".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.keymap_editor.open);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
);
assert_eq!(state.runtime.ui_keymap, UiKeymap::Vim);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
state.focus = Pane::Threads;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Threads));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Subscriptions));
state.palette.open = true;
state.palette.input = "keymap".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.keymap_editor.open);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
);
assert_eq!(state.runtime.ui_keymap, UiKeymap::Custom);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
);
assert_eq!(state.runtime.ui_keymap, UiKeymap::Default);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
state.focus = Pane::Threads;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Threads));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Subscriptions));
let _ = fs::remove_dir_all(root);
}
#[test]
fn keymap_editor_lists_active_bindings_for_selected_scheme() {
let root = temp_dir("keymap-editor-active-bindings");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[ui]
keymap = "default"
keymap_base = "default"
[ui.custom_keymap]
focus_prev = ["x"]
"#,
)
.expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path;
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(vec![], runtime.clone());
state.open_keymap_editor();
state.keymap_editor.selected_field = 2;
let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw default keymap editor");
let rendered_default = format!("{}", terminal.backend());
assert!(rendered_default.contains("j [default]"));
state.keymap_editor.selected_field = 0;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
);
state.keymap_editor.selected_field = 2;
let runtime_vim = state.runtime.clone();
let bootstrap_vim = test_bootstrap(&runtime_vim);
terminal
.draw(|frame| draw(frame, &state, &runtime_vim, &bootstrap_vim))
.expect("draw vim keymap editor");
let rendered_vim = format!("{}", terminal.backend());
assert!(rendered_vim.contains("h [vim]"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn config_get_ui_keymap_returns_current_value() {
let root = temp_dir("get-keymap");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[ui]
keymap = "vim"
"#,
)
.expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path.clone();
runtime.ui_keymap = UiKeymap::Vim;
let mut state = AppState::new(vec![], runtime);
state.palette.open = true;
state.palette.input = "config get ui.keymap".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(
state.status.contains("vim"),
"config get should report vim, got: {}",
state.status
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn loaded_vim_keymap_drives_navigation_keys() {
let mut runtime = test_runtime();
runtime.ui_keymap = UiKeymap::Vim;
let mut state = AppState::new(
vec![
sample_thread("t0", "a@example.com", 0),
sample_thread("t1", "b@example.com", 1),
],
runtime,
);
state.subscription_index = 1;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Threads));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(state.thread_index, 1, "j should move down in vim keymap");
let prev_index = state.thread_index;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert_eq!(
state.thread_index, prev_index,
"i should not navigate in vim keymap"
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
);
assert!(
matches!(state.focus, Pane::Subscriptions),
"h should move focus left in vim keymap"
);
}
#[test]
fn default_keymap_supports_counted_ik_navigation() {
let mut state = AppState::new(sample_threads(15), test_runtime());
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Threads));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE),
);
let action_down = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert!(matches!(action_down, LoopAction::Continue));
assert_eq!(state.thread_index, 12);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE),
);
let action_up = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert!(matches!(action_up, LoopAction::Continue));
assert_eq!(state.thread_index, 7);
}
#[test]
fn vim_keymap_supports_counted_jk_navigation() {
let mut runtime = test_runtime();
runtime.ui_keymap = UiKeymap::Vim;
let mut state = AppState::new(sample_threads(15), runtime);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Threads));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE),
);
let action_down = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert!(matches!(action_down, LoopAction::Continue));
assert_eq!(state.thread_index, 12);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE),
);
let action_up = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert!(matches!(action_up, LoopAction::Continue));
assert_eq!(state.thread_index, 7);
}
#[test]
fn counted_main_page_navigation_does_not_leak_into_focus_changes() {
let mut state = AppState::new(sample_threads(4), test_runtime());
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE),
);
let focus_action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
assert!(matches!(focus_action, LoopAction::Continue));
assert!(matches!(state.focus, Pane::Threads));
let move_action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert!(matches!(move_action, LoopAction::Continue));
assert_eq!(state.thread_index, 1);
}
#[test]
fn preview_focus_supports_minus_equals_shifted_equals_and_plus_thread_navigation() {
let mut state = AppState::new(sample_threads(4), test_runtime());
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Preview));
assert_eq!(state.thread_index, 1);
state.preview_scroll = 7;
let action_previous = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE),
);
assert!(matches!(action_previous, LoopAction::Continue));
assert!(matches!(state.focus, Pane::Preview));
assert_eq!(state.thread_index, 0);
assert_eq!(state.preview_scroll, 0);
state.preview_scroll = 5;
let action_next_with_equals = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('='), KeyModifiers::NONE),
);
assert!(matches!(action_next_with_equals, LoopAction::Continue));
assert!(matches!(state.focus, Pane::Preview));
assert_eq!(state.thread_index, 1);
assert_eq!(state.preview_scroll, 0);
state.preview_scroll = 5;
let action_next_with_shifted_equals = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('='), KeyModifiers::SHIFT),
);
assert!(matches!(
action_next_with_shifted_equals,
LoopAction::Continue
));
assert!(matches!(state.focus, Pane::Preview));
assert_eq!(state.thread_index, 2);
assert_eq!(state.preview_scroll, 0);
state.preview_scroll = 5;
let action_next_with_plus = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('+'), KeyModifiers::SHIFT),
);
assert!(matches!(action_next_with_plus, LoopAction::Continue));
assert!(matches!(state.focus, Pane::Preview));
assert_eq!(state.thread_index, 3);
assert_eq!(state.preview_scroll, 0);
}
#[test]
fn resize_shortcuts_follow_the_focused_mail_pane_and_persist_layout() {
let root = temp_dir("mail-pane-resize");
let runtime = test_runtime_in(root.clone());
let mut state = AppState::new(vec![], runtime);
let expand_subscriptions = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE),
);
assert!(matches!(expand_subscriptions, LoopAction::Continue));
assert_eq!(state.mail_pane_layout.subscriptions_width, 27);
assert_eq!(state.mail_pane_layout.preview_width, 90);
let shrink_subscriptions = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('}'), KeyModifiers::SHIFT),
);
assert!(matches!(shrink_subscriptions, LoopAction::Continue));
assert_eq!(state.mail_pane_layout.subscriptions_width, 23);
state.focus = Pane::Threads;
let expand_threads_left = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE),
);
assert!(matches!(expand_threads_left, LoopAction::Continue));
assert_eq!(state.mail_pane_layout.subscriptions_width, 19);
let shrink_threads_left = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('{'), KeyModifiers::SHIFT),
);
assert!(matches!(shrink_threads_left, LoopAction::Continue));
assert_eq!(state.mail_pane_layout.subscriptions_width, 23);
let expand_threads_right = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE),
);
assert!(matches!(expand_threads_right, LoopAction::Continue));
assert_eq!(state.mail_pane_layout.preview_width, 86);
let shrink_threads_right = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('}'), KeyModifiers::SHIFT),
);
assert!(matches!(shrink_threads_right, LoopAction::Continue));
assert_eq!(state.mail_pane_layout.preview_width, 90);
state.focus = Pane::Preview;
let expand_preview = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE),
);
assert!(matches!(expand_preview, LoopAction::Continue));
assert_eq!(state.mail_pane_layout.preview_width, 94);
let shrink_preview = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('{'), KeyModifiers::SHIFT),
);
assert!(matches!(shrink_preview, LoopAction::Continue));
assert_eq!(state.mail_pane_layout.preview_width, 90);
let persisted = ui_state::load(&state.ui_state_path)
.expect("load persisted ui state")
.expect("ui state exists");
assert_eq!(persisted.mail_subscriptions_width, 23);
assert_eq!(persisted.mail_preview_width, 90);
let _ = fs::remove_dir_all(root);
}
#[test]
fn resize_shortcuts_stop_at_fixed_edges_and_minimum_mail_pane_widths() {
let mut state = AppState::new(vec![], test_runtime());
let fixed_edge_action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE),
);
assert!(matches!(fixed_edge_action, LoopAction::Continue));
assert_eq!(
state.mail_pane_layout.subscriptions_width,
ui_state::DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH
);
assert_eq!(state.status, "mail pane cannot expand in that direction");
state.mail_pane_layout.subscriptions_width = MIN_MAIL_SUBSCRIPTIONS_WIDTH;
let min_subscriptions_action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('}'), KeyModifiers::SHIFT),
);
assert!(matches!(min_subscriptions_action, LoopAction::Continue));
assert_eq!(
state.mail_pane_layout.subscriptions_width,
MIN_MAIL_SUBSCRIPTIONS_WIDTH
);
assert_eq!(state.status, "mail pane cannot shrink in that direction");
state.focus = Pane::Preview;
let preview_fixed_edge_action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE),
);
assert!(matches!(preview_fixed_edge_action, LoopAction::Continue));
assert_eq!(state.status, "mail pane cannot expand in that direction");
state.mail_pane_layout.preview_width = MIN_MAIL_PREVIEW_WIDTH;
let min_preview_action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('{'), KeyModifiers::SHIFT),
);
assert!(matches!(min_preview_action, LoopAction::Continue));
assert_eq!(state.mail_pane_layout.preview_width, MIN_MAIL_PREVIEW_WIDTH);
assert_eq!(state.status, "mail pane cannot shrink in that direction");
}
#[test]
fn vim_keymap_supports_gg_and_capital_g_jumps_on_mail_panes() {
let mut runtime = test_runtime();
runtime.ui_keymap = UiKeymap::Vim;
let mut state = AppState::new(
vec![
sample_thread("t0", "a@example.com", 0),
sample_thread("t1", "b@example.com", 1),
sample_thread("t2", "c@example.com", 2),
],
runtime,
);
let subscription_rows = state.subscription_rows();
assert!(!subscription_rows.is_empty());
let action_bottom = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
);
assert!(matches!(action_bottom, LoopAction::Continue));
assert_eq!(
state.subscription_row_index,
subscription_rows.len().saturating_sub(1)
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
let action_top = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
assert!(matches!(action_top, LoopAction::Continue));
assert_eq!(state.subscription_row_index, 0);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
let action_thread_bottom = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
);
assert!(matches!(action_thread_bottom, LoopAction::Continue));
assert_eq!(state.thread_index, 2);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
let action_thread_top = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
assert!(matches!(action_thread_top, LoopAction::Continue));
assert_eq!(state.thread_index, 0);
state.focus = Pane::Preview;
state.preview_scroll = 7;
let action_preview_bottom = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
);
assert!(matches!(action_preview_bottom, LoopAction::Continue));
assert_eq!(state.preview_scroll, u16::MAX);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
let action_preview_top = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
assert!(matches!(action_preview_top, LoopAction::Continue));
assert_eq!(state.preview_scroll, 0);
}
#[test]
fn vim_keymap_supports_gg_and_capital_g_jumps_in_code_browser() {
let tree_root = temp_dir("vim-jump-code-browser");
let file_path = tree_root.join("demo.c");
let source = (1..=40)
.map(|line| format!("int line_{line} = {line};"))
.collect::<Vec<_>>()
.join("\n");
fs::write(&file_path, format!("{source}\n")).expect("write file");
let mut runtime = test_runtime_with_kernel_tree(tree_root.clone());
runtime.ui_keymap = UiKeymap::Vim;
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(vec![], runtime.clone());
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(state.ui_page, UiPage::CodeBrowser));
let action_tree_bottom = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
);
assert!(matches!(action_tree_bottom, LoopAction::Continue));
assert_eq!(
state.kernel_tree_row_index,
state.kernel_tree_rows.len().saturating_sub(1)
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
let action_tree_top = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
assert!(matches!(action_tree_top, LoopAction::Continue));
assert_eq!(state.kernel_tree_row_index, 0);
state.code_focus = CodePaneFocus::Source;
state.kernel_tree_row_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("file row exists");
let mut terminal = Terminal::new(TestBackend::new(140, 18)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw source preview before vim jumps");
state.code_preview_scroll = 9;
let action_source_bottom = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
);
assert!(matches!(action_source_bottom, LoopAction::Continue));
let code_preview_scroll_limit = state.code_preview_scroll_limit.get();
assert!(code_preview_scroll_limit > 0);
assert_eq!(state.code_preview_scroll, code_preview_scroll_limit);
let action_source_up = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert!(matches!(action_source_up, LoopAction::Continue));
assert_eq!(
state.code_preview_scroll,
code_preview_scroll_limit.saturating_sub(1)
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
let action_source_top = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
assert!(matches!(action_source_top, LoopAction::Continue));
assert_eq!(state.code_preview_scroll, 0);
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn vim_keymap_supports_qq_quit_chord() {
let mut runtime = test_runtime();
runtime.ui_keymap = UiKeymap::Vim;
let mut state = AppState::new(vec![], runtime);
let first = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert!(matches!(first, LoopAction::Continue));
assert_eq!(
state.status,
"press qq to quit or use command palette quit/exit"
);
let second = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert!(matches!(second, LoopAction::Exit));
}
#[test]
fn vim_chords_do_not_leak_into_right_preview_pane() {
let mut runtime = test_runtime();
runtime.ui_keymap = UiKeymap::Vim;
let mut state = AppState::new(
vec![
sample_thread("t0", "a@example.com", 0),
sample_thread("t1", "b@example.com", 1),
],
runtime,
);
let arm_quit = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert!(matches!(arm_quit, LoopAction::Continue));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
assert!(matches!(state.focus, Pane::Preview));
state.preview_scroll = 11;
let first_g = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
assert!(matches!(first_g, LoopAction::Continue));
assert_eq!(state.preview_scroll, 11);
let second_g = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
assert!(matches!(second_g, LoopAction::Continue));
assert_eq!(state.preview_scroll, 0);
}
#[test]
fn preview_pane_shift_g_keeps_tui_renderable() {
let mut runtime = test_runtime();
runtime.ui_keymap = UiKeymap::Vim;
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(
vec![sample_thread("preview thread", "preview@example.com", 0)],
runtime.clone(),
);
state.focus = Pane::Preview;
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
);
assert!(matches!(action, LoopAction::Continue));
let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw after preview shift-g");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("Preview"));
}
#[test]
fn preview_pane_can_move_up_after_reaching_bottom() {
let root = temp_dir("preview-scroll-bottom");
let raw_path = root.join("preview.eml");
let body = (1..=40)
.map(|line| format!("preview line {line:02}"))
.collect::<Vec<_>>()
.join("\n");
fs::write(
&raw_path,
format!(
"Message-ID: <preview-scroll@example.com>\r\nSubject: preview scroll\r\nFrom: preview@example.com\r\n\r\n{body}\n"
),
)
.expect("write raw mail");
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(
vec![sample_thread_with_raw(
"preview scroll",
"preview-scroll@example.com",
0,
raw_path.clone(),
)],
runtime.clone(),
);
state.focus = Pane::Preview;
let mut terminal = Terminal::new(TestBackend::new(120, 16)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw initial preview frame");
let preview_scroll_limit = state.preview_scroll_limit.get();
assert!(preview_scroll_limit > 0);
for _ in 0..(preview_scroll_limit as usize + 10) {
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
}
assert_eq!(state.preview_scroll, preview_scroll_limit);
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw bottom preview frame");
let bottom_frame = format!("{}", terminal.backend());
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert_eq!(state.preview_scroll, preview_scroll_limit.saturating_sub(1));
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw preview frame after moving up");
let after_up_frame = format!("{}", terminal.backend());
assert_ne!(after_up_frame, bottom_frame);
let _ = fs::remove_dir_all(root);
}
#[test]
fn preview_scroll_limit_accounts_for_wrapped_long_lines() {
let root = temp_dir("preview-wrap-scroll");
let raw_path = root.join("wrapped-preview.eml");
let wrapped_body = format!("{} WRAP_TAIL_TOKEN\n", "x".repeat(380));
fs::write(
&raw_path,
format!(
"Message-ID: <wrapped-preview@example.com>\r\nSubject: wrapped preview\r\nFrom: preview@example.com\r\n\r\n{wrapped_body}"
),
)
.expect("write wrapped preview mail");
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(
vec![sample_thread_with_raw(
"wrapped preview",
"wrapped-preview@example.com",
0,
raw_path.clone(),
)],
runtime.clone(),
);
state.focus = Pane::Preview;
let mut terminal = Terminal::new(TestBackend::new(40, 10)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw wrapped preview");
let top_frame = format!("{}", terminal.backend());
let preview_scroll_limit = state.preview_scroll_limit.get();
assert!(preview_scroll_limit > 0);
assert!(!top_frame.contains("WRAP_TAIL_TOKEN"));
for _ in 0..(preview_scroll_limit as usize + 5) {
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
}
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw wrapped preview after scrolling");
let bottom_frame = format!("{}", terminal.backend());
assert!(bottom_frame.contains("WRAP_TAIL_TOKEN"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn source_pane_shift_g_keeps_tui_renderable() {
let tree_root = temp_dir("source-pane-shift-g");
let file_path = tree_root.join("demo.c");
fs::write(
&file_path,
"int line_1;\nint line_2;\nint line_3;\nint line_4;\nint line_5;\n",
)
.expect("write source file");
let mut runtime = test_runtime_with_kernel_tree(tree_root.clone());
runtime.ui_keymap = UiKeymap::Vim;
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(vec![], runtime.clone());
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(state.ui_page, UiPage::CodeBrowser));
state.code_focus = CodePaneFocus::Source;
state.kernel_tree_row_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("file row exists");
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
);
assert!(matches!(action, LoopAction::Continue));
let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw after source shift-g");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("Source Preview"));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn config_command_opens_visual_editor() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "config".to_string();
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Continue));
assert!(state.config_editor.open);
assert!(!state.palette.open);
assert_eq!(state.selected_config_editor_field().key, "source.mailbox");
}
#[test]
fn config_editor_saves_selected_value() {
let root = temp_dir("config-editor-save");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[source]
mailbox = "inbox"
"#,
)
.expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path.clone();
let mut state = AppState::new(vec![], runtime);
state.open_config_editor(Some("source.mailbox"));
state.start_config_editor_edit();
state.config_editor.input = "io-uring".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert_eq!(state.runtime.source_mailbox, "io-uring");
assert!(!state.config_editor.open || state.config_editor.input.is_empty());
let persisted = fs::read_to_string(&config_path).expect("read config file");
assert!(persisted.contains("mailbox = \"io-uring\""));
let _ = fs::remove_dir_all(root);
}
#[test]
fn config_editor_tab_cycles_boolean_presets() {
let root = temp_dir("config-editor-toggle");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[ui]
startup_sync = true
"#,
)
.expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path.clone();
let mut state = AppState::new(vec![], runtime);
state.open_config_editor(Some("ui.startup_sync"));
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(!state.runtime.startup_sync);
let persisted = fs::read_to_string(&config_path).expect("read config file");
assert!(persisted.contains("startup_sync = false"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn config_editor_saves_inbox_auto_sync_interval() {
let root = temp_dir("config-editor-auto-sync-interval");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[ui]
inbox_auto_sync_interval_secs = 30
"#,
)
.expect("write config file");
let mut runtime = test_runtime_with_imap();
runtime.config_path = config_path.clone();
let mut state = AppState::new(vec![], runtime);
state.open_config_editor(Some("ui.inbox_auto_sync_interval_secs"));
state.start_config_editor_edit();
state.config_editor.input = "45".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert_eq!(state.runtime.inbox_auto_sync_interval_secs, 45);
let persisted = fs::read_to_string(&config_path).expect("read config file");
assert!(persisted.contains("inbox_auto_sync_interval_secs = 45"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn config_editor_can_unset_optional_key() {
let root = temp_dir("config-editor-unset");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[b4]
path = "/usr/bin/b4"
"#,
)
.expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path.clone();
let mut state = AppState::new(vec![], runtime);
state.open_config_editor(Some("b4.path"));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
assert!(state.runtime.b4_path.is_none());
let persisted = fs::read_to_string(&config_path).expect("read config file");
assert!(!persisted.contains("path = "));
let _ = fs::remove_dir_all(root);
}
#[test]
fn config_editor_rejects_invalid_runtime_value_without_writing_file() {
let root = temp_dir("config-editor-invalid");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[ui]
startup_sync = true
"#,
)
.expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path.clone();
let mut state = AppState::new(vec![], runtime);
state.open_config_editor(Some("ui.startup_sync"));
state.start_config_editor_edit();
state.config_editor.input = "maybe".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(
state
.status
.contains("failed to set config key ui.startup_sync")
);
assert!(state.runtime.startup_sync);
let persisted = fs::read_to_string(&config_path).expect("read config file");
assert!(persisted.contains("startup_sync = true"));
assert!(!persisted.contains("maybe"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn config_editor_rejects_zero_inbox_auto_sync_interval_without_writing_file() {
let root = temp_dir("config-editor-zero-auto-sync-interval");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[ui]
inbox_auto_sync_interval_secs = 30
"#,
)
.expect("write config file");
let mut runtime = test_runtime_with_imap();
runtime.config_path = config_path.clone();
let mut state = AppState::new(vec![], runtime);
state.open_config_editor(Some("ui.inbox_auto_sync_interval_secs"));
state.start_config_editor_edit();
state.config_editor.input = "0".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(
state
.status
.contains("failed to set config key ui.inbox_auto_sync_interval_secs")
);
assert_eq!(state.runtime.inbox_auto_sync_interval_secs, 30);
let persisted = fs::read_to_string(&config_path).expect("read config file");
assert!(persisted.contains("inbox_auto_sync_interval_secs = 30"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn config_editor_reports_unsupported_key_hint_and_allows_keyboard_navigation() {
let mut state = AppState::new(vec![], test_runtime());
state.open_config_editor(Some("unsupported.key"));
assert!(state.config_editor.open);
assert!(
state
.status
.contains("config editor does not support unsupported.key")
);
assert_eq!(state.selected_config_editor_field().key, "source.mailbox");
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_ne!(state.selected_config_editor_field().key, "source.mailbox");
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(state.selected_config_editor_field().key, "source.mailbox");
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!state.config_editor.open);
assert_eq!(state.status, "config editor closed");
}
#[test]
fn config_editor_edit_mode_handles_char_backspace_tab_and_escape() {
let root = temp_dir("config-editor-keyboard");
let config_path = root.join("criew-config.toml");
fs::write(
&config_path,
r#"
[ui]
startup_sync = true
"#,
)
.expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path;
let mut state = AppState::new(vec![], runtime);
state.open_config_editor(Some("ui.startup_sync"));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(
state.config_editor.mode,
super::ConfigEditorMode::Edit
));
assert_eq!(state.config_editor.input, "true");
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
assert_eq!(state.config_editor.input, "truex");
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(state.config_editor.input, "true");
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(state.config_editor.input, "false");
assert_eq!(state.status, "preset value selected for ui.startup_sync");
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(
state.config_editor.mode,
super::ConfigEditorMode::Browse
));
assert!(state.config_editor.input.is_empty());
assert_eq!(state.status, "config edit cancelled");
let _ = fs::remove_dir_all(root);
}
#[test]
fn config_palette_help_and_usage_are_reported() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "config help".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.status.contains("config usage:"));
state.palette.open = true;
state.palette.input = "config get".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert_eq!(state.status, "usage: config get <key>");
state.palette.open = true;
state.palette.input = "config set".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert_eq!(state.status, "usage: config set <key> <value>");
}
#[test]
fn config_palette_reports_effective_and_missing_values() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "config get source.mailbox".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(
state
.status
.contains("config effective source.mailbox = inbox (default/runtime)")
);
state.palette.open = true;
state.palette.input = "config get no.such.key".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert_eq!(state.status, "config key not found: no.such.key");
}
#[test]
fn config_palette_set_does_not_overwrite_scalar_parent_keys() {
let root = temp_dir("config-palette-scalar-parent");
let config_path = root.join("criew-config.toml");
fs::write(&config_path, "source = \"broken\"\n").expect("write config file");
let mut runtime = test_runtime();
runtime.config_path = config_path.clone();
let mut state = AppState::new(vec![], runtime);
state.palette.open = true;
state.palette.input = "config set source.mailbox io-uring".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.status.contains("cannot set source.mailbox"));
assert_eq!(state.runtime.source_mailbox, "inbox");
let persisted = fs::read_to_string(&config_path).expect("read config file");
assert_eq!(persisted, "source = \"broken\"\n");
let _ = fs::remove_dir_all(root);
}
#[test]
fn config_editor_overlay_is_rendered() {
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(vec![], runtime.clone());
state.open_config_editor(Some("source.mailbox"));
let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw config editor");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("Runtime Config"));
assert!(rendered.contains("source.mailbox"));
assert!(rendered.contains("Selected Field"));
}
#[test]
fn ctrl_backtick_toggles_command_palette() {
let key = KeyEvent::new(KeyCode::Char('`'), KeyModifiers::CONTROL);
assert!(is_palette_toggle(key));
}
#[test]
fn colon_opens_command_palette() {
let mut state = AppState::new(vec![], test_runtime());
let key = KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT);
assert!(is_palette_open_shortcut(key));
let action = handle_key_event(&mut state, key);
assert!(matches!(action, LoopAction::Continue));
assert!(state.palette.open);
}
#[test]
fn palette_tab_completes_top_level_command() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "co".to_string();
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(state.palette.input, "config ");
}
#[test]
fn palette_tab_completes_config_subcommand_and_key() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "config g".to_string();
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(state.palette.input, "config get ");
state.palette.input = "config get source.m".to_string();
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(state.palette.input, "config get source.mailbox ");
}
#[test]
fn palette_tab_completes_sync_mailbox() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "sync bp".to_string();
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(state.palette.input, "sync bpf ");
}
#[test]
fn palette_double_tab_lists_config_arguments() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "config".to_string();
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(state.palette.input, "config ");
assert!(!state.palette.show_suggestions);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(state.palette.show_suggestions);
let values: Vec<String> = state
.palette
.suggestions
.iter()
.map(|item| item.value.clone())
.collect();
assert!(values.contains(&"show".to_string()));
assert!(values.contains(&"get".to_string()));
assert!(values.contains(&"set".to_string()));
assert!(values.contains(&"help".to_string()));
}
#[test]
fn palette_tab_completes_local_command_path() {
let tree_root = temp_dir("palette-bang-complete");
fs::write(tree_root.join("echo-local"), "#!/bin/sh\n").expect("write executable");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
state.palette.open = true;
state.palette.input = "!./ec".to_string();
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(state.palette.input, "!./echo-local ");
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn local_command_mode_uses_kernel_tree_as_workdir() {
let tree_root = temp_dir("palette-bang-kernel-tree");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let state = AppState::new(vec![], runtime);
let workdir = resolve_palette_local_workdir(&state).expect("resolve local workdir");
assert_eq!(workdir, tree_root);
let _ = fs::remove_dir_all(workdir);
}
#[test]
fn local_command_mode_falls_back_to_home_workdir() {
let state = AppState::new(vec![], test_runtime());
let resolved = resolve_palette_local_workdir(&state);
match std::env::var("HOME") {
Ok(home) => assert_eq!(resolved.expect("resolve home"), PathBuf::from(home)),
Err(_) => assert!(resolved.is_err()),
}
}
#[test]
fn palette_bang_executes_local_command() {
let tree_root = temp_dir("palette-bang-exec");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
state.palette.open = true;
state.palette.input = "!pwd".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.status.contains("local command ok"));
assert!(state.status.contains(&tree_root.display().to_string()));
let local_result = state
.palette
.last_local_result
.as_ref()
.expect("local result should exist");
assert_eq!(local_result.command, "pwd");
assert!(
local_result
.output
.contains(&tree_root.display().to_string())
);
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn command_palette_renders_local_command_result() {
let tree_root = temp_dir("palette-bang-render");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(vec![], runtime.clone());
state.palette.open = true;
state.palette.input = "!pwd".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let mut terminal = Terminal::new(TestBackend::new(180, 30)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw frame");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("Local Result"));
assert!(rendered.contains("!pwd"));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn tab_toggles_between_mail_page_and_code_browser_page() {
let tree_root = temp_dir("kernel-tree-tab");
fs::create_dir_all(tree_root.join("io_uring")).expect("create kernel dir");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
assert!(matches!(state.ui_page, UiPage::Mail));
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(state.ui_page, UiPage::CodeBrowser));
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(state.ui_page, UiPage::Mail));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn kernel_tree_enter_expands_and_collapses_selected_directory() {
let tree_root = temp_dir("kernel-tree-expand");
let dir_a = tree_root.join("a");
let dir_b = dir_a.join("b");
let dir_c = tree_root.join("c");
fs::create_dir_all(&dir_b).expect("create nested directory");
fs::create_dir_all(&dir_c).expect("create sibling directory");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(state.ui_page, UiPage::CodeBrowser));
let index_a = state
.kernel_tree_rows
.iter()
.position(|row| row.path == dir_a)
.expect("directory a row exists");
state.kernel_tree_row_index = index_a;
assert!(state.kernel_tree_rows[index_a].expandable);
assert!(!state.kernel_tree_rows.iter().any(|row| row.path == dir_b));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.kernel_tree_rows.iter().any(|row| row.path == dir_b));
let index_a_after_expand = state
.kernel_tree_rows
.iter()
.position(|row| row.path == dir_a)
.expect("directory a row exists after expand");
state.kernel_tree_row_index = index_a_after_expand;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(!state.kernel_tree_rows.iter().any(|row| row.path == dir_b));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn kernel_tree_lists_files_and_source_preview_preserves_indentation() {
let tree_root = temp_dir("kernel-tree-files");
let dir_a = tree_root.join("a");
let file_path = dir_a.join("demo.c");
fs::create_dir_all(&dir_a).expect("create directory");
fs::write(
&file_path,
"fn demo() {\n\tif true {\n return;\n\t}\n}\n",
)
.expect("write source file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(state.ui_page, UiPage::CodeBrowser));
let index_a = state
.kernel_tree_rows
.iter()
.position(|row| row.path == dir_a)
.expect("directory a row exists");
state.kernel_tree_row_index = index_a;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("file row exists");
state.kernel_tree_row_index = file_index;
let preview = load_source_file_preview(&file_path);
assert!(preview.contains(" if true {"));
assert!(preview.contains(" return;"));
assert!(!preview.contains('\t'));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn code_edit_mode_enters_only_on_source_file_focus() {
let tree_root = temp_dir("code-edit-enter");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "fn demo() {}\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(state.ui_page, UiPage::CodeBrowser));
state.code_focus = CodePaneFocus::Tree;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::Browse));
assert!(state.status.contains("select a source file"));
state.code_focus = CodePaneFocus::Source;
state.kernel_tree_row_index = 0;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::Browse));
assert!(state.status.contains("select a source file"));
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal));
assert_eq!(state.code_edit_target.as_ref(), Some(&file_path));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn code_edit_insert_save_and_escape_exit_updates_file() {
let tree_root = temp_dir("code-edit-save-esc");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "alpha\nbeta\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
state.code_focus = CodePaneFocus::Source;
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::VimInsert));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('!'), KeyModifiers::SHIFT),
);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
);
let saved = fs::read_to_string(&file_path).expect("read saved file");
assert!(saved.starts_with("!alpha"));
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(state.code_edit_mode, CodeEditMode::Browse));
let preview = load_source_file_preview(&file_path);
assert!(preview.contains("!alpha"));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn code_edit_command_mode_handles_dirty_q_w_and_wq() {
let tree_root = temp_dir("code-edit-command");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "hello\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
state.code_focus = CodePaneFocus::Source;
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal));
assert!(state.code_edit_dirty);
assert!(state.status.contains("unsaved changes"));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(!state.code_edit_dirty);
let saved_once = fs::read_to_string(&file_path).expect("read saved file");
assert!(saved_once.starts_with("xhello"));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(state.code_edit_dirty);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::Browse));
let saved_twice = fs::read_to_string(&file_path).expect("read saved file");
assert!(saved_twice.starts_with("xyhello"));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn code_edit_command_mode_rejects_unsupported_command() {
let tree_root = temp_dir("code-edit-unsupported-command");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "hello\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
state.code_focus = CodePaneFocus::Source;
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal));
assert!(state.status.contains("unsupported command"));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn code_edit_command_mode_supports_force_quit_without_saving() {
let tree_root = temp_dir("code-edit-force-quit");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "hello\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
state.code_focus = CodePaneFocus::Source;
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(state.code_edit_dirty);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('!'), KeyModifiers::SHIFT),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::Browse));
assert!(state.status.contains("discarded unsaved changes"));
let disk = fs::read_to_string(&file_path).expect("read file");
assert_eq!(disk, "hello\n");
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn code_browser_external_vim_key_updates_selected_file_preview() {
let tree_root = temp_dir("external-vim-key");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "before\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
state.code_focus = CodePaneFocus::Source;
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
state.external_editor_runner = external_editor_mock_success;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT),
);
assert!(state.status.contains("external vim exited successfully"));
let preview = load_source_file_preview(&file_path);
assert!(preview.contains("externally edited"));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn code_edit_external_vim_rejects_dirty_buffer() {
let tree_root = temp_dir("external-vim-dirty");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "hello\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
state.code_focus = CodePaneFocus::Source;
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
state.external_editor_runner = external_editor_mock_success;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(state.code_edit_dirty);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT),
);
assert!(
state
.status
.contains("unsaved changes, run :w before external vim")
);
assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal));
assert!(state.code_edit_dirty);
let disk = fs::read_to_string(&file_path).expect("read file");
assert_eq!(disk, "hello\n");
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn code_edit_command_mode_vim_reloads_buffer_after_external_edit() {
let tree_root = temp_dir("external-vim-command");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "hello\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
state.code_focus = CodePaneFocus::Source;
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
state.external_editor_runner = external_editor_mock_success;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal));
assert!(!state.code_edit_dirty);
assert_eq!(
state.code_edit_buffer.first().map(String::as_str),
Some("externally edited")
);
assert!(state.status.contains("external vim exited successfully"));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn command_palette_vim_runs_external_editor() {
let tree_root = temp_dir("external-vim-palette");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "before\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
state.code_focus = CodePaneFocus::Source;
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
state.external_editor_runner = external_editor_mock_success;
state.palette.open = true;
state.palette.input = "vim".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.status.contains("external vim exited successfully"));
let preview = load_source_file_preview(&file_path);
assert!(preview.contains("externally edited"));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn external_vim_launch_failure_keeps_tui_interactive() {
let tree_root = temp_dir("external-vim-failure");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "before\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
state.code_focus = CodePaneFocus::Source;
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
state.external_editor_runner = external_editor_mock_failure;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT),
);
assert!(state.status.contains("external vim failed"));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert!(matches!(state.code_focus, CodePaneFocus::Tree));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn external_vim_marks_terminal_refresh_needed_after_return() {
let tree_root = temp_dir("external-vim-refresh");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "before\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
state.code_focus = CodePaneFocus::Source;
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
state.external_editor_runner = external_editor_mock_success;
assert!(!state.needs_terminal_refresh);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('E'), KeyModifiers::SHIFT),
);
assert!(state.needs_terminal_refresh);
assert!(state.take_terminal_refresh_needed());
assert!(!state.needs_terminal_refresh);
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn code_edit_draw_sets_terminal_cursor_position() {
let tree_root = temp_dir("code-edit-cursor");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "hello\nworld\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(vec![], runtime.clone());
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
state.code_focus = CodePaneFocus::Source;
let file_index = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_path)
.expect("find source file");
state.kernel_tree_row_index = file_index;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal));
let mut terminal = Terminal::new(TestBackend::new(180, 30)).expect("create test terminal");
let mut expected_cursor: Option<(u16, u16)> = None;
terminal
.draw(|frame| {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(10),
Constraint::Length(1),
])
.split(frame.area());
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(areas[1]);
let inner_area = Rect::new(
panes[1].x + 1,
panes[1].y + 1,
panes[1].width.saturating_sub(2),
panes[1].height.saturating_sub(2),
);
expected_cursor = code_edit_cursor_position(&state, inner_area);
draw(frame, &state, &runtime, &bootstrap);
})
.expect("draw frame");
let expected = expected_cursor.expect("cursor position should be visible");
terminal
.backend_mut()
.assert_cursor_position(Position::new(expected.0, expected.1));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn code_browser_navigation_keys_unchanged_when_not_editing() {
let tree_root = temp_dir("code-edit-regression");
let file_path = tree_root.join("demo.rs");
fs::write(&file_path, "line1\nline2\n").expect("write demo file");
let runtime = test_runtime_with_kernel_tree(tree_root.clone());
let mut state = AppState::new(vec![], runtime);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(state.ui_page, UiPage::CodeBrowser));
assert!(matches!(state.code_focus, CodePaneFocus::Tree));
state.code_focus = CodePaneFocus::Source;
state.code_preview_scroll = 2;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert_eq!(state.code_preview_scroll, 1);
assert!(matches!(state.code_edit_mode, CodeEditMode::Browse));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert_eq!(state.code_preview_scroll, 2);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert!(matches!(state.code_focus, CodePaneFocus::Tree));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
assert!(matches!(state.code_focus, CodePaneFocus::Source));
let _ = fs::remove_dir_all(tree_root);
}
#[test]
fn enter_on_subscription_opens_threads_and_focuses_threads_pane_without_toggling_enabled_state() {
let root = temp_dir("enter-open-subscription");
let runtime = test_runtime_with_imap_in(root.clone());
seed_mailbox_thread(
&runtime.database_path,
"io-uring",
1,
"io-uring@example.com",
"io-uring thread",
);
let mut state = AppState::new_with_ui_state(
vec![],
runtime,
Some(UiState {
enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "io-uring".to_string()],
active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()),
..UiState::default()
}),
);
state.focus = Pane::Subscriptions;
let io_uring_index = state
.subscriptions
.iter()
.position(|item| item.mailbox == "io-uring")
.expect("io-uring subscription exists");
state.subscription_index = io_uring_index;
state.sync_subscription_row_to_selected_item();
let initial = state.subscriptions[io_uring_index].enabled;
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Continue));
assert_eq!(state.subscriptions[io_uring_index].enabled, initial);
assert!(matches!(state.focus, Pane::Threads));
assert_eq!(state.active_thread_mailbox, "io-uring");
assert_eq!(state.threads.len(), 1);
let _ = fs::remove_dir_all(root);
}
#[test]
fn enter_on_group_header_toggles_expand_and_collapse() {
let mut state = AppState::new(vec![], test_runtime());
state.focus = Pane::Subscriptions;
state.subscription_row_index = 0;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(!state.enabled_group_expanded);
let rows_after_collapse = state.subscription_rows();
assert!(rows_after_collapse[0].text.starts_with('â–¶'));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.enabled_group_expanded);
let rows_after_expand = state.subscription_rows();
assert!(rows_after_expand[0].text.starts_with('â–¼'));
}
#[test]
fn enter_on_category_header_toggles_expand_and_collapse() {
let mut state = AppState::new(vec![], test_runtime());
state.focus = Pane::Subscriptions;
state.subscription_row_index = state
.subscription_rows()
.iter()
.position(|row| row.text.contains("linux subsystem"))
.expect("linux category header exists");
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(!state.disabled_linux_subsystem_expanded);
let rows_after_collapse = state.subscription_rows();
let linux_header_after_collapse = rows_after_collapse
.iter()
.find(|row| row.text.contains("linux subsystem"))
.expect("linux category header exists after collapse");
assert!(linux_header_after_collapse.text.starts_with(" â–¶"));
assert!(
!rows_after_collapse
.iter()
.any(|row| row.text.contains("[n] io-uring"))
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.disabled_linux_subsystem_expanded);
let rows_after_expand = state.subscription_rows();
let linux_header_after_expand = rows_after_expand
.iter()
.find(|row| row.text.contains("linux subsystem"))
.expect("linux category header exists after expand");
assert!(linux_header_after_expand.text.starts_with(" â–¼"));
assert!(
rows_after_expand
.iter()
.any(|row| row.text.contains("[n] io-uring"))
);
}
#[test]
fn first_open_starts_with_all_subscriptions_disabled() {
let state = AppState::new(vec![], test_runtime());
assert!(state.subscriptions.iter().all(|item| !item.enabled));
}
#[test]
fn subscription_rows_show_linux_and_qemu_categories() {
let state = AppState::new(vec![], test_runtime());
let rows = state.subscription_rows();
let linux_header = rows
.iter()
.position(|row| row.text.contains("linux subsystem"))
.expect("linux category header exists");
let qemu_header = rows
.iter()
.position(|row| row.text.contains("qemu subsystem"))
.expect("qemu category header exists");
let qemu_devel = rows
.iter()
.position(|row| row.text.contains("[n] qemu-devel"))
.expect("qemu-devel row exists");
assert!(linux_header < qemu_header);
assert!(qemu_header < qemu_devel);
}
#[test]
fn qemu_mailbox_case_variants_reuse_the_default_subscription() {
let mut runtime = test_runtime();
runtime.source_mailbox = "QEMU-devel".to_string();
let state = AppState::new_with_ui_state(
vec![],
runtime,
Some(UiState {
enabled_mailboxes: vec!["QEMU-devel".to_string()],
active_mailbox: Some("QEMU-devel".to_string()),
..UiState::default()
}),
);
let qemu_devel_items: Vec<&SubscriptionItem> = state
.subscriptions
.iter()
.filter(|item| item.mailbox.eq_ignore_ascii_case("qemu-devel"))
.collect();
assert_eq!(qemu_devel_items.len(), 1);
assert_eq!(qemu_devel_items[0].mailbox, "qemu-devel");
assert!(qemu_devel_items[0].enabled);
assert_eq!(
qemu_devel_items[0].category,
Some(SubscriptionCategory::QemuSubsystem)
);
assert_eq!(
state.subscriptions[state.subscription_index].mailbox,
"qemu-devel"
);
}
#[test]
fn first_open_with_complete_imap_enables_my_inbox() {
let state = AppState::new(vec![], test_runtime_with_imap());
let my_inbox = state
.subscriptions
.iter()
.find(|item| item.mailbox == IMAP_INBOX_MAILBOX)
.expect("my inbox subscription exists");
assert!(my_inbox.enabled);
assert_eq!(my_inbox.label, MY_INBOX_LABEL);
assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX);
}
#[test]
fn app_state_restores_and_re_persists_mail_pane_layout_from_ui_state() {
let state = AppState::new_with_ui_state(
vec![],
test_runtime(),
Some(UiState {
mail_subscriptions_width: 29,
mail_preview_width: 82,
..UiState::default()
}),
);
assert_eq!(state.mail_pane_layout.subscriptions_width, 29);
assert_eq!(state.mail_pane_layout.preview_width, 82);
let persisted = state.to_ui_state();
assert_eq!(persisted.mail_subscriptions_width, 29);
assert_eq!(persisted.mail_preview_width, 82);
}
#[test]
fn legacy_ui_state_with_complete_imap_enables_my_inbox_once() {
let state = AppState::new_with_ui_state(
vec![],
test_runtime_with_imap(),
Some(UiState {
enabled_mailboxes: vec!["io-uring".to_string()],
enabled_group_expanded: true,
disabled_group_expanded: true,
enabled_linux_subsystem_expanded: true,
enabled_qemu_subsystem_expanded: true,
disabled_linux_subsystem_expanded: true,
disabled_qemu_subsystem_expanded: true,
imap_defaults_initialized: false,
active_mailbox: Some("io-uring".to_string()),
..UiState::default()
}),
);
let my_inbox = state
.subscriptions
.iter()
.find(|item| item.mailbox == IMAP_INBOX_MAILBOX)
.expect("my inbox subscription exists");
assert!(my_inbox.enabled);
assert!(state.imap_defaults_initialized);
}
#[test]
fn initialized_ui_state_keeps_my_inbox_disabled_when_user_opted_out() {
let state = AppState::new_with_ui_state(
vec![],
test_runtime_with_imap(),
Some(UiState {
enabled_mailboxes: vec!["io-uring".to_string()],
enabled_group_expanded: true,
disabled_group_expanded: true,
enabled_linux_subsystem_expanded: true,
enabled_qemu_subsystem_expanded: true,
disabled_linux_subsystem_expanded: true,
disabled_qemu_subsystem_expanded: true,
imap_defaults_initialized: true,
active_mailbox: Some("io-uring".to_string()),
..UiState::default()
}),
);
let my_inbox = state
.subscriptions
.iter()
.find(|item| item.mailbox == IMAP_INBOX_MAILBOX)
.expect("my inbox subscription exists");
assert!(!my_inbox.enabled);
assert!(state.imap_defaults_initialized);
}
#[test]
fn catch_sync_panic_converts_panics_into_errors() {
let error = catch_sync_panic("INBOX", || -> crate::infra::error::Result<()> {
panic!("boom");
})
.expect_err("panic should become criew error");
assert!(error.to_string().contains("sync panicked for INBOX: boom"));
}
#[test]
fn empty_active_inbox_recovers_to_cached_enabled_mailbox() {
let root = temp_dir("imap-fallback-cache");
let runtime = test_runtime_with_imap_in(root.clone());
seed_mailbox_thread(
&runtime.database_path,
"kvm",
1,
"kvm@example.com",
"kvm thread",
);
let mut state = AppState::new_with_ui_state(
vec![],
runtime,
Some(UiState {
enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "kvm".to_string()],
enabled_group_expanded: true,
disabled_group_expanded: true,
enabled_linux_subsystem_expanded: true,
enabled_qemu_subsystem_expanded: true,
disabled_linux_subsystem_expanded: true,
disabled_qemu_subsystem_expanded: true,
imap_defaults_initialized: true,
active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()),
..UiState::default()
}),
);
assert!(state.recover_from_empty_active_mailbox("inbox unavailable"));
assert_eq!(state.active_thread_mailbox, "kvm");
assert_eq!(state.threads.len(), 1);
assert!(state.status.contains("showing threads for kvm"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn startup_sync_failure_for_empty_inbox_falls_back_to_cached_mailbox() {
let root = temp_dir("imap-fallback-startup");
let runtime = test_runtime_with_imap_in(root.clone());
seed_mailbox_thread(
&runtime.database_path,
"io-uring",
1,
"io-uring@example.com",
"io_uring thread",
);
let mut state = AppState::new_with_ui_state(
vec![],
runtime,
Some(UiState {
enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "io-uring".to_string()],
enabled_group_expanded: true,
disabled_group_expanded: true,
enabled_linux_subsystem_expanded: true,
enabled_qemu_subsystem_expanded: true,
disabled_linux_subsystem_expanded: true,
disabled_qemu_subsystem_expanded: true,
imap_defaults_initialized: true,
active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()),
..UiState::default()
}),
);
state.apply_startup_sync_event(StartupSyncEvent::MailboxFailed {
mailbox: IMAP_INBOX_MAILBOX.to_string(),
error: "imap unavailable".to_string(),
});
assert_eq!(state.active_thread_mailbox, "io-uring");
assert_eq!(state.threads.len(), 1);
assert!(state.status.contains("showing threads for io-uring"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn command_palette_sync_queues_background_job_and_resets_my_inbox_auto_sync_deadline() {
let mut state = AppState::new(vec![], test_runtime_with_imap());
state.manual_sync_spawner = manual_sync_spawner_idle;
state
.inbox_auto_sync
.as_mut()
.expect("inbox auto-sync state")
.next_due_at = Instant::now() - Duration::from_secs(1);
run_palette_sync(&mut state, "sync INBOX");
assert!(state.status.contains("sync queued in background"));
assert!(state.manual_sync.is_some());
assert!(
state
.inbox_auto_sync
.as_ref()
.expect("inbox auto-sync state")
.next_due_at
> Instant::now() + Duration::from_secs(20)
);
}
#[test]
fn opening_empty_inbox_queues_background_sync_and_defers_next_auto_sync_tick() {
let root = temp_dir("imap-open-inbox-sync");
let runtime = test_runtime_with_imap_in(root.clone());
fs::create_dir_all(runtime.database_path.parent().expect("db parent"))
.expect("create db parent");
db::initialize(&runtime.database_path).expect("initialize db");
let mut state = AppState::new(vec![], runtime);
state.manual_sync_spawner = manual_sync_spawner_idle;
state
.inbox_auto_sync
.as_mut()
.expect("inbox auto-sync state")
.next_due_at = Instant::now() - Duration::from_secs(1);
state.open_threads_for_selected_subscription();
assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX);
assert!(state.threads.is_empty());
assert!(state.status.contains("syncing in background"));
assert!(state.manual_sync.is_some());
assert!(matches!(state.focus, Pane::Threads));
assert!(
state
.inbox_auto_sync
.as_ref()
.expect("inbox auto-sync state")
.next_due_at
> Instant::now() + Duration::from_secs(20)
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn manual_sync_same_mailbox_request_reports_already_syncing() {
let mut state = AppState::new(vec![], test_runtime_with_imap());
state.manual_sync = Some(manual_sync_state(&[(
IMAP_INBOX_MAILBOX,
StartupSyncMailboxStatus::InFlight,
)]));
let outcome =
state.start_manual_sync(vec!["inbox".to_string()], ManualSyncOrigin::PaletteCommand);
assert_eq!(outcome, ManualSyncRequestOutcome::AlreadySyncing);
assert!(state.status.contains("sync already running in background"));
}
#[test]
fn manual_sync_different_mailbox_request_reports_busy() {
let mut state = AppState::new(vec![], test_runtime_with_imap());
state.manual_sync = Some(manual_sync_state(&[(
"io-uring",
StartupSyncMailboxStatus::InFlight,
)]));
let outcome = state.start_manual_sync(
vec![IMAP_INBOX_MAILBOX.to_string()],
ManualSyncOrigin::PaletteCommand,
);
assert_eq!(outcome, ManualSyncRequestOutcome::Busy);
assert!(state.status.contains("background sync busy"));
assert!(state.status.contains("0/1"));
}
#[test]
fn manual_sync_dedups_case_variants_and_defers_auto_sync_deadlines() {
let mut state = AppState::new(vec![], test_runtime_with_imap());
state.manual_sync_spawner = manual_sync_spawner_idle;
let io_uring_index = state
.subscriptions
.iter()
.position(|item| item.mailbox == "io-uring")
.expect("io-uring subscription exists");
state.subscriptions[io_uring_index].enabled = true;
state.reconcile_subscription_auto_sync();
state
.inbox_auto_sync
.as_mut()
.expect("inbox auto-sync state")
.next_due_at = Instant::now() - Duration::from_secs(1);
state
.subscription_auto_sync
.as_mut()
.expect("subscription auto-sync state")
.next_due_at = Instant::now() - Duration::from_secs(1);
let outcome = state.start_manual_sync(
vec![
IMAP_INBOX_MAILBOX.to_string(),
"inbox".to_string(),
"io-uring".to_string(),
"IO-URING".to_string(),
],
ManualSyncOrigin::PaletteCommand,
);
assert_eq!(outcome, ManualSyncRequestOutcome::Started);
let sync_state = state.manual_sync.as_ref().expect("manual sync state");
assert_eq!(
sync_state.mailbox_order,
vec!["INBOX".to_string(), "io-uring".to_string()]
);
assert!(
state
.inbox_auto_sync
.as_ref()
.expect("inbox auto-sync state")
.next_due_at
> Instant::now() + Duration::from_secs(20)
);
assert!(
state
.subscription_auto_sync
.as_ref()
.expect("subscription auto-sync state")
.next_due_at
> Instant::now() + Duration::from_secs(20)
);
}
#[test]
fn command_palette_sync_queues_background_job_and_resets_subscription_auto_sync_deadline() {
let mut state = AppState::new(vec![], test_runtime());
state.manual_sync_spawner = manual_sync_spawner_idle;
let io_uring_index = state
.subscriptions
.iter()
.position(|item| item.mailbox == "io-uring")
.expect("io-uring subscription exists");
state.subscriptions[io_uring_index].enabled = true;
state.reconcile_subscription_auto_sync();
state
.subscription_auto_sync
.as_mut()
.expect("subscription auto-sync state")
.next_due_at = Instant::now() - Duration::from_secs(1);
run_palette_sync(&mut state, "sync io-uring");
assert!(state.status.contains("sync queued in background"));
assert!(state.manual_sync.is_some());
assert!(
state
.subscription_auto_sync
.as_ref()
.expect("subscription auto-sync state")
.next_due_at
> Instant::now() + Duration::from_secs(20)
);
}
#[test]
fn manual_sync_completion_refreshes_active_mailbox_after_worker_finishes() {
let root = temp_dir("manual-sync-finish-refresh");
let runtime = test_runtime_with_imap_in(root.clone());
fs::create_dir_all(runtime.database_path.parent().expect("db parent"))
.expect("create db parent");
db::initialize(&runtime.database_path).expect("initialize db");
let mut state = AppState::new(vec![], runtime);
state.manual_sync_spawner = manual_sync_spawner_seed_success;
state.open_threads_for_selected_subscription();
assert!(state.threads.is_empty());
assert!(state.manual_sync.is_some());
state.pump_manual_sync_events();
assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX);
assert_eq!(state.threads.len(), 1);
assert!(state.status.contains("sync finished: ok=1"));
assert!(state.manual_sync.is_none());
let _ = fs::remove_dir_all(root);
}
#[test]
fn manual_sync_failure_finishes_with_first_error_summary() {
let mut state = AppState::new(vec![], test_runtime_with_imap());
state.manual_sync = Some(manual_sync_state(&[(
IMAP_INBOX_MAILBOX,
StartupSyncMailboxStatus::Pending,
)]));
state.apply_manual_sync_event(StartupSyncEvent::MailboxFailed {
mailbox: IMAP_INBOX_MAILBOX.to_string(),
error: "imap unavailable".to_string(),
});
assert!(state.manual_sync.is_none());
assert_eq!(state.status, "sync failed: INBOX: imap unavailable");
}
#[test]
fn manual_sync_partial_failure_reports_partial_summary() {
let mut state = AppState::new(vec![], test_runtime());
state.manual_sync = Some(manual_sync_state(&[
("io-uring", StartupSyncMailboxStatus::Pending),
("kvm", StartupSyncMailboxStatus::Pending),
]));
state.apply_manual_sync_event(StartupSyncEvent::MailboxFinished {
mailbox: "io-uring".to_string(),
fetched: 2,
inserted: 1,
updated: 0,
});
assert!(state.manual_sync.is_some());
state.apply_manual_sync_event(StartupSyncEvent::MailboxFailed {
mailbox: "kvm".to_string(),
error: "network timeout".to_string(),
});
assert!(state.manual_sync.is_none());
assert!(state.status.contains("sync finished with failures"));
assert!(state.status.contains("ok=1 failed=1"));
assert!(state.status.contains("fetched=2 inserted=1 updated=0"));
}
#[test]
fn manual_sync_worker_disconnect_reports_failure_summary() {
let mut state = AppState::new(vec![], test_runtime());
state.manual_sync_spawner = manual_sync_spawner_idle;
let outcome = state.start_manual_sync(
vec!["io-uring".to_string()],
ManualSyncOrigin::PaletteCommand,
);
assert_eq!(outcome, ManualSyncRequestOutcome::Started);
state.pump_manual_sync_events();
assert!(state.manual_sync.is_none());
assert!(state.status.contains("background sync worker disconnected"));
}
#[test]
fn enter_on_mailbox_pending_startup_sync_stays_non_blocking() {
let root = temp_dir("imap-pending-enter");
let runtime = test_runtime_with_imap_in(root.clone());
fs::create_dir_all(runtime.database_path.parent().expect("db parent"))
.expect("create db parent");
db::initialize(&runtime.database_path).expect("initialize db");
let mut state = AppState::new_with_ui_state(
vec![],
runtime,
Some(UiState {
enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string()],
enabled_group_expanded: true,
disabled_group_expanded: true,
enabled_linux_subsystem_expanded: true,
enabled_qemu_subsystem_expanded: true,
disabled_linux_subsystem_expanded: true,
disabled_qemu_subsystem_expanded: true,
imap_defaults_initialized: true,
active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()),
..UiState::default()
}),
);
state.focus = Pane::Subscriptions;
state.startup_sync = Some(startup_sync_state(&[(
IMAP_INBOX_MAILBOX,
StartupSyncMailboxStatus::InFlight,
)]));
state.open_threads_for_selected_subscription();
assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX);
assert!(matches!(state.focus, Pane::Threads));
assert!(state.threads.is_empty());
assert!(state.status.contains("syncing in background"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn enter_on_mailbox_pending_manual_sync_stays_non_blocking() {
let root = temp_dir("imap-pending-manual-enter");
let runtime = test_runtime_with_imap_in(root.clone());
fs::create_dir_all(runtime.database_path.parent().expect("db parent"))
.expect("create db parent");
db::initialize(&runtime.database_path).expect("initialize db");
let mut state = AppState::new_with_ui_state(
vec![],
runtime,
Some(UiState {
enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string()],
enabled_group_expanded: true,
disabled_group_expanded: true,
enabled_linux_subsystem_expanded: true,
enabled_qemu_subsystem_expanded: true,
disabled_linux_subsystem_expanded: true,
disabled_qemu_subsystem_expanded: true,
imap_defaults_initialized: true,
active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()),
..UiState::default()
}),
);
state.focus = Pane::Subscriptions;
state.manual_sync = Some(manual_sync_state(&[(
IMAP_INBOX_MAILBOX,
StartupSyncMailboxStatus::InFlight,
)]));
state.open_threads_for_selected_subscription();
assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX);
assert!(state.threads.is_empty());
assert!(state.status.contains("syncing in background"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn opening_empty_mailbox_while_other_manual_sync_is_busy_shows_busy_hint() {
let root = temp_dir("manual-sync-busy-open");
let runtime = test_runtime_with_imap_in(root.clone());
fs::create_dir_all(runtime.database_path.parent().expect("db parent"))
.expect("create db parent");
db::initialize(&runtime.database_path).expect("initialize db");
let mut state = AppState::new(vec![], runtime);
state.manual_sync = Some(manual_sync_state(&[(
"io-uring",
StartupSyncMailboxStatus::InFlight,
)]));
state.open_threads_for_selected_subscription();
assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX);
assert!(state.threads.is_empty());
assert!(state.status.contains("another background sync is running"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn background_success_does_not_steal_focus_from_pending_inbox() {
let root = temp_dir("imap-pending-focus");
let runtime = test_runtime_with_imap_in(root.clone());
seed_mailbox_thread(
&runtime.database_path,
"kvm",
1,
"kvm@example.com",
"kvm thread",
);
let mut state = AppState::new_with_ui_state(
vec![],
runtime,
Some(UiState {
enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "kvm".to_string()],
enabled_group_expanded: true,
disabled_group_expanded: true,
enabled_linux_subsystem_expanded: true,
enabled_qemu_subsystem_expanded: true,
disabled_linux_subsystem_expanded: true,
disabled_qemu_subsystem_expanded: true,
imap_defaults_initialized: true,
active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()),
..UiState::default()
}),
);
state.startup_sync = Some(startup_sync_state(&[
(IMAP_INBOX_MAILBOX, StartupSyncMailboxStatus::InFlight),
("kvm", StartupSyncMailboxStatus::Pending),
]));
state.apply_startup_sync_event(StartupSyncEvent::MailboxFinished {
mailbox: "kvm".to_string(),
fetched: 1,
inserted: 1,
updated: 0,
});
assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX);
assert!(state.threads.is_empty());
let _ = fs::remove_dir_all(root);
}
#[test]
fn y_and_n_toggle_subscription_and_keep_grouped_sort_order() {
let mut state = AppState::new(vec![], test_runtime());
state.focus = Pane::Subscriptions;
let target_index = state
.subscriptions
.iter()
.position(|item| item.mailbox == "bpf")
.expect("bpf subscription exists");
state.subscription_index = target_index;
state.sync_subscription_row_to_selected_item();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
);
let bpf_after_enable = state
.subscriptions
.iter()
.position(|item| item.mailbox == "bpf")
.expect("bpf exists after enable");
assert!(state.subscriptions[bpf_after_enable].enabled);
let first_disabled = state
.subscriptions
.iter()
.position(|item| !item.enabled)
.expect("has disabled subscriptions");
assert!(bpf_after_enable < first_disabled);
let enabled_group = &state.subscriptions[..first_disabled];
assert!(
enabled_group
.windows(2)
.all(|pair| subscription_sort_key(&pair[0]) <= subscription_sort_key(&pair[1]))
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
);
let bpf_after_disable = state
.subscriptions
.iter()
.position(|item| item.mailbox == "bpf")
.expect("bpf exists after disable");
assert!(!state.subscriptions[bpf_after_disable].enabled);
let last_enabled = state.subscriptions.iter().rposition(|item| item.enabled);
if let Some(last_enabled) = last_enabled {
assert!(bpf_after_disable > last_enabled);
let disabled_group = &state.subscriptions[last_enabled + 1..];
assert!(
disabled_group
.windows(2)
.all(|pair| subscription_sort_key(&pair[0]) <= subscription_sort_key(&pair[1]))
);
} else {
assert!(state.subscriptions.iter().all(|item| !item.enabled));
assert!(
state
.subscriptions
.windows(2)
.all(|pair| subscription_sort_key(&pair[0]) <= subscription_sort_key(&pair[1]))
);
}
}
#[test]
fn slash_opens_search_and_filters_threads() {
let mut state = AppState::new(
vec![
sample_thread("[PATCH] mm cleanup", "a@example.com", 0),
sample_thread("[PATCH] net fix", "b@example.com", 0),
],
test_runtime(),
);
let action_search = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
);
assert!(matches!(action_search, LoopAction::Continue));
assert!(state.search.active);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(!state.search.active);
assert_eq!(state.filtered_thread_indices.len(), 1);
let selected = state.selected_thread().expect("selected thread");
assert_eq!(selected.message_id, "b@example.com");
}
#[test]
fn search_on_code_browser_reports_mail_only_scope() {
let mut state = AppState::new(vec![], test_runtime());
state.ui_page = UiPage::CodeBrowser;
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Continue));
assert!(!state.search.active);
assert_eq!(state.status, "search is only available on mail page");
}
#[test]
fn search_backspace_and_escape_clear_pending_query() {
let mut state = AppState::new(vec![], test_runtime());
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
);
type_text(&mut state, "ab");
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(state.search.input, "a");
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!state.search.active);
assert!(state.search.input.is_empty());
assert_eq!(state.status, "search cancelled");
}
#[test]
fn ctrl_backtick_closes_open_palette() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "help".to_string();
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('`'), KeyModifiers::CONTROL),
);
assert!(matches!(action, LoopAction::Continue));
assert!(!state.palette.open);
assert!(state.palette.input.is_empty());
assert_eq!(state.status, "command palette closed");
}
#[test]
fn palette_reports_empty_and_unknown_commands() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = " ".to_string();
let empty_action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(empty_action, LoopAction::Continue));
assert_eq!(state.status, "empty command");
state.palette.open = true;
state.palette.input = "wat".to_string();
let unknown_action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(unknown_action, LoopAction::Continue));
assert_eq!(state.status, "unknown command: wat");
}
#[test]
fn palette_escape_backspace_and_char_input_update_buffer() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "he".to_string();
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(state.palette.input, "h");
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert_eq!(state.palette.input, "hi");
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!state.palette.open);
assert!(state.palette.input.is_empty());
}
#[test]
fn palette_sync_command_runs_via_handle_key_event() {
let mut state = AppState::new(vec![], test_runtime());
state.manual_sync_spawner = manual_sync_spawner_idle;
state.palette.open = true;
state.palette.input = "sync io-uring".to_string();
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Continue));
assert!(state.status.contains("sync queued in background"));
assert!(state.manual_sync.is_some());
assert!(!state.palette.open);
assert!(state.palette.input.is_empty());
}
#[test]
fn palette_bang_reports_empty_local_command() {
let mut state = AppState::new(vec![], test_runtime());
state.palette.open = true;
state.palette.input = "! ".to_string();
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Continue));
assert_eq!(state.status, "empty local command after !");
}
#[test]
fn enter_on_thread_focuses_preview_and_sets_selected_status_message() {
let mut state = AppState::new(
vec![sample_thread("normal mail", "plain@example.com", 0)],
test_runtime(),
);
state.focus = Pane::Threads;
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Continue));
assert!(matches!(state.focus, Pane::Preview));
assert_eq!(state.status, "selected plain@example.com");
}
#[test]
fn escape_quit_and_ctrl_c_show_exit_guidance() {
let mut state = AppState::new(vec![], test_runtime());
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(
state.status,
"open command palette with : (preferred) or Ctrl+`"
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert_eq!(
state.status,
"q emergency exit disabled; use command palette quit/exit"
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
);
assert_eq!(
state.status,
"Ctrl+C is disabled, use command palette quit/exit"
);
}
#[test]
fn jl_focus_and_ik_move_selection() {
let mut state = AppState::new(
vec![
sample_thread("t0", "a@example.com", 0),
sample_thread("t1", "b@example.com", 1),
],
test_runtime(),
);
state.subscription_index = 1;
let action_l = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
assert!(matches!(action_l, LoopAction::Continue));
assert!(matches!(state.focus, Pane::Threads));
let action_i = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert!(matches!(action_i, LoopAction::Continue));
assert_eq!(state.thread_index, 0);
let action_k = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert!(matches!(action_k, LoopAction::Continue));
assert_eq!(state.thread_index, 1);
let action_j = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert!(matches!(action_j, LoopAction::Continue));
assert!(matches!(state.focus, Pane::Subscriptions));
}
#[test]
fn vim_keymap_uses_hl_focus_and_jk_move_selection() {
let mut runtime = test_runtime();
runtime.ui_keymap = UiKeymap::Vim;
let mut state = AppState::new(
vec![
sample_thread("t0", "a@example.com", 0),
sample_thread("t1", "b@example.com", 1),
],
runtime,
);
state.subscription_index = 1;
let action_l = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
);
assert!(matches!(action_l, LoopAction::Continue));
assert!(matches!(state.focus, Pane::Threads));
let action_j = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert!(matches!(action_j, LoopAction::Continue));
assert_eq!(state.thread_index, 1);
let action_k = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert!(matches!(action_k, LoopAction::Continue));
assert_eq!(state.thread_index, 0);
let action_h = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
);
assert!(matches!(action_h, LoopAction::Continue));
assert!(matches!(state.focus, Pane::Subscriptions));
}
#[test]
fn a_d_and_u_require_patch_series_or_apply_snapshot_on_thread_focus() {
let mut state = AppState::new(
vec![sample_thread("normal mail", "plain@example.com", 0)],
test_runtime(),
);
state.focus = Pane::Threads;
let action_apply = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
);
assert!(matches!(action_apply, LoopAction::Continue));
assert!(state.status.contains("not a patch series"));
let action_download = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
);
assert!(matches!(action_download, LoopAction::Continue));
assert!(state.status.contains("not a patch series"));
let action_undo = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE),
);
assert!(matches!(action_undo, LoopAction::Continue));
assert!(state.status.contains("no apply action to undo"));
}
#[test]
fn inline_ui_text_collapses_multiline_errors() {
let sanitized = sanitize_inline_ui_text(
"sync failed:\nCould not automatically determine provider\r\n\tline2",
);
assert_eq!(
sanitized,
"sync failed: Could not automatically determine provider line2"
);
}
#[test]
fn preview_hides_rfc_headers_and_keeps_body() {
let raw = b"Message-ID: <a@example.com>\r\nSubject: test\r\nFrom: a@example.com\r\n\r\nhello\nworld\n";
let preview = extract_mail_body_preview(raw);
assert!(!preview.contains("Message-ID:"));
assert!(!preview.contains("Subject: test"));
assert!(preview.contains("hello"));
assert!(preview.contains("world"));
}
#[test]
fn preview_skips_first_mime_part_headers() {
let raw = b"Content-Type: multipart/alternative; boundary=\"abc\"\r\n\r\n--abc\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\nplain body line\r\n--abc--\r\n";
let preview = extract_mail_body_preview(raw);
assert!(!preview.contains("Content-Transfer-Encoding"));
assert!(preview.contains("plain body line"));
}
#[test]
fn preview_strips_control_characters() {
let raw =
b"Message-ID: <a@example.com>\r\nSubject: test\r\n\r\nline1\x1b[31m\x07\nline2\tok\r\n";
let preview = extract_mail_body_preview(raw);
assert!(!preview.contains('\u{001b}'));
assert!(!preview.contains('\u{0007}'));
assert!(!preview.contains('\t'));
assert!(preview.contains("line1"));
assert!(preview.contains("line2 ok"));
}
#[test]
fn preview_shows_from_sent_to_cc_headers() {
let raw = b"From: Chen Miao <chenmiao.ku@gmail.com>\r\nDate: Monday, March 2, 2026 5:29 PM\r\nTo: Daniel Baluta <daniel.baluta@nxp.com>; Simona Toaca <simona.toaca@nxp.com>\r\nCc: Team One <team1@example.com>\r\nSubject: [PATCH] demo\r\n\r\nmail body line\n";
let preview = extract_mail_preview(raw, "(no subject)", "<unknown sender>", None);
assert!(preview.contains("From: Chen Miao <chenmiao.ku@gmail.com>"));
assert!(preview.contains("Sent: Monday, March 2, 2026 5:29 PM"));
assert!(preview.contains(
"To: Daniel Baluta <daniel.baluta@nxp.com>; Simona Toaca <simona.toaca@nxp.com>"
));
assert!(preview.contains("Cc: Team One <team1@example.com>"));
assert!(preview.contains("Subject: [PATCH] demo"));
assert!(preview.contains("mail body line"));
}
#[test]
fn preview_truncates_to_and_cc_recipient_lists() {
let raw = b"From: sender@example.com\r\nDate: Tue, 3 Mar 2026 12:00:00 +0000\r\nTo: A <a@example.com>, B <b@example.com>, C <c@example.com>\r\nCc: X <x@example.com>; Y <y@example.com>; Z <z@example.com>\r\nSubject: test\r\n\r\nbody\n";
let preview = extract_mail_preview(raw, "(no subject)", "<unknown sender>", None);
assert!(preview.contains("To: A <a@example.com>; B <b@example.com>; ..."));
assert!(preview.contains("Cc: X <x@example.com>; Y <y@example.com>; ..."));
assert!(!preview.contains("C <c@example.com>"));
assert!(!preview.contains("Z <z@example.com>"));
}
#[test]
fn preview_warns_for_multipart_mail() {
let raw = b"Content-Type: multipart/alternative; boundary=\"abc\"\r\n\r\n--abc\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nplain body line\r\n--abc--\r\n";
let warning = preview_warning_message(raw).expect("warning expected");
assert!(warning.contains("NON-PLAIN-TEXT MAIL"));
assert!(warning.contains("Parse artifacts/errors are normal"));
assert!(warning.contains("Content-Type: multipart/alternative; boundary=\"abc\""));
}
#[test]
fn preview_warns_for_encoded_html_mail() {
let raw = b"Content-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n<html><body>hello</body></html>\r\n";
let warning = preview_warning_message(raw).expect("warning expected");
assert!(warning.contains("NON-PLAIN-TEXT MAIL"));
assert!(warning.contains("Content-Type: text/html; charset=utf-8"));
assert!(warning.contains("Transfer-Encoding: quoted-printable"));
}
#[test]
fn multiline_sync_error_does_not_break_footer_or_palette_render() {
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(vec![], runtime.clone());
state.status = "sync failed: E1007:\nCould not automatically determine provider".to_string();
state.palette.open = true;
let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw multiline status");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("sync failed: E1007: Could not automatically determine provider"));
assert!(rendered.contains("Command Palette"));
}
#[test]
fn header_shows_criew_brand_and_default_footer_hides_empty_status() {
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let state = AppState::new(vec![], runtime.clone());
let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw branded header");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("CRIEW"));
assert!(rendered.contains(env!("CARGO_PKG_VERSION")));
assert!(rendered.contains("Mail / inbox"));
assert!(rendered.contains("keymap default"));
assert!(!rendered.contains("db schema"));
assert!(!rendered.contains("db:"));
assert!(!rendered.contains("status:"));
assert!(!rendered.contains(" ready "));
}
#[test]
fn header_shows_custom_keymap_scheme_when_configured() {
let mut runtime = test_runtime();
runtime.ui_keymap = UiKeymap::Custom;
let bootstrap = test_bootstrap(&runtime);
let state = AppState::new(vec![], runtime.clone());
let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw custom keymap header");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("keymap custom"));
}
#[test]
fn header_formats_uptime_with_clock_units() {
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(vec![], runtime.clone());
state.started_at = Instant::now() - Duration::from_secs(61);
let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw formatted uptime header");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("up 01m:"));
assert!(!rendered.contains("up 61s"));
}
#[test]
fn startup_sync_progress_bar_renders_at_right_edge_of_header() {
let runtime = test_runtime_in(PathBuf::from("/t"));
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(vec![], runtime.clone());
state.startup_sync = Some(startup_sync_state(&[
("INBOX", StartupSyncMailboxStatus::InFlight),
("io-uring", StartupSyncMailboxStatus::Pending),
("kvm", StartupSyncMailboxStatus::Finished),
]));
state.status = "startup sync [1/3] syncing INBOX...".to_string();
let mut terminal = Terminal::new(TestBackend::new(260, 35)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw startup sync");
let rendered = format!("{}", terminal.backend());
let header_row = rendered_row_text(&terminal, 0);
let footer_row = rendered_row_text(&terminal, terminal.backend().buffer().area().height - 1);
let progress_text = sanitize_inline_ui_text(
&state
.background_sync_progress_text()
.expect("background sync progress"),
);
assert!(rendered.contains("Mail / inbox"));
assert!(rendered.contains("sync ["));
assert!(rendered.contains("1/3"));
assert!(rendered.contains("INBOX"));
assert!(rendered.contains("startup sync [1/3] syncing INBOX..."));
assert!(header_row.trim_end().ends_with(&progress_text));
assert!(header_row.ends_with(' '));
assert!(!footer_row.contains(&progress_text));
}
#[test]
fn background_sync_progress_text_prefers_manual_sync_over_other_sources() {
let mut state = AppState::new(vec![], test_runtime_with_imap());
state.manual_sync = Some(manual_sync_state(&[(
"io-uring",
StartupSyncMailboxStatus::InFlight,
)]));
state.startup_sync = Some(startup_sync_state(&[(
IMAP_INBOX_MAILBOX,
StartupSyncMailboxStatus::InFlight,
)]));
state
.inbox_auto_sync
.as_mut()
.expect("inbox auto-sync state")
.receiver = Some(mpsc::channel().1);
let progress = state
.background_sync_progress_text()
.expect("background progress");
assert!(progress.contains("0/1"));
assert!(progress.contains("io-uring"));
assert!(!progress.contains("auto INBOX"));
}
#[test]
fn background_sync_progress_text_reports_auto_sync_sources() {
let mut inbox_state = AppState::new(vec![], test_runtime_with_imap());
inbox_state
.inbox_auto_sync
.as_mut()
.expect("inbox auto-sync state")
.receiver = Some(mpsc::channel().1);
let inbox_progress = inbox_state
.background_sync_progress_text()
.expect("inbox progress");
assert!(inbox_progress.contains("auto INBOX"));
assert_eq!(inbox_progress.matches('>').count(), 3);
let mut subscription_state = AppState::new(vec![], test_runtime());
let io_uring_index = subscription_state
.subscriptions
.iter()
.position(|item| item.mailbox == "io-uring")
.expect("io-uring subscription exists");
subscription_state.subscriptions[io_uring_index].enabled = true;
subscription_state.reconcile_subscription_auto_sync();
let state = subscription_state
.subscription_auto_sync
.as_mut()
.expect("subscription auto-sync state");
state.receiver = Some(mpsc::channel().1);
state.in_flight_mailboxes.insert("io-uring".to_string());
let subscription_progress = subscription_state
.background_sync_progress_text()
.expect("subscription progress");
assert!(subscription_progress.contains("auto io-uring"));
assert_eq!(subscription_progress.matches('>').count(), 3);
}
#[test]
fn progress_bar_helpers_cover_zero_total_and_completed_states() {
let state = AppState::new(vec![], test_runtime());
let zero_total = state.render_progress_bar(0, 0);
let completed = state.render_progress_bar(3, 3);
let indeterminate = state.render_indeterminate_progress_bar();
assert_eq!(zero_total, "[............]");
assert!(completed.starts_with('['));
assert!(completed.ends_with(']'));
assert_eq!(completed.matches('=').count(), 12);
assert_eq!(completed.matches('>').count(), 0);
assert_eq!(indeterminate.len(), 14);
assert_eq!(indeterminate.matches('>').count(), 3);
}
#[test]
fn manual_sync_progress_bar_is_rendered_at_right_edge_of_header() {
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(vec![], runtime.clone());
state.manual_sync = Some(manual_sync_state(&[
(IMAP_INBOX_MAILBOX, StartupSyncMailboxStatus::InFlight),
("io-uring", StartupSyncMailboxStatus::Pending),
]));
let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw manual sync progress");
let rendered = format!("{}", terminal.backend());
let header_row = rendered_row_text(&terminal, 0);
let footer_row = rendered_row_text(&terminal, terminal.backend().buffer().area().height - 1);
let progress_text = sanitize_inline_ui_text(
&state
.background_sync_progress_text()
.expect("background sync progress"),
);
assert!(rendered.contains("sync ["));
assert!(rendered.contains("0/2"));
assert!(rendered.contains(IMAP_INBOX_MAILBOX));
assert!(header_row.trim_end().ends_with(&progress_text));
assert!(header_row.ends_with(' '));
assert!(!footer_row.contains(&progress_text));
}
#[test]
fn mail_preview_e_opens_reply_panel_with_autofilled_headers() {
let root = temp_dir("reply-open");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: CRIEW Test <criew@example.com>, Bob <bob@example.com>\r\nCc: Alice <alice@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
runtime.clone(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let panel = state.reply_panel.as_ref().expect("reply panel should open");
assert_eq!(panel.from, "CRIEW Test <criew@example.com>");
assert_eq!(panel.to, "Bob <bob@example.com>");
assert_eq!(panel.cc, "Alice <alice@example.com>");
assert_eq!(panel.subject, "Re: [PATCH] demo");
assert_eq!(panel.in_reply_to, "patch@example.com");
assert_eq!(panel.references, vec!["patch@example.com"]);
assert_eq!(panel.section, ReplySection::From);
let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw reply panel");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("Reply Panel"));
assert!(rendered.contains("focus:From"));
assert!(rendered.contains("Headers ([edit] / [read-only])"));
assert!(rendered.contains("Reply Body"));
assert!(rendered.contains("[edit] To: Bob <bob@example.com>"));
assert!(rendered.contains("[edit] Cc: Alice <alice@example.com>"));
assert!(rendered.contains("[read-only] In-Reply-To: <patch@example.com>"));
assert!(rendered.contains("Subject: Re: [PATCH] demo"));
assert!(
state
.status
.contains("edit From/To/Cc/Subject before Send Preview")
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_panel_body_renders_80_column_guide_marker() {
let root = temp_dir("reply-body-guide");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
runtime.clone(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let panel = state.reply_panel.as_mut().expect("reply panel should open");
panel.body = vec!["short line".to_string(), String::new()];
panel.body_row = 0;
let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw reply panel with guide");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("Reply Body"));
assert!(rendered.contains("80 cols"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_send_preview_requires_confirmation_before_send() {
let root = temp_dir("reply-send-gate");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let runtime = test_runtime_in(root.clone());
seed_mailbox_thread(
&runtime.database_path,
"inbox",
1,
"patch@example.com",
"[PATCH] demo",
);
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
runtime.clone(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
state.reply_send_executor = reply_send_mock_success;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
);
assert!(state.status.contains("run Send Preview and confirm first"));
assert!(
state
.reply_panel
.as_ref()
.and_then(|panel| panel.reply_notice.as_ref())
.is_some_and(|notice| notice.title == "Send Blocked")
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
assert!(
state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.preview_open)
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
);
assert!(
state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.preview_confirmed)
);
assert!(
state
.reply_panel
.as_ref()
.and_then(|panel| panel.reply_notice.as_ref())
.is_some_and(|notice| notice.title == "Ready To Send")
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
);
assert!(state.status.contains("reply sent as <sent@example.com>"));
assert!(state.reply_panel.is_none());
let record = reply_store::latest_reply_send_for_mail(&runtime.database_path, 1)
.expect("load latest reply send")
.expect("reply send record");
assert_eq!(record.status, ReplySendStatus::Sent);
assert_eq!(record.message_id, "sent@example.com");
assert_eq!(record.subject, "Re: [PATCH] demo");
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_send_blocked_notice_and_ready_notice_replace_reply_panel_view() {
let root = temp_dir("reply-notice-overlay");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
runtime.clone(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
);
let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw blocked notice");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("Send Blocked"));
assert!(rendered.contains("You must open Send Preview"));
assert!(!rendered.contains("Headers ([edit] / [read-only])"));
assert!(!rendered.contains("Reply Body"));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
);
let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw ready notice");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("Ready To Send"));
assert!(rendered.contains("Press S to send the reply"));
assert!(!rendered.contains("Headers ([edit] / [read-only])"));
assert!(!rendered.contains("Reply Body"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_send_failure_keeps_panel_open_and_persists_failure() {
let root = temp_dir("reply-send-failure");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let runtime = test_runtime_in(root.clone());
seed_mailbox_thread(
&runtime.database_path,
"inbox",
1,
"patch@example.com",
"[PATCH] demo",
);
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
runtime.clone(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
state.reply_send_executor = reply_send_mock_failure;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
);
assert!(state.status.contains("smtp auth failed"));
assert!(state.reply_panel.is_some());
let record = reply_store::latest_reply_send_for_mail(&runtime.database_path, 1)
.expect("load latest reply send")
.expect("reply send record");
assert_eq!(record.status, ReplySendStatus::Failed);
assert_eq!(record.message_id, "failed@example.com");
assert_eq!(record.error_summary.as_deref(), Some("smtp auth failed"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_preview_validation_blocks_confirm_on_missing_recipients() {
let root = temp_dir("reply-preview-validation");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
if let Some(panel) = state.reply_panel.as_mut() {
panel.to.clear();
panel.cc = "criew@example.com".to_string();
}
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
assert!(
state
.reply_panel
.as_ref()
.is_some_and(|panel| !panel.preview_errors.is_empty())
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
);
assert!(state.status.contains("cannot confirm send preview"));
assert!(
!state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.preview_confirmed)
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_send_preview_warns_but_allows_confirm_without_authored_reply_text() {
let root = temp_dir("reply-preview-empty-authored");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
runtime.clone(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
let panel = state.reply_panel.as_ref().expect("reply panel");
assert!(panel.preview_open);
assert!(panel.preview_errors.is_empty());
assert!(
panel
.preview_warnings
.iter()
.any(|value| value.contains("no authored reply content"))
);
assert!(state.status.contains("send preview warning"));
let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw warning preview");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("Send Preview [warning]"));
assert!(rendered.contains("draft has no authored reply content"));
assert!(!rendered.contains("Headers ([edit] / [read-only])"));
assert!(!rendered.contains("Reply Body"));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
);
assert!(
state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.preview_confirmed)
);
assert_eq!(state.status, "send preview confirmed; ready to send");
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_send_preview_highlights_authored_lines_and_keeps_quotes_bright() {
let root = temp_dir("reply-preview-highlighted-authored");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
runtime.clone(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
if let Some(panel) = state.reply_panel.as_mut() {
panel.body = vec![
"Looks good to me.".to_string(),
String::new(),
"On Fri, 6 Mar 2026 09:30:00 +0000, Alice wrote:".to_string(),
"> body line".to_string(),
];
panel.mark_dirty();
}
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw highlighted preview");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("Send Preview [reply highlighted]"));
assert!(rendered.contains("Your authored reply lines are highlighted below."));
assert!(!rendered.contains("Headers ([edit] / [read-only])"));
assert!(!rendered.contains("Reply Body"));
let (authored_fg, authored_bg, authored_modifier) =
rendered_cell_style_for_substring(&terminal, "Looks good to me.")
.expect("authored line style");
assert_eq!(authored_fg, Color::Black);
assert_eq!(authored_bg, Color::Yellow);
assert!(authored_modifier.contains(Modifier::BOLD));
let (quoted_fg, quoted_bg, _) =
rendered_cell_style_for_substring(&terminal, "> body line").expect("quoted line style");
assert_eq!(quoted_fg, Color::White);
assert_eq!(quoted_bg, Color::Reset);
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_send_preview_uses_edited_header_values() {
let root = temp_dir("reply-preview-edited-headers");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nCc: Carol <carol@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
if let Some(panel) = state.reply_panel.as_mut() {
panel.from = "Reviewer Bot <reviewer@example.com>".to_string();
panel.to = "Maintainer <maintainer@example.com>".to_string();
panel.cc = "List <list@example.com>".to_string();
panel.mark_dirty();
}
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
let panel = state
.reply_panel
.as_ref()
.expect("reply panel should stay open");
assert!(panel.preview_open);
assert!(panel.preview_errors.is_empty());
assert!(
panel
.preview_rendered
.contains("From: Reviewer Bot <reviewer@example.com>")
);
assert!(
panel
.preview_rendered
.contains("To: Maintainer <maintainer@example.com>")
);
assert!(
panel
.preview_rendered
.contains("Cc: List <list@example.com>")
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn mail_page_r_opens_reply_panel_from_threads_focus() {
let root = temp_dir("reply-open-r");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Threads;
state.reply_identity_resolver = reply_identity_mock;
let action = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
);
assert!(matches!(action, LoopAction::Continue));
assert!(state.reply_panel.is_some());
assert!(
state
.status
.contains("reply panel opened for <patch@example.com>")
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_notice_escape_closes_blocked_notice() {
let root = temp_dir("reply-notice-esc");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
);
assert!(
state
.reply_panel
.as_ref()
.and_then(|panel| panel.reply_notice.as_ref())
.is_some()
);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.reply_notice.is_none())
);
assert_eq!(state.status, "reply notice closed");
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_send_preview_scrolls_with_j_and_k() {
let root = temp_dir("reply-preview-scroll");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
assert!(
state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.preview_open)
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(
state
.reply_panel
.as_ref()
.expect("reply panel")
.preview_scroll,
1
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert_eq!(
state
.reply_panel
.as_ref()
.expect("reply panel")
.preview_scroll,
0
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_send_preview_escape_closes_preview() {
let root = temp_dir("reply-preview-esc");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
state
.reply_panel
.as_ref()
.is_some_and(|panel| !panel.preview_open)
);
assert_eq!(state.status, "send preview closed");
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_notice_enter_closes_blocked_notice() {
let root = temp_dir("reply-notice-enter");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(
state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.reply_notice.is_none())
);
assert_eq!(state.status, "reply notice closed");
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_command_mode_handles_empty_unsupported_and_discard_commands() {
let root = temp_dir("reply-command-mode");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert_eq!(state.status, "empty command");
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE),
);
type_text(&mut state, "zzz");
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert_eq!(state.status, "unsupported command: :zzz");
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE),
);
type_text(&mut state, "q!");
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(state.reply_panel.is_none());
assert_eq!(state.status, "discarded reply draft");
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_command_mode_escape_and_backspace_restore_normal_mode() {
let root = temp_dir("reply-command-cancel");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE),
);
type_text(&mut state, "ab");
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(
state
.reply_panel
.as_ref()
.expect("reply panel")
.command_input,
"a"
);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let panel = state.reply_panel.as_ref().expect("reply panel");
assert!(matches!(panel.mode, ReplyEditMode::Normal));
assert!(panel.command_input.is_empty());
assert_eq!(state.status, "reply command cancelled");
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_command_q_closes_clean_panel_but_blocks_dirty_draft() {
let root = temp_dir("reply-command-q");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut clean_state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
clean_state.focus = Pane::Preview;
clean_state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut clean_state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut clean_state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE),
);
type_text(&mut clean_state, "q");
let _ = handle_key_event(
&mut clean_state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(clean_state.reply_panel.is_none());
assert_eq!(clean_state.status, "closed reply panel");
let mut dirty_state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
dirty_state.focus = Pane::Preview;
dirty_state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut dirty_state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
if let Some(panel) = dirty_state.reply_panel.as_mut() {
panel.mark_dirty();
}
let _ = handle_key_event(
&mut dirty_state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE),
);
type_text(&mut dirty_state, "q");
let _ = handle_key_event(
&mut dirty_state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(dirty_state.reply_panel.is_some());
assert_eq!(
dirty_state.status,
"unsaved reply draft, run :q! to discard"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_command_preview_and_preview_enter_cover_remaining_preview_shortcuts() {
let root = temp_dir("reply-command-preview");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE),
);
type_text(&mut state, "preview");
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(
state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.preview_open)
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(
state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.preview_confirmed)
);
assert_eq!(state.status, "send preview confirmed; ready to send");
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_insert_mode_tab_and_backspace_modify_body() {
let root = temp_dir("reply-insert-tab");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
if let Some(panel) = state.reply_panel.as_mut() {
panel.section = ReplySection::Body;
panel.body = vec![String::new()];
panel.body_row = 0;
panel.cursor_col = 0;
panel.adjust_scroll();
}
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(
state.reply_panel.as_ref().expect("reply panel").body[0],
" "
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
);
assert_eq!(
state.reply_panel.as_ref().expect("reply panel").body[0],
" "
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_insert_enter_on_quote_line_starts_unquoted_reply_line() {
let root = temp_dir("reply-quote-enter");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
if let Some(panel) = state.reply_panel.as_mut() {
panel.section = ReplySection::Body;
panel.body_row = 2;
panel.cursor_col = panel.body[2].chars().count();
panel.adjust_scroll();
}
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let panel = state
.reply_panel
.as_ref()
.expect("reply panel should stay open");
assert_eq!(panel.body_row, 3);
assert_eq!(panel.cursor_col, 0);
assert_eq!(panel.body[3], "");
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_normal_mode_enter_opens_unquoted_reply_line_below_current_line_and_enters_insert() {
let root = temp_dir("reply-normal-enter");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
if let Some(panel) = state.reply_panel.as_mut() {
panel.section = ReplySection::Body;
panel.body = vec!["> xxxx".to_string(), "> yyyy".to_string()];
panel.body_row = 1;
panel.cursor_col = 0;
panel.adjust_scroll();
}
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let panel = state
.reply_panel
.as_ref()
.expect("reply panel should stay open");
assert_eq!(panel.body_row, 2);
assert_eq!(panel.cursor_col, 0);
assert_eq!(
panel.body,
vec!["> xxxx".to_string(), "> yyyy".to_string(), String::new(),]
);
assert!(matches!(panel.mode, ReplyEditMode::Insert));
assert_eq!(state.status, "reply insert mode");
let _ = fs::remove_dir_all(root);
}
#[test]
fn reply_normal_mode_o_opens_unquoted_reply_line_below_current_line_and_enters_insert() {
let root = temp_dir("reply-normal-open-below");
let raw = root.join("patch.eml");
fs::write(
&raw,
b"Message-ID: <patch@example.com>\r\nSubject: [PATCH] demo\r\nFrom: Alice <alice@example.com>\r\nTo: Bob <bob@example.com>\r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n",
)
.expect("write raw reply fixture");
let mut state = AppState::new(
vec![sample_thread_with_raw(
"[PATCH] demo",
"patch@example.com",
0,
raw.clone(),
)],
test_runtime(),
);
state.focus = Pane::Preview;
state.reply_identity_resolver = reply_identity_mock;
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
);
if let Some(panel) = state.reply_panel.as_mut() {
panel.section = ReplySection::Body;
panel.body = vec!["> xxxx".to_string(), "> yyyy".to_string()];
panel.body_row = 1;
panel.cursor_col = 0;
panel.adjust_scroll();
}
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
);
let panel = state
.reply_panel
.as_ref()
.expect("reply panel should stay open");
assert_eq!(panel.body_row, 2);
assert_eq!(panel.cursor_col, 0);
assert_eq!(
panel.body,
vec!["> xxxx".to_string(), "> yyyy".to_string(), String::new(),]
);
assert!(matches!(panel.mode, ReplyEditMode::Insert));
assert_eq!(state.status, "reply insert mode");
let _ = fs::remove_dir_all(root);
}
#[test]
fn preview_redraw_clears_stale_characters_after_thread_switch() {
let root = temp_dir("preview-clear");
let first_raw = root.join("first.eml");
let second_raw = root.join("second.eml");
fs::write(
&first_raw,
b"Message-ID: <first@example.com>\r\nSubject: first\r\nFrom: a@example.com\r\n\r\nSTALE_PREVIEW_TOKEN_123456\nlong line that should disappear\n",
)
.expect("write first raw mail");
fs::write(
&second_raw,
b"Message-ID: <second@example.com>\r\nSubject: second\r\nFrom: b@example.com\r\n\r\nshort\n",
)
.expect("write second raw mail");
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(
vec![
sample_thread_with_raw("first", "first@example.com", 0, first_raw.clone()),
sample_thread_with_raw("second", "second@example.com", 0, second_raw.clone()),
],
runtime.clone(),
);
state.focus = Pane::Threads;
let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw first frame");
let first_frame = format!("{}", terminal.backend());
assert!(first_frame.contains("STALE_PREVIEW_TOKEN_123456"));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw second frame");
let second_frame = format!("{}", terminal.backend());
assert!(!second_frame.contains("STALE_PREVIEW_TOKEN_123456"));
assert!(second_frame.contains("short"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn preview_redraw_uses_cached_mail_body_after_raw_file_is_removed() {
let root = temp_dir("preview-cache");
let raw_path = root.join("cached.eml");
fs::write(
&raw_path,
b"Message-ID: <cached@example.com>\r\nSubject: cached\r\nFrom: cache@example.com\r\n\r\ncached body line\n",
)
.expect("write raw mail");
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let state = AppState::new(
vec![sample_thread_with_raw(
"cached",
"cached@example.com",
0,
raw_path.clone(),
)],
runtime.clone(),
);
let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw first frame");
let first_frame = format!("{}", terminal.backend());
assert!(first_frame.contains("cached body line"));
fs::remove_file(&raw_path).expect("remove raw mail");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw second frame");
let second_frame = format!("{}", terminal.backend());
assert!(second_frame.contains("cached body line"));
assert!(!second_frame.contains("failed to read"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn code_source_preview_redraw_clears_stale_characters_after_file_switch() {
let root = temp_dir("code-preview-clear");
let file_a = root.join("a-long.rs");
let file_b = root.join("b-short.rs");
fs::write(
&file_a,
"fn demo() {\n let _x = \"STALE_SOURCE_TOKEN_987654\";\n}\n",
)
.expect("write file a");
fs::write(&file_b, "fn demo() {}\n").expect("write file b");
let runtime = test_runtime_with_kernel_tree(root.clone());
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(vec![], runtime.clone());
state.ui_page = UiPage::CodeBrowser;
state.code_focus = CodePaneFocus::Tree;
let index_a = state
.kernel_tree_rows
.iter()
.position(|row| row.path == file_a)
.expect("find file a");
state.kernel_tree_row_index = index_a;
let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw first frame");
let first_frame = format!("{}", terminal.backend());
assert!(first_frame.contains("STALE_SOURCE_TOKEN_987654"));
let _ = handle_key_event(
&mut state,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw second frame");
let second_frame = format!("{}", terminal.backend());
assert!(!second_frame.contains("STALE_SOURCE_TOKEN_987654"));
assert!(second_frame.contains("fn demo() {}"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn preview_render_preserves_code_indentation() {
let root = temp_dir("preview-indent");
let raw = root.join("indent.eml");
fs::write(
&raw,
b"Message-ID: <indent@example.com>\r\nSubject: indent\r\nFrom: a@example.com\r\n\r\nfn demo() {\n\tif true {\n return;\n\t}\n}\n",
)
.expect("write raw mail");
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let state = AppState::new(
vec![sample_thread_with_raw(
"indent",
"indent@example.com",
0,
raw.clone(),
)],
runtime.clone(),
);
let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw frame");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains(" if true {"));
assert!(rendered.contains(" return;"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn threads_panel_renders_thread_group_headers() {
let runtime = test_runtime();
let bootstrap = test_bootstrap(&runtime);
let mut state = AppState::new(
vec![
sample_thread_in_thread(100, 1, "thread a root", "a-root@example.com", 0),
sample_thread_in_thread(100, 2, "thread a reply", "a-reply@example.com", 1),
sample_thread_in_thread(200, 3, "thread b root", "b-root@example.com", 0),
],
runtime.clone(),
);
state.focus = Pane::Threads;
let mut terminal = Terminal::new(TestBackend::new(180, 30)).expect("create test terminal");
terminal
.draw(|frame| draw(frame, &state, &runtime, &bootstrap))
.expect("draw frame");
let rendered = format!("{}", terminal.backend());
assert!(rendered.contains("Thread 100 (2 msgs)"));
assert!(rendered.contains("Thread 200 (1 msg)"));
assert!(rendered.contains("thread a root"));
assert!(rendered.contains("thread b root"));
}