use crate::logging::reader::{LogLevel, parse};
use crate::tui::app::mission_control::log_viewer::{LogViewerState, level_for_key};
fn line(level: &str, target: &str, msg: &str) -> String {
format!("2026-09-13T01:00:00.000000+01:00 {level} ThreadId(1) {target}: {msg}")
}
fn viewer(text: &str) -> LogViewerState {
LogViewerState {
entries: parse(text),
viewport_rows: 100,
..Default::default()
}
}
fn sample() -> LogViewerState {
let text = [
line("INFO", "opencrabs::boot", "started"),
line("DEBUG", "opencrabs::brain", "thinking"),
line("ERROR", "opencrabs::channels::whatsapp", "send failed"),
line("WARN", "opencrabs::db", "slow query"),
]
.join("\n");
viewer(&text)
}
#[test]
fn the_default_level_shows_everything_the_app_actually_logs() {
let v = sample();
assert_eq!(v.min_level, LogLevel::Debug);
assert_eq!(v.visible().len(), 4);
}
#[test]
fn a_level_filter_keeps_that_level_and_worse() {
let mut v = sample();
v.set_level(LogLevel::Warn);
let levels: Vec<LogLevel> = v.visible().iter().map(|e| e.level).collect();
assert_eq!(levels, vec![LogLevel::Error, LogLevel::Warn]);
}
#[test]
fn filtering_to_error_keeps_only_errors() {
let mut v = sample();
v.set_level(LogLevel::Error);
assert_eq!(v.visible().len(), 1);
assert_eq!(v.visible()[0].message, "send failed");
}
#[test]
fn a_level_filter_never_orphans_a_continuation_line() {
let text = format!(
"{}\n{{\n \"reason\": \"timeout\"\n}}",
line("ERROR", "opencrabs::net", "request failed")
);
let mut v = viewer(&text);
v.set_level(LogLevel::Error);
let visible = v.visible();
assert_eq!(visible.len(), 1);
assert_eq!(
visible[0].continuation.len(),
3,
"the body must survive its own entry's filter"
);
assert_eq!(v.content_rows(), 4, "header plus three body rows");
}
#[test]
fn a_text_filter_and_a_level_filter_compose() {
let mut v = sample();
v.set_level(LogLevel::Warn);
for c in "whatsapp".chars() {
v.push_search(c);
}
assert_eq!(v.visible().len(), 1);
assert_eq!(v.visible()[0].level, LogLevel::Error);
}
#[test]
fn a_text_filter_matching_nothing_hides_everything() {
let mut v = sample();
for c in "zzzznope".chars() {
v.push_search(c);
}
assert!(v.visible().is_empty());
assert_eq!(v.entries.len(), 4);
}
#[test]
fn changing_a_filter_resets_scroll() {
let mut v = sample();
v.scroll = 3;
v.set_level(LogLevel::Error);
assert_eq!(v.scroll, 0);
v.scroll = 2;
v.push_search('x');
assert_eq!(v.scroll, 0);
}
#[test]
fn scroll_cannot_pass_the_last_row() {
let mut v = sample();
v.viewport_rows = 2;
assert_eq!(v.max_scroll(), 2);
v.scroll_down(999);
assert_eq!(v.scroll, 2);
}
#[test]
fn scroll_cannot_go_above_the_first_row() {
let mut v = sample();
v.scroll_up(999);
assert_eq!(v.scroll, 0);
}
#[test]
fn content_shorter_than_the_viewport_cannot_scroll() {
let mut v = sample();
v.viewport_rows = 100;
assert_eq!(v.max_scroll(), 0);
v.scroll_down(10);
assert_eq!(v.scroll, 0);
}
#[test]
fn end_lands_on_the_newest_entries() {
let mut v = sample();
v.viewport_rows = 2;
v.scroll_to_end();
assert_eq!(v.scroll, v.max_scroll());
}
#[test]
fn content_rows_counts_continuation_lines() {
let text = format!("{}\nbody one\nbody two", line("INFO", "a::b", "head"));
assert_eq!(viewer(&text).content_rows(), 3);
}
#[test]
fn backspacing_an_empty_filter_closes_the_search_box() {
let mut v = sample();
v.search_active = true;
v.pop_search();
assert!(!v.search_active);
}
#[test]
fn backspacing_a_non_empty_filter_only_removes_a_character() {
let mut v = sample();
v.search_active = true;
v.push_search('d');
v.push_search('b');
v.pop_search();
assert_eq!(v.search, "d");
assert!(v.search_active, "still typing");
}
#[test]
fn clearing_the_filter_leaves_the_search_box() {
let mut v = sample();
v.search_active = true;
v.push_search('x');
v.clear_search();
assert!(v.search.is_empty());
assert!(!v.search_active);
assert_eq!(v.visible().len(), 4);
}
#[test]
fn stepping_past_either_end_of_the_file_list_stays_put() {
let mut v = sample();
v.files = vec!["a".into(), "b".into(), "c".into()];
v.file_index = 2;
v.step_file(1);
assert_eq!(v.file_index, 2, "cannot go past the newest");
v.file_index = 0;
v.step_file(-1);
assert_eq!(v.file_index, 0, "cannot go before the oldest");
}
#[test]
fn stepping_with_no_files_is_a_no_op() {
let mut v = sample();
assert!(v.files.is_empty());
v.step_file(1);
v.step_file(-1);
assert_eq!(v.file_index, 0);
}
#[test]
fn level_keys_accept_digits_and_initials() {
assert_eq!(level_for_key('1'), Some(LogLevel::Error));
assert_eq!(level_for_key('e'), Some(LogLevel::Error));
assert_eq!(level_for_key('2'), Some(LogLevel::Warn));
assert_eq!(level_for_key('w'), Some(LogLevel::Warn));
assert_eq!(level_for_key('3'), Some(LogLevel::Info));
assert_eq!(level_for_key('i'), Some(LogLevel::Info));
assert_eq!(level_for_key('4'), Some(LogLevel::Debug));
assert_eq!(level_for_key('d'), Some(LogLevel::Debug));
}
#[test]
fn an_unbound_key_is_not_a_level() {
for c in ['/', 'q', 'z', '9', '['] {
assert_eq!(level_for_key(c), None, "{c}");
}
}
#[test]
fn the_file_name_is_readable_with_no_file_open() {
assert_eq!(LogViewerState::default().file_name(), "(no file)");
}