use crate::explorer::{self, FileEntry};
use crate::parser::DltMessage;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug, PartialEq, Clone)]
pub enum AppScreen {
Explorer,
LogViewer,
LogDetail,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Filter {
pub min_level: Option<crate::parser::LogLevel>,
pub app_id: Option<String>,
pub ctx_id: Option<String>,
pub text: Option<String>,
}
#[derive(Debug, PartialEq, Clone)]
pub enum FilterInputMode {
Text,
AppId,
CtxId,
MinLevel,
}
pub struct App {
pub screen: AppScreen,
pub explorer_items: Vec<FileEntry>,
pub explorer_selected_index: usize,
pub logs: Vec<DltMessage>,
pub filtered_log_indices: Vec<usize>,
pub logs_selected_index: usize,
pub filter: Filter,
pub filter_input_mode: Option<FilterInputMode>,
pub filter_input: String,
pub error_message: Option<String>,
pub info_message: Option<String>,
pub should_quit: bool,
pub log_receiver: Option<std::sync::mpsc::Receiver<DltMessage>>,
pub is_loading: bool,
pub connection_info: Option<String>,
pub auto_scroll: bool,
pub horizontal_scroll: usize,
pub show_time_delta: bool,
pub skipped_bytes: usize,
skipped_bytes_shared: Option<Arc<AtomicUsize>>,
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
pub fn new() -> Self {
Self {
screen: AppScreen::Explorer,
explorer_items: vec![],
explorer_selected_index: 0,
logs: vec![],
filtered_log_indices: vec![],
logs_selected_index: 0,
filter: Filter::default(),
filter_input_mode: None,
filter_input: String::new(),
error_message: None,
info_message: None,
should_quit: false,
log_receiver: None,
is_loading: false,
connection_info: None,
auto_scroll: false,
horizontal_scroll: 0,
show_time_delta: false,
skipped_bytes: 0,
skipped_bytes_shared: None,
}
}
pub fn load_directory(&mut self, path: &Path) -> std::io::Result<()> {
let mut entries = explorer::list_directory(path)?;
entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
if let Some(parent) = path.parent() {
entries.insert(
0,
FileEntry {
name: "..".to_string(),
is_dir: true,
path: parent.to_path_buf(),
},
);
}
self.explorer_items = entries;
self.explorer_selected_index = 0;
Ok(())
}
pub fn load_file(&mut self, path: &Path) -> std::io::Result<()> {
self.logs.clear();
self.filtered_log_indices.clear();
self.logs_selected_index = 0;
self.filter = Filter::default();
self.is_loading = true;
self.horizontal_scroll = 0;
self.show_time_delta = false;
self.skipped_bytes = 0;
let (tx, rx) = std::sync::mpsc::channel();
self.log_receiver = Some(rx);
let skipped_shared = Arc::new(AtomicUsize::new(0));
self.skipped_bytes_shared = Some(Arc::clone(&skipped_shared));
let path_buf = path.to_path_buf();
std::thread::spawn(move || {
let mut stream = match crate::fs_reader::open_dlt_stream(&path_buf) {
Ok(s) => s,
Err(_) => return,
};
let mut buffer = Vec::new();
if std::io::Read::read_to_end(&mut stream, &mut buffer).is_err() {
return;
}
let (messages, skipped) = crate::parser::parse_all_messages(&buffer);
skipped_shared.store(skipped, Ordering::Relaxed);
for msg in messages {
if tx.send(msg).is_err() {
break; }
}
});
self.apply_filter();
self.screen = AppScreen::LogViewer;
Ok(())
}
fn check_log_against_filter(
log: &DltMessage,
filter: &Filter,
regex: Option<®ex::Regex>,
) -> bool {
if let Some(ref min_level) = filter.min_level {
let level_val = |l: &crate::parser::LogLevel| match l {
crate::parser::LogLevel::Fatal => 1,
crate::parser::LogLevel::Error => 2,
crate::parser::LogLevel::Warn => 3,
crate::parser::LogLevel::Info => 4,
crate::parser::LogLevel::Debug => 5,
crate::parser::LogLevel::Verbose => 6,
crate::parser::LogLevel::Unknown(_) => 7,
};
let target_val = level_val(min_level);
let current_val = log.log_level.as_ref().map(level_val).unwrap_or(7);
if current_val > target_val {
return false;
}
}
if let Some(ref text) = filter.text {
if let Some(re) = regex {
if !re.is_match(&log.payload_text) {
return false;
}
} else if !log
.payload_text
.to_lowercase()
.contains(&text.to_lowercase())
{
return false;
}
}
if let Some(ref app_id) = filter.app_id {
let matches = log
.apid
.as_deref()
.map(|s| s.eq_ignore_ascii_case(app_id))
.unwrap_or(false);
if !matches {
return false;
}
}
if let Some(ref ctx_id) = filter.ctx_id {
let matches = log
.ctid
.as_deref()
.map(|s| s.eq_ignore_ascii_case(ctx_id))
.unwrap_or(false);
if !matches {
return false;
}
}
true
}
pub fn apply_filter(&mut self) {
self.filtered_log_indices.clear();
let text_regex = self.filter.text.as_ref().and_then(|text| {
regex::RegexBuilder::new(text)
.case_insensitive(true)
.build()
.ok() });
for (idx, log) in self.logs.iter().enumerate() {
if Self::check_log_against_filter(log, &self.filter, text_regex.as_ref()) {
self.filtered_log_indices.push(idx);
}
}
self.logs_selected_index = 0;
}
pub fn on_home(&mut self) {
match self.screen {
AppScreen::Explorer => self.explorer_selected_index = 0,
AppScreen::LogViewer | AppScreen::LogDetail => self.logs_selected_index = 0,
}
}
pub fn on_end(&mut self) {
match self.screen {
AppScreen::Explorer => {
if !self.explorer_items.is_empty() {
self.explorer_selected_index = self.explorer_items.len() - 1;
}
}
AppScreen::LogViewer | AppScreen::LogDetail => {
if !self.filtered_log_indices.is_empty() {
self.logs_selected_index = self.filtered_log_indices.len() - 1;
}
}
}
}
pub fn on_right(&mut self, amount: usize) {
if self.screen == AppScreen::LogViewer {
self.horizontal_scroll = self.horizontal_scroll.saturating_add(amount);
}
}
pub fn on_left(&mut self, amount: usize) {
if self.screen == AppScreen::LogViewer {
self.horizontal_scroll = self.horizontal_scroll.saturating_sub(amount);
}
}
pub fn on_tick(&mut self) {
if let Some(rx) = &self.log_receiver {
let mut added = false;
let current_len = self.logs.len();
let text_regex = self.filter.text.as_ref().and_then(|text| {
regex::RegexBuilder::new(text)
.case_insensitive(true)
.build()
.ok()
});
loop {
match rx.try_recv() {
Ok(msg) => {
self.logs.push(msg);
added = true;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
break;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.is_loading = false;
if self.connection_info.is_some() {
self.connection_info = None;
}
if let Some(ref shared) = self.skipped_bytes_shared {
self.skipped_bytes = shared.load(Ordering::Relaxed);
}
self.skipped_bytes_shared = None;
self.log_receiver = None;
break;
}
}
}
if added {
for idx in current_len..self.logs.len() {
let log = &self.logs[idx];
if Self::check_log_against_filter(log, &self.filter, text_regex.as_ref()) {
self.filtered_log_indices.push(idx);
}
}
if self.auto_scroll && !self.filtered_log_indices.is_empty() {
self.logs_selected_index = self.filtered_log_indices.len() - 1;
}
}
}
}
pub fn on_up(&mut self) {
match self.screen {
AppScreen::Explorer => {
if self.explorer_selected_index > 0 {
self.explorer_selected_index -= 1;
}
}
AppScreen::LogViewer | AppScreen::LogDetail => {
if self.logs_selected_index > 0 {
self.logs_selected_index -= 1;
}
}
}
}
pub fn on_down(&mut self) {
match self.screen {
AppScreen::Explorer => {
if !self.explorer_items.is_empty()
&& self.explorer_selected_index < self.explorer_items.len() - 1
{
self.explorer_selected_index += 1;
}
}
AppScreen::LogViewer | AppScreen::LogDetail => {
if !self.filtered_log_indices.is_empty()
&& self.logs_selected_index < self.filtered_log_indices.len() - 1
{
self.logs_selected_index += 1;
}
}
}
}
pub fn on_page_down(&mut self, page_size: usize) {
if page_size == 0 {
return;
}
match self.screen {
AppScreen::Explorer => {
if !self.explorer_items.is_empty() {
let max = self.explorer_items.len() - 1;
self.explorer_selected_index =
(self.explorer_selected_index + page_size).min(max);
}
}
AppScreen::LogViewer | AppScreen::LogDetail => {
if !self.filtered_log_indices.is_empty() {
let max = self.filtered_log_indices.len() - 1;
self.logs_selected_index = (self.logs_selected_index + page_size).min(max);
}
}
}
}
pub fn on_page_up(&mut self, page_size: usize) {
if page_size == 0 {
return;
}
match self.screen {
AppScreen::Explorer => {
self.explorer_selected_index =
self.explorer_selected_index.saturating_sub(page_size);
}
AppScreen::LogViewer | AppScreen::LogDetail => {
self.logs_selected_index = self.logs_selected_index.saturating_sub(page_size);
}
}
}
pub fn on_half_page_down(&mut self, page_size: usize) {
self.on_page_down(page_size / 2);
}
pub fn on_half_page_up(&mut self, page_size: usize) {
self.on_page_up(page_size / 2);
}
pub fn on_key_q(&mut self) {
self.should_quit = true;
}
pub fn handle_key(&mut self, key: crossterm::event::KeyEvent, page_size: usize) {
use crossterm::event::{KeyCode, KeyModifiers};
if self.error_message.is_some() || self.info_message.is_some() {
self.error_message = None;
self.info_message = None;
return;
}
if let Some(mode) = self.filter_input_mode.clone() {
match key.code {
KeyCode::Enter => {
self.filter_input_mode = None;
let input = if self.filter_input.is_empty() {
None
} else {
Some(self.filter_input.clone())
};
match mode {
FilterInputMode::Text => self.filter.text = input,
FilterInputMode::AppId => self.filter.app_id = input,
FilterInputMode::CtxId => self.filter.ctx_id = input,
FilterInputMode::MinLevel => {
self.filter.min_level = match self.filter_input.to_lowercase().as_str()
{
"f" | "fatal" => Some(crate::parser::LogLevel::Fatal),
"e" | "error" => Some(crate::parser::LogLevel::Error),
"w" | "warn" => Some(crate::parser::LogLevel::Warn),
"i" | "info" => Some(crate::parser::LogLevel::Info),
"d" | "debug" => Some(crate::parser::LogLevel::Debug),
"v" | "verbose" => Some(crate::parser::LogLevel::Verbose),
_ => None,
};
}
}
self.apply_filter();
}
KeyCode::Esc => {
self.filter_input_mode = None;
self.filter_input.clear();
}
KeyCode::Char(c) => self.filter_input.push(c),
KeyCode::Backspace => {
self.filter_input.pop();
}
_ => {}
}
return;
}
match key.code {
KeyCode::Char('q') => match self.screen {
AppScreen::Explorer => self.on_key_q(),
AppScreen::LogViewer => self.screen = AppScreen::Explorer,
AppScreen::LogDetail => self.screen = AppScreen::LogViewer,
},
KeyCode::Char('E') if self.screen == AppScreen::LogViewer => {
self.on_export();
}
KeyCode::Char('t') if self.screen == AppScreen::LogViewer => {
self.show_time_delta = !self.show_time_delta;
}
KeyCode::Char('j') | KeyCode::Down => self.on_down(),
KeyCode::Char('k') | KeyCode::Up => self.on_up(),
KeyCode::Left => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
self.on_left(10);
} else {
self.on_left(1);
}
}
KeyCode::Right => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
self.on_right(10);
} else {
self.on_right(1);
}
}
KeyCode::Char('g') | KeyCode::Home => self.on_home(),
KeyCode::Char('G') | KeyCode::End => self.on_end(),
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.on_page_down(page_size);
}
KeyCode::PageDown => self.on_page_down(page_size),
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.on_page_up(page_size);
}
KeyCode::PageUp => self.on_page_up(page_size),
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.on_half_page_down(page_size);
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.on_half_page_up(page_size);
}
KeyCode::Char('/') if self.screen == AppScreen::LogViewer => {
self.filter_input_mode = Some(FilterInputMode::Text);
self.filter_input.clear();
}
KeyCode::Char('l') if self.screen == AppScreen::LogViewer => {
self.filter_input_mode = Some(FilterInputMode::MinLevel);
self.filter_input.clear();
}
KeyCode::Char('a') if self.screen == AppScreen::LogViewer => {
self.filter_input_mode = Some(FilterInputMode::AppId);
self.filter_input.clear();
}
KeyCode::Char('c') if self.screen == AppScreen::LogViewer => {
self.filter_input_mode = Some(FilterInputMode::CtxId);
self.filter_input.clear();
}
KeyCode::Char('C') if self.screen == AppScreen::LogViewer => {
self.filter = Filter::default();
self.apply_filter();
}
KeyCode::Char('F') if self.screen == AppScreen::LogViewer => {
self.auto_scroll = !self.auto_scroll;
}
KeyCode::Enter => {
if self.screen == AppScreen::Explorer {
if !self.explorer_items.is_empty() {
let selected = &self.explorer_items[self.explorer_selected_index];
if selected.is_dir {
let path_clone = selected.path.clone();
if let Err(e) = self.load_directory(&path_clone) {
self.error_message =
Some(format!("Could not open directory: {}", e));
}
} else {
let path_clone = selected.path.clone();
if let Err(e) = self.load_file(&path_clone) {
self.error_message = Some(format!("Could not open file: {}", e));
}
}
}
} else if self.screen == AppScreen::LogViewer
&& !self.filtered_log_indices.is_empty()
{
self.screen = AppScreen::LogDetail;
}
}
KeyCode::Esc => {
if self.screen == AppScreen::LogViewer {
self.screen = AppScreen::Explorer;
} else if self.screen == AppScreen::LogDetail {
self.screen = AppScreen::LogViewer;
}
}
_ => {}
}
}
pub fn connect_tcp(&mut self, addr: &str) {
self.logs.clear();
self.filtered_log_indices.clear();
self.logs_selected_index = 0;
self.filter = Filter::default();
self.is_loading = true;
self.auto_scroll = true;
self.horizontal_scroll = 0;
self.show_time_delta = false;
self.connection_info = Some(addr.to_string());
let (tx, rx) = std::sync::mpsc::channel();
self.log_receiver = Some(rx);
let addr_owned = addr.to_string();
std::thread::spawn(move || {
if let Err(_e) = crate::tcp_client::stream_from_tcp(&addr_owned, tx) {
}
});
self.screen = AppScreen::LogViewer;
}
pub fn on_export(&mut self) {
if self.filtered_log_indices.is_empty() {
self.error_message = Some("No logs to export".to_string());
return;
}
let now = chrono::Local::now();
let filename = format!("dlt_export_{}.txt", now.format("%Y%m%d_%H%M%S"));
let filtered_logs: Vec<&crate::parser::DltMessage> = self
.filtered_log_indices
.iter()
.map(|&i| &self.logs[i])
.collect();
match crate::exporter::export_to_txt(&filtered_logs, &filename) {
Ok(_) => {
self.info_message = Some(format!(
"Exported {} logs to {}",
filtered_logs.len(),
filename
));
}
Err(e) => {
self.error_message = Some(format!("Export failed: {}", e));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::path::PathBuf;
fn build_mock_app_with_explorer_files() -> App {
App {
screen: AppScreen::Explorer,
explorer_items: vec![
FileEntry {
name: "folder1".to_string(),
is_dir: true,
path: PathBuf::from("folder1"),
},
FileEntry {
name: "fileA.dlt".to_string(),
is_dir: false,
path: PathBuf::from("fileA.dlt"),
},
FileEntry {
name: "fileB.dlt".to_string(),
is_dir: false,
path: PathBuf::from("fileB.dlt"),
},
],
..App::new()
}
}
fn build_mock_app_with_logs(count: usize) -> App {
let mut app = App::new();
app.screen = AppScreen::LogViewer;
for i in 0..count {
app.logs.push(DltMessage {
timestamp_us: 1000 + i as u64,
ecu_id: format!("ECU{}", i),
apid: None,
ctid: None,
log_level: None,
payload_text: format!("Log message {}", i),
payload_raw: format!("Log message {}", i).into_bytes(),
});
}
app.apply_filter();
app
}
#[test]
fn test_app_initialization() {
let app = App::new();
assert_eq!(app.screen, AppScreen::Explorer);
assert_eq!(app.explorer_selected_index, 0);
assert_eq!(app.logs_selected_index, 0);
assert!(!app.should_quit);
}
#[test]
fn test_quit_on_q() {
let mut app = App::new();
app.on_key_q();
assert!(app.should_quit);
}
#[test]
fn test_explorer_up_down_bounds() {
let mut app = build_mock_app_with_explorer_files();
app.on_up();
assert_eq!(app.explorer_selected_index, 0);
app.on_down();
assert_eq!(app.explorer_selected_index, 1);
app.on_down();
assert_eq!(app.explorer_selected_index, 2);
app.on_down();
assert_eq!(app.explorer_selected_index, 2);
app.on_up();
assert_eq!(app.explorer_selected_index, 1);
}
#[test]
fn test_log_viewer_up_down_bounds() {
let mut app = build_mock_app_with_logs(5);
app.on_up();
assert_eq!(app.logs_selected_index, 0);
app.on_down();
assert_eq!(app.logs_selected_index, 1);
app.on_down();
app.on_down();
app.on_down();
app.on_down();
assert_eq!(app.logs_selected_index, 4);
app.on_home();
assert_eq!(app.logs_selected_index, 0);
app.on_end();
assert_eq!(app.logs_selected_index, 4);
}
#[test]
fn test_page_down_log_viewer() {
let mut app = build_mock_app_with_logs(100);
assert_eq!(app.logs_selected_index, 0);
app.on_page_down(20);
assert_eq!(app.logs_selected_index, 20);
app.on_page_down(20);
assert_eq!(app.logs_selected_index, 40);
}
#[test]
fn test_page_down_clamps_at_end() {
let mut app = build_mock_app_with_logs(50);
app.logs_selected_index = 45;
app.on_page_down(20);
assert_eq!(app.logs_selected_index, 49);
}
#[test]
fn test_page_up_log_viewer() {
let mut app = build_mock_app_with_logs(100);
app.logs_selected_index = 50;
app.on_page_up(20);
assert_eq!(app.logs_selected_index, 30);
app.on_page_up(20);
assert_eq!(app.logs_selected_index, 10);
}
#[test]
fn test_page_up_clamps_at_start() {
let mut app = build_mock_app_with_logs(100);
app.logs_selected_index = 5;
app.on_page_up(20);
assert_eq!(app.logs_selected_index, 0);
}
#[test]
fn test_half_page_scroll() {
let mut app = build_mock_app_with_logs(100);
app.on_half_page_down(20); assert_eq!(app.logs_selected_index, 10);
app.on_half_page_down(20);
assert_eq!(app.logs_selected_index, 20);
app.on_half_page_up(20);
assert_eq!(app.logs_selected_index, 10);
app.on_half_page_up(20);
assert_eq!(app.logs_selected_index, 0);
}
#[test]
fn test_page_scroll_explorer() {
let mut app = build_mock_app_with_explorer_files();
app.on_page_down(10);
assert_eq!(app.explorer_selected_index, 2);
app.on_page_up(10);
assert_eq!(app.explorer_selected_index, 0); }
#[test]
fn test_page_scroll_empty_list() {
let mut app = App::new();
app.screen = AppScreen::LogViewer;
app.on_page_down(20);
assert_eq!(app.logs_selected_index, 0);
app.on_page_up(20);
assert_eq!(app.logs_selected_index, 0);
}
#[test]
fn test_page_scroll_zero_page_size() {
let mut app = build_mock_app_with_logs(10);
app.logs_selected_index = 5;
app.on_page_down(0);
assert_eq!(app.logs_selected_index, 5);
app.on_page_up(0);
assert_eq!(app.logs_selected_index, 5); }
fn build_mock_app_with_diverse_logs() -> App {
let mut app = App::new();
app.screen = AppScreen::LogViewer;
let entries = vec![
(
"ECU1",
Some("DIAG"),
Some("CAN1"),
Some(crate::parser::LogLevel::Error),
"CAN bus timeout on channel 1",
),
(
"ECU1",
Some("DIAG"),
Some("CAN2"),
Some(crate::parser::LogLevel::Warn),
"CAN retransmit count high",
),
(
"ECU1",
Some("SYS"),
Some("BOOT"),
Some(crate::parser::LogLevel::Info),
"System boot complete",
),
(
"ECU2",
Some("NAV"),
Some("GPS1"),
Some(crate::parser::LogLevel::Debug),
"GPS fix acquired lat=35.6 lon=139.7",
),
(
"ECU2",
Some("NAV"),
Some("MAP1"),
Some(crate::parser::LogLevel::Info),
"Map data loaded",
),
(
"ECU1",
Some("SYS"),
Some("BOOT"),
Some(crate::parser::LogLevel::Fatal),
"Watchdog reset detected",
),
(
"ECU1",
Some("DIAG"),
Some("UDS1"),
Some(crate::parser::LogLevel::Info),
"UDS session started",
),
(
"ECU2",
Some("HMI"),
Some("DISP"),
Some(crate::parser::LogLevel::Verbose),
"Frame rendered in 16ms",
),
];
for (i, (ecu, apid, ctid, level, text)) in entries.into_iter().enumerate() {
app.logs.push(DltMessage {
timestamp_us: 1000 + i as u64,
ecu_id: ecu.to_string(),
apid: apid.map(|s| s.to_string()),
ctid: ctid.map(|s| s.to_string()),
log_level: level,
payload_text: text.to_string(),
payload_raw: text.as_bytes().to_vec(),
});
}
app.apply_filter();
app
}
#[test]
fn test_filter_by_text() {
let mut app = build_mock_app_with_diverse_logs();
assert_eq!(app.filtered_log_indices.len(), 8);
app.filter.text = Some("CAN".to_string());
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 2); }
#[test]
fn test_filter_by_text_case_insensitive() {
let mut app = build_mock_app_with_diverse_logs();
app.filter.text = Some("can".to_string());
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 2); }
#[test]
fn test_filter_by_app_id() {
let mut app = build_mock_app_with_diverse_logs();
app.filter.app_id = Some("DIAG".to_string());
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 3); }
#[test]
fn test_filter_by_ctx_id() {
let mut app = build_mock_app_with_diverse_logs();
app.filter.ctx_id = Some("BOOT".to_string());
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 2); }
#[test]
fn test_filter_by_log_level() {
let mut app = build_mock_app_with_diverse_logs();
app.filter.min_level = Some(crate::parser::LogLevel::Warn);
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 3); }
#[test]
fn test_filter_compound_level_and_app() {
let mut app = build_mock_app_with_diverse_logs();
app.filter.app_id = Some("DIAG".to_string());
app.filter.min_level = Some(crate::parser::LogLevel::Warn);
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 2); }
#[test]
fn test_filter_compound_all_criteria() {
let mut app = build_mock_app_with_diverse_logs();
app.filter.app_id = Some("DIAG".to_string());
app.filter.min_level = Some(crate::parser::LogLevel::Error);
app.filter.text = Some("timeout".to_string());
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 1); assert_eq!(
app.logs[app.filtered_log_indices[0]].payload_text,
"CAN bus timeout on channel 1"
);
}
#[test]
fn test_filter_by_regex() {
let mut app = build_mock_app_with_diverse_logs();
app.filter.text = Some(r"lat=\d+\.\d+".to_string());
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 1); }
#[test]
fn test_filter_no_match() {
let mut app = build_mock_app_with_diverse_logs();
app.filter.text = Some("NONEXISTENT_STRING".to_string());
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 0);
}
#[test]
fn test_filter_reset() {
let mut app = build_mock_app_with_diverse_logs();
app.filter.text = Some("CAN".to_string());
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 2);
app.filter = Filter::default();
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 8); }
#[test]
fn test_filter_resets_selected_index() {
let mut app = build_mock_app_with_diverse_logs();
app.logs_selected_index = 5;
app.filter.text = Some("CAN".to_string());
app.apply_filter();
assert_eq!(app.logs_selected_index, 0);
}
#[test]
fn test_horizontal_scroll() {
let mut app = build_mock_app_with_logs(1);
assert_eq!(app.horizontal_scroll, 0);
app.on_right(1);
assert_eq!(app.horizontal_scroll, 1);
app.on_right(10);
assert_eq!(app.horizontal_scroll, 11);
app.on_left(5);
assert_eq!(app.horizontal_scroll, 6);
app.on_left(10);
assert_eq!(app.horizontal_scroll, 0, "Should clamp at 0");
}
fn make_key(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}
}
#[test]
fn test_esc_preserves_previous_filter() {
let mut app = build_mock_app_with_diverse_logs();
app.filter.text = Some("CAN".to_string());
app.apply_filter();
assert_eq!(app.filtered_log_indices.len(), 2);
app.handle_key(make_key(KeyCode::Char('/')), 20);
assert!(app.filter_input_mode.is_some());
app.handle_key(make_key(KeyCode::Char('X')), 20);
app.handle_key(make_key(KeyCode::Esc), 20);
assert_eq!(
app.filter.text,
Some("CAN".to_string()),
"Esc should preserve the previous filter value"
);
assert_eq!(
app.filtered_log_indices.len(),
2,
"Filter results should remain unchanged after Esc"
);
}
#[test]
fn test_esc_first_time_filter_leaves_none() {
let mut app = build_mock_app_with_diverse_logs();
assert_eq!(app.filter.text, None);
assert_eq!(app.filtered_log_indices.len(), 8);
app.handle_key(make_key(KeyCode::Char('/')), 20);
app.handle_key(make_key(KeyCode::Char('X')), 20);
app.handle_key(make_key(KeyCode::Esc), 20);
assert_eq!(app.filter.text, None);
assert_eq!(app.filtered_log_indices.len(), 8);
}
#[test]
fn test_app_id_filter_case_insensitive() {
let mut app = build_mock_app_with_diverse_logs();
app.filter.app_id = Some("diag".to_string());
app.apply_filter();
assert_eq!(
app.filtered_log_indices.len(),
3,
"APP ID filter should be case-insensitive: 'diag' matches 'DIAG'"
);
app.filter.app_id = None;
app.filter.ctx_id = Some("boot".to_string());
app.apply_filter();
assert_eq!(
app.filtered_log_indices.len(),
2,
"CTX ID filter should be case-insensitive: 'boot' matches 'BOOT'"
);
}
}