use super::*;
#[test]
fn the_branch_after_the_path_and_who_by_the_logo() {
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.dir_left = PathBuf::from("/home/colt/fm84");
app_state.branch_left = Some("main".to_string());
app_state.user_at_host = ("colt@laserbeak".to_string(), false);
let row = |width: u16, y: u16, app_state: &mut AppState| {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 30)).unwrap();
render_ui(&mut terminal, app_state);
(0..width).map(|x| terminal.backend().buffer()[(x, y)].symbol().to_string()).collect::<String>()
};
let path_bar = row(100, 3, &mut app_state);
assert!(path_bar.contains(&format!("/home/colt/fm84 ─ {ICON_BRANCH} main ─")), "{path_bar}");
let top = row(100, 0, &mut app_state);
assert!(top.contains(&format!("{} ─ colt@laserbeak ─", logo_icon(IconStyle::NerdFont))), "{top}");
app_state.options.icon_style = IconStyle::Plain;
assert!(row(100, 3, &mut app_state).contains("/home/colt/fm84 ─ git:main ─"));
let narrow = row(44, 3, &mut app_state);
assert!(!narrow.contains("git:main"), "{narrow}");
assert!(!row(44, 0, &mut app_state).contains("colt@"));
}
#[test]
fn tabs_are_drawn_and_clicked_where_they_are() {
assert_eq!(tab_label(Path::new("/")), "/");
assert_eq!(tab_label(Path::new("/home/colt/src")), "src");
assert_eq!(tab_label(Path::new("/tmp/a-rather-long-directory-name")), "a-rather-long-d…");
let mut app_state = AppState::new();
app_state.is_left_active = true;
app_state.dir_left = "/tmp/b".into();
app_state.tabs_left = crate::app::Tabs::new(vec!["/a".into(), "/tmp/b".into()], 1);
tab_strip(&mut app_state, true, Rect::new(0, 1, 40, 1));
assert_eq!(app_state.tab_at(0, 1), None);
assert_eq!(app_state.tab_at(1, 1), Some((true, StripHit::Item(0))));
assert_eq!(app_state.tab_at(3, 1), Some((true, StripHit::Item(0))));
assert_eq!(app_state.tab_at(4, 1), Some((true, StripHit::Item(1))));
assert_eq!(app_state.tab_at(7, 1), None);
assert_eq!(app_state.tab_at(4, 0), None);
}
#[test]
fn a_crowded_drive_strip_scrolls() {
use crate::fs_ops::{Mount, MountKind};
let mut app_state = AppState::new();
app_state.options.icon_style = IconStyle::Plain;
app_state.dir_left = "/".into();
app_state.mounts = (0..12)
.map(|index| Mount {
path: format!("/mnt/{index}").into(),
label: format!("d{index}"),
kind: MountKind::Disk,
})
.collect();
let area = Rect::new(0, 0, 30, 1);
drive_strip(&mut app_state, true, area);
let strip = &app_state.drive_strip_left;
assert_eq!((strip.first, strip.visible), (0, 5));
assert_eq!(strip.hits.first(), Some(&StripHit::Item(0)));
assert_eq!(strip.hits.last(), Some(&StripHit::Forward));
assert_eq!(app_state.drive_at(1, 0), None); assert_eq!(app_state.drive_at(2, 0), Some((true, StripHit::Item(0))));
assert_eq!(app_state.drive_at(27, 0), Some((true, StripHit::Forward)));
app_state.scroll_drive_strip(true, true);
drive_strip(&mut app_state, true, area);
assert_eq!(app_state.drive_at(0, 0), Some((true, StripHit::Back)));
assert_eq!(app_state.drive_at(2, 0), Some((true, StripHit::Item(5))));
app_state.scroll_drive_strip(true, true);
drive_strip(&mut app_state, true, area);
assert_eq!(app_state.drive_strip_left.first, 7);
assert!(!app_state.drive_strip_left.hits.contains(&StripHit::Forward));
drive_strip(&mut app_state, true, Rect::new(0, 0, 80, 1));
assert_eq!(app_state.drive_strip_left.first, 0);
assert_eq!(app_state.drive_at(1, 0), Some((true, StripHit::Item(0))));
}
#[test]
fn a_hostile_name_never_reaches_the_terminal() {
let dir = std::env::temp_dir().join(format!("fm84-hostile-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let nasty = "a\u{1b}[41;97mHACKED\u{1b}[0m\tbell\u{7}.txt";
std::fs::write(dir.join(nasty), "contents").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.show_preview = true;
app_state.open_dir(true, dir.clone(), Some(nasty.as_ref()));
app_state.refresh_cursor_detail();
app_state.refresh_preview();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut clean = |app_state: &mut AppState, what: &str| {
render_ui(&mut terminal, app_state);
let dirty: Vec<String> = terminal.backend().buffer().content().iter().filter(|cell| cell.symbol().chars().any(char::is_control)).map(|cell| format!("{:?}", cell.symbol())).collect();
assert!(dirty.is_empty(), "{what} drew {dirty:?}");
};
clean(&mut app_state, "the panel");
app_state.dialog = Some(Dialog::Delete {
items: vec![(dir.join(nasty), false)],
to_trash: true,
});
clean(&mut app_state, "the delete popup");
let mut input = TextInput::new();
input.set(nasty.to_string());
app_state.dialog = Some(Dialog::Rename(input));
clean(&mut app_state, "the rename field");
let mut input = TextInput::new();
input.set(nasty.to_string());
app_state.dialog = Some(Dialog::Prompt(crate::app::PromptKind::GoTo, input));
clean(&mut app_state, "the go to field");
app_state.dialog = None;
app_state.error = Some(format!("No such file or directory: {nasty}"));
clean(&mut app_state, "the error popup");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn every_input_field_wears_the_rename_colours() {
use crate::app::PromptKind;
use crate::options::CursorShape;
use ratatui::backend::Backend;
let dir = std::env::temp_dir().join(format!("fm84-input-fields-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("file.txt"), "").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), Some("file.txt".as_ref()));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
let typed = || {
let mut input = TextInput::new();
input.set("typed".to_string());
input
};
let dialogs: Vec<(&str, Dialog)> = vec![
("F2", Dialog::Rename(typed())),
("F7", Dialog::Create { is_dir: true, input: typed() }),
(
"F5",
Dialog::Transfer {
sources: vec![(std::path::PathBuf::from("/a"), false)],
is_copy: true,
input: typed(),
opened_with: String::new(),
default: std::path::PathBuf::from("/b"),
},
),
(
"Ctrl+Shift+F5",
Dialog::Symlink {
sources: vec![(std::path::PathBuf::from("/a"), false)],
input: typed(),
opened_with: String::new(),
default: std::path::PathBuf::from("/b"),
hard: false,
},
),
("+", Dialog::Prompt(PromptKind::Select, typed())),
("Ctrl+L", Dialog::Prompt(PromptKind::GoTo, typed())),
("Options", Dialog::Options { editing: Some(typed()) }),
];
let reversed = |terminal: &Terminal<ratatui::backend::TestBackend>| terminal.backend().buffer().content().iter().filter(|cell| cell.modifier.contains(Modifier::REVERSED) && cell.bg == palette().rename_background).count();
let marked = |terminal: &Terminal<ratatui::backend::TestBackend>| {
terminal
.backend()
.buffer()
.content()
.iter()
.any(|cell| cell.modifier.intersects(FIELD_CURSOR | EDITOR_CURSOR | crate::app::CURSOR_AFTER | crate::app::PAST_END))
};
for (what, dialog) in dialogs {
app_state.dialog = Some(dialog);
for shape in [CursorShape::Bar, CursorShape::Classic] {
app_state.options.cursor = shape;
render_ui(&mut terminal, &mut app_state);
assert!(!marked(&terminal), "{what}, {shape:?}: a mark reached the terminal");
if shape == CursorShape::Classic {
assert_eq!(reversed(&terminal), 1, "{what}: no cursor in the rename colours");
} else {
let at = terminal.backend_mut().get_cursor_position().unwrap();
let last = &terminal.backend().buffer()[(at.x - 1, at.y)];
assert_eq!((last.symbol(), last.bg), ("d", palette().rename_background), "{what}: the cursor is not after the text");
assert_eq!(reversed(&terminal), 0, "{what}: a cell reversed as well");
}
let text = terminal
.backend()
.buffer()
.content()
.iter()
.filter(|cell| cell.bg == palette().rename_background && cell.fg == palette().selected_foreground && !cell.modifier.contains(Modifier::REVERSED))
.count();
assert!(text >= "typed".len() - 1, "{what}: typed text not in the rename colours");
}
}
app_state.open_find_files();
render_ui(&mut terminal, &mut app_state);
assert_eq!(reversed(&terminal), 1, "Alt+F7");
app_state.open_history();
app_state.dialog_input().unwrap().set("typed".to_string());
render_ui(&mut terminal, &mut app_state);
assert_eq!(reversed(&terminal), 1, "Alt+Down");
assert!(!marked(&terminal), "Alt+Down: a mark reached the terminal");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn popups_share_a_width_and_keep_to_the_panels() {
let mut app_state = AppState::new();
app_state.options = Options::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
let frame = |terminal: &Terminal<ratatui::backend::TestBackend>, title: &str| {
let buffer = terminal.backend().buffer();
let rows: Vec<String> = (0..buffer.area.height).map(|y| (0..buffer.area.width).map(|x| buffer[(x, y)].symbol()).collect()).collect();
let top = rows.iter().position(|row| row.contains(title)).unwrap_or_else(|| panic!("{title} not drawn"));
let chars: Vec<char> = rows[top].chars().collect();
let at = rows[top].find(title).map(|byte| rows[top][..byte].chars().count()).unwrap();
let left = chars[..at].iter().rposition(|&c| c == '┌').unwrap();
let right = at + chars[at..].iter().position(|&c| c == '┐').unwrap();
let bottom = (top..rows.len()).find(|&y| rows[y].chars().nth(left) == Some('└')).unwrap();
(left, right - left + 1, top, bottom)
};
app_state.open_history();
let history = app_state.dialog.take().unwrap();
let mut input = TextInput::new();
input.set("/b".to_string());
let dialogs = vec![
(" Help/About ", Dialog::Help),
(" Create Directory ", Dialog::Create { is_dir: true, input: TextInput::new() }),
(
" Copy file ",
Dialog::Transfer {
sources: vec![(PathBuf::from("/a"), false)],
is_copy: true,
input,
opened_with: String::new(),
default: PathBuf::from("/b"),
},
),
(" Options ", Dialog::Options { editing: None }),
(
" Delete: ",
Dialog::Delete {
items: vec![(PathBuf::from("/a"), false)],
to_trash: false,
},
),
(" Unsaved Changes ", Dialog::SaveChanges { then_quit: false }),
(" Bookmarks ", Dialog::Bookmarks { list: vec![PathBuf::from("/a")], index: 0 }),
(" Directory History ", history),
(
" User Menu ",
Dialog::UserMenu(Box::new(crate::app::UserMenu {
entries: vec![crate::options::MenuEntry {
label: "Disk use".to_string(),
command: "du -sh {files}".to_string(),
}],
index: 0,
file: None,
})),
),
(
" Rename 2 Entries ",
Dialog::MultiRename(Box::new(crate::app::MultiRename::new(
["a.txt", "b.txt"]
.iter()
.map(|name| crate::app::RenameEntry {
path: PathBuf::from("/").join(name),
old: name.to_string(),
readable: true,
})
.collect(),
))),
),
(
" Open a With ",
Dialog::OpenWith(Box::new(crate::app::OpenWith {
path: PathBuf::from("/a"),
openers: vec![crate::opener::Opener {
label: "System default".to_string(),
detail: String::new(),
launch: crate::opener::Launch::Default,
terminal: false,
}],
index: 0,
})),
),
];
let mut widths = Vec::new();
for (title, dialog) in dialogs {
app_state.dialog = Some(dialog);
render_ui(&mut terminal, &mut app_state);
let (left, width, top, bottom) = frame(&terminal, title);
widths.push((title, left, width));
assert!(top >= 4 && bottom <= 30 - 5, "{title} runs from row {top} to {bottom}");
let screen: String = terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect();
if title == " Options " {
assert!(screen.contains(&format!(" 1/{} ", OPTION_ROWS.len())), "F11 does not say it scrolls");
}
if title == " Help/About " {
assert!(screen.contains(" 1-"), "F1 does not say it scrolls");
}
}
assert!(widths.iter().all(|&(_, left, width)| (left, width) == (widths[0].1, widths[0].2)), "{widths:?}");
app_state.dialog = Some(Dialog::Help);
app_state.help_scroll = usize::MAX;
render_ui(&mut terminal, &mut app_state);
let screen: String = terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect();
let help = help_lines();
assert!(screen.contains(help[help.len() - 1].0.trim()), "F1's last line");
assert!(screen.contains(" About "), "the About heading");
assert!(screen.contains(&format!("v{}", env!("CARGO_PKG_VERSION"))), "F1's About");
assert!(screen.contains(&format!("-{}/{} ", help.len(), help.len())), "F1's corner");
}
#[test]
fn nothing_is_added_past_the_end_for_the_cursor() {
use crate::options::CursorShape;
let dir = std::env::temp_dir().join(format!("fm84-past-end-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("doc")).unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), Some("doc".as_ref()));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
let screen = |terminal: &Terminal<ratatui::backend::TestBackend>| terminal.backend().buffer().content().iter().map(|cell| cell.symbol()).collect::<String>();
let typed = |text: &str| {
let mut input = TextInput::new();
input.set(text.to_string());
input
};
app_state.dialog = Some(Dialog::Rename(typed("doc")));
render_ui(&mut terminal, &mut app_state);
assert!(screen(&terminal).contains("[doc]"), "F2 on a directory");
app_state.options.cursor = CursorShape::Classic;
render_ui(&mut terminal, &mut app_state);
assert!(screen(&terminal).contains("[doc ]"), "Classic's cursor has a cell");
app_state.options.cursor = CursorShape::Bar;
app_state.dialog = Some(Dialog::Prompt(PromptKind::GoTo, typed("/somewhere")));
render_ui(&mut terminal, &mut app_state);
assert!(screen(&terminal).contains("/somewhere ─"), "Ctrl+L");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_editor_cursor_gives_way_to_a_prompt() {
use crate::options::CursorShape;
use ratatui::backend::Backend;
let file = std::env::temp_dir().join(format!("fm84-editor-cursor-{}.txt", std::process::id()));
std::fs::write(&file, "hello\n").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.open_editor(file.clone()).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
render_ui(&mut terminal, &mut app_state);
let in_editor = terminal.backend_mut().get_cursor_position().unwrap();
assert_eq!(terminal.backend().buffer()[in_editor].symbol(), "h");
app_state.open_prompt(crate::app::PromptKind::Find);
app_state.dialog_input().unwrap().set("he".to_string());
render_ui(&mut terminal, &mut app_state);
let at = terminal.backend_mut().get_cursor_position().unwrap();
let last = &terminal.backend().buffer()[(at.x - 1, at.y)];
assert_eq!((last.symbol(), last.bg), ("e", palette().rename_background), "the prompt has the cursor");
app_state.dialog = None;
app_state.options.cursor = CursorShape::Classic;
render_ui(&mut terminal, &mut app_state);
let cell = &terminal.backend().buffer()[in_editor];
assert_eq!((cell.symbol(), cell.bg), ("h", palette().selected_background));
std::fs::remove_file(&file).unwrap();
}
#[test]
fn go_to_is_typed_over_the_panels_own_path() {
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.dir_left = std::path::PathBuf::from("/left/dir");
app_state.dir_right = std::path::PathBuf::from("/right/dir");
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap();
let row = |terminal: &Terminal<ratatui::backend::TestBackend>, y: u16| -> String {
let buffer = terminal.backend().buffer();
(0..buffer.area.width).map(|x| buffer[(x, y)].symbol().to_string()).collect()
};
for (is_left, typed) in [(true, "/typed/here"), (false, "/somewhere/else"), (true, &format!("/{}/end", "long".repeat(30))[..])] {
app_state.is_left_active = is_left;
let mut input = TextInput::new();
input.set(typed.to_string());
app_state.dialog = Some(Dialog::Prompt(PromptKind::GoTo, input));
render_ui(&mut terminal, &mut app_state);
let bar = row(&terminal, 3);
let (left, right) = bar.split_at(bar.find('┬').unwrap());
let (typed_in, other) = if is_left { (left, right) } else { (right, left) };
assert!(typed_in.contains(if typed.len() > 40 { "long/end" } else { typed }), "{bar}");
assert!(other.contains(if is_left { "/right/dir" } else { "/left/dir" }), "{bar}");
assert!(bar.starts_with("├──") && bar.ends_with("─┤") && bar.chars().count() == 80, "{bar}");
assert!(!row(&terminal, 19).contains("Go to"));
}
}
#[test]
fn hex_preview_fits_the_pane_it_is_given() {
let bytes: Vec<u8> = (0..=255u8).collect();
let mut seen = Vec::new();
for width in 1..100u16 {
let rows = hex_preview(&bytes, width, 4);
let widest = rows.iter().map(|row| row.chars().count()).max().unwrap();
if width > 1 + crate::viewer::hex_row_width(4, 0) as u16 {
assert!(widest < width as usize, "{width} columns held a {widest}-column row");
}
let bytes_shown: usize = rows.iter().map(|row| row.chars().filter(|c| *c == '|').count()).sum();
assert_eq!(bytes_shown, 8, "{width}: every row keeps its gutter");
seen.push((width, widest));
}
assert!(seen.windows(2).all(|pair| pair[0].1 <= pair[1].1), "{seen:?}");
assert_eq!(hex_preview(&bytes, 100, 4)[0].chars().count(), crate::viewer::hex_row_width(16, 4));
}
#[test]
fn scrolling_sideways_goes_past_what_a_u16_holds() {
let text = |spans: &[Span<'static>]| spans.iter().map(|span| span.content.as_ref()).collect::<String>();
let spans = vec![Span::raw("ab"), Span::styled("cdef", style_title())];
assert_eq!(text(&skip_columns(spans.clone(), 0)), "abcdef");
assert_eq!(text(&skip_columns(spans.clone(), 3)), "def");
assert_eq!(skip_columns(spans.clone(), 3)[0].style, style_title());
assert!(skip_columns(spans, 10).is_empty());
assert_eq!(text(&skip_columns(vec![Span::raw("界x")], 1)), " x");
let long = format!("{}END", "x".repeat(70_000));
assert_eq!(text(&skip_columns(vec![Span::raw(long)], 70_000)), "END");
}
#[test]
fn popup_text_wraps_at_spaces_and_keeps_the_last_line_in_view() {
assert_eq!(wrap_text("one two three", 7), ["one two", "three"]);
assert_eq!(wrap_text("short", 20), ["short"]);
assert_eq!(wrap_text("abcdefghij", 4), ["abcd", "efgh", "ij"]);
assert_eq!(wrap_text("", 4), [""]);
assert_eq!(wrap_text("界界界", 4), ["界界", "界"]);
let names = (0..60).map(|n| format!("file{n:02}")).collect::<Vec<_>>().join(", ");
let lines = vec![Line::from(names), Line::from("to: /tmp"), Line::from("Y / Enter - Yes")];
let blocks = fit_rows(lines.into_iter().map(|line| wrap_line(line, 30)).collect(), 7);
let rows: Vec<String> = blocks.iter().flatten().map(|line| line.to_string()).collect();
assert_eq!(rows.len(), 7);
assert!(rows[4].ends_with('…'), "{rows:?}");
assert_eq!(rows[5..], ["to: /tmp", "Y / Enter - Yes"]);
}
#[test]
fn matches_on_wrapped_rows_are_marked_where_they_fall() {
let path = std::env::temp_dir().join(format!("fm84-wrap-marks-{}.txt", std::process::id()));
let words: Vec<String> = (0..40).map(|n| if n % 7 == 3 { "needle".to_string() } else { format!("w{n:02}") }).collect();
std::fs::write(&path, words.join(" ")).unwrap();
let mut app_state = AppState::new();
app_state.open_viewer(path.clone()).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 20)).unwrap();
render_ui(&mut terminal, &mut app_state);
app_state.viewer_toggle_wrap();
app_state.find_term = "needle".to_string();
app_state.find_shown = true;
render_ui(&mut terminal, &mut app_state);
let buffer = terminal.backend().buffer().clone();
let mut marked = Vec::new();
for y in 0..20 {
let mut run = String::new();
for x in 0..40 {
let cell = &buffer[(x, y)];
if cell.modifier.contains(Modifier::UNDERLINED) {
run.push_str(cell.symbol());
} else if !run.is_empty() {
marked.push(std::mem::take(&mut run));
}
}
if !run.is_empty() {
marked.push(run);
}
}
assert_eq!(marked, vec!["needle"; 6]);
std::fs::remove_file(path).unwrap();
}
#[test]
fn centered_sits_in_the_middle_and_never_overflows() {
let area = Rect::new(10, 5, 80, 20);
assert_eq!(centered(area, 40, 20), Rect::new(30, 5, 40, 20));
assert_eq!(centered(area, 80, 10), Rect::new(10, 10, 80, 10));
assert_eq!(centered(area, 200, 90), area);
assert_eq!(centered(area, 79, 20).x, 10);
}
fn editor_on(name: &str, lines: usize) -> (AppState, Terminal<ratatui::backend::TestBackend>, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!("fm84-{name}-{}.txt", std::process::id()));
let text: String = (1..=lines).map(|n| format!("{n}\n")).collect();
std::fs::write(&path, text).unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.open_editor(path.clone()).unwrap();
let terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
(app_state, terminal, path)
}
#[test]
fn deleting_everything_from_the_bottom_of_a_long_file_draws() {
let (mut app_state, mut terminal, path) = editor_on("select-all", 100);
render_ui(&mut terminal, &mut app_state);
app_state.editor_select_all();
app_state.editor_backspace();
render_ui(&mut terminal, &mut app_state);
let state = app_state.editor().unwrap();
assert_eq!(state.lines, [""]);
assert_eq!(state.scroll_offset, 0);
std::fs::remove_file(path).unwrap();
}
#[test]
fn a_long_paste_leaves_the_cursor_on_screen() {
let (mut app_state, mut terminal, path) = editor_on("paste", 3);
render_ui(&mut terminal, &mut app_state);
let pasted: String = (0..200).map(|n| format!("pasted {n}\n")).collect();
app_state.editor_insert_text(&pasted);
render_ui(&mut terminal, &mut app_state);
let state = app_state.editor().unwrap();
let height = app_state.editor_viewport_height;
assert!(state.cursor_line >= state.scroll_offset && state.cursor_line < state.scroll_offset + height);
std::fs::remove_file(path).unwrap();
}
#[test]
fn scrolling_keeps_to_the_room_there_is() {
let mut app_state = AppState::new();
app_state.options = Options::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
app_state.dialog = Some(Dialog::Help);
app_state.help_scroll = usize::MAX;
render_ui(&mut terminal, &mut app_state);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 50)).unwrap();
render_ui(&mut terminal, &mut app_state);
assert_eq!(app_state.help_scroll, help_lines().len() - app_state.help_room, "F1 past its end");
app_state.dialog = None;
let left: Vec<String> = (0..100).map(|n| n.to_string()).collect();
let mut right = left.clone();
right[98] = "changed".to_string();
app_state.screen = Screen::Diff(crate::diff::DiffState::new(PathBuf::from("/a"), PathBuf::from("/b"), left, right));
render_ui(&mut terminal, &mut app_state);
let Screen::Diff(state) = &app_state.screen else { unreachable!() };
assert_eq!(state.scroll_offset, state.rows.len() - app_state.diff_viewport_height, "the diff past its end");
}
#[test]
fn the_diff_header_keeps_the_end_of_a_long_path() {
let mut app_state = AppState::new();
app_state.options = Options::default();
let deep = "/home/user/projects/somewhere/rather/deep/inside";
let (left, right) = (PathBuf::from(format!("{deep}/left_name.txt")), PathBuf::from(format!("{deep}/right_name.txt")));
app_state.screen = Screen::Diff(crate::diff::DiffState::new(left, right, vec!["a".to_string()], vec!["b".to_string()]));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 20)).unwrap();
render_ui(&mut terminal, &mut app_state);
let buffer = terminal.backend().buffer();
let rows: Vec<String> = (0..buffer.area.height).map(|y| (0..buffer.area.width).map(|x| buffer[(x, y)].symbol().to_string()).collect()).collect();
let header = rows.iter().find(|row| row.contains("...") && row.contains("_name")).expect("the paths are cut short");
assert!(header.contains("left_name.txt") && header.contains("right_name.txt"), "{header}");
}
fn thumb_cells(terminal: &Terminal<ratatui::backend::TestBackend>) -> Vec<(u16, u16, &'static str)> {
let buffer = terminal.backend().buffer();
let area = buffer.area;
let cells: Vec<(u16, u16, &'static str)> = (area.y..area.bottom())
.flat_map(|y| (area.x..area.right()).map(move |x| (x, y)))
.filter_map(|(x, y)| {
let (down, along) = ScrollBarStyle::Heavy.thumbs();
[down, along].into_iter().find(|&thumb| buffer[(x, y)].symbol() == thumb).map(|thumb| (x, y, thumb))
})
.collect();
for &(x, y, _) in &cells {
assert_eq!(buffer[(x, y)].fg, crate::display::palette().title, "the thumb at {x},{y}");
}
cells
}
#[test]
fn the_viewer_and_the_editor_show_scroll_bars_when_there_is_more() {
let dir = std::env::temp_dir().join(format!("fm84-scroll-bars-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let short = dir.join("short.txt");
std::fs::write(&short, "one\ntwo\n").unwrap();
let tall = dir.join("tall.txt");
std::fs::write(&tall, (1..=200).map(|n| format!("{n}\n")).collect::<String>()).unwrap();
let wide = dir.join("wide.txt");
std::fs::write(&wide, "x".repeat(500)).unwrap();
let (width, height) = (80, 24);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
let mut shown = |app_state: &mut AppState| {
render_ui(&mut terminal, app_state);
thumb_cells(&terminal)
};
for edit in [false, true] {
let open = |app_state: &mut AppState, path: &PathBuf| {
if edit {
app_state.open_editor(path.clone()).unwrap();
} else {
app_state.open_viewer(path.clone()).unwrap();
}
};
open(&mut app_state, &short);
assert_eq!(shown(&mut app_state), [], "everything fits (edit: {edit})");
open(&mut app_state, &tall);
let cells = shown(&mut app_state);
assert!(!cells.is_empty(), "edit: {edit}");
let column = cells[0].0;
assert!(cells.iter().all(|&(x, _, thumb)| x == column && thumb == ScrollBarStyle::Heavy.thumbs().0), "one column: {cells:?}");
let top = cells.iter().map(|&(_, y, _)| y).min().unwrap();
if edit {
app_state.editor_mut().unwrap().cursor_line = 199;
} else {
app_state.viewer_end();
}
let bottom = shown(&mut app_state).iter().map(|&(_, y, _)| y).max().unwrap();
assert!(bottom > top + 10, "moved down: {top} to {bottom}");
open(&mut app_state, &wide);
let cells = shown(&mut app_state);
assert!(!cells.is_empty(), "edit: {edit}");
let row = cells[0].1;
assert!(cells.iter().all(|&(_, y, thumb)| y == row && thumb == ScrollBarStyle::Heavy.thumbs().1), "one row: {cells:?}");
}
app_state.open_viewer(wide).unwrap();
app_state.viewer_mut().unwrap().wrap = true;
assert_eq!(shown(&mut app_state), []);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_scroll_bar_style_from_f11_draws_both_thumbs() {
let path = std::env::temp_dir().join(format!("fm84-bar-style-{}.txt", std::process::id()));
std::fs::write(&path, (1..=200).map(|_| format!("{}\n", "x".repeat(300))).collect::<String>()).unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
assert_eq!(app_state.options.scroll_bar, ScrollBarStyle::Heavy);
app_state.open_viewer(path.clone()).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
let mut seen = Vec::new();
for _ in ScrollBarStyle::ALL {
let style = app_state.options.scroll_bar;
seen.push(style);
render_ui(&mut terminal, &mut app_state);
let buffer = terminal.backend().buffer();
let drawn = |bar: &crate::scroll_bar::ScrollBar| -> Vec<String> {
let track = bar.track;
(track.y..track.bottom()).flat_map(|y| (track.x..track.right()).map(move |x| (x, y))).map(|(x, y)| buffer[(x, y)].symbol().to_string()).collect()
};
let (down, along) = style.thumbs();
for bar in &app_state.scroll_bars {
let thumb = if bar.vertical { down } else { along };
assert!(drawn(bar).iter().any(|symbol| symbol == thumb), "{style:?}: {:?}", drawn(bar));
}
assert_eq!(app_state.scroll_bars.len(), 2, "{style:?}");
app_state.options.cycle(crate::options::OptionRow::ScrollBars, true);
}
assert_eq!(seen, ScrollBarStyle::ALL);
assert_eq!(app_state.options.scroll_bar, ScrollBarStyle::Heavy, "round to the first again");
assert_eq!(app_state.options.value(crate::options::OptionRow::ScrollBars), "Heavy ┃━");
std::fs::remove_file(path).unwrap();
}
#[test]
fn git_marks_sit_at_the_end_of_the_name() {
let dir = std::env::temp_dir().join(format!("fm84-git-marks-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let run = |arguments: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(&dir)
.args(["-c", "user.name=fm84", "-c", "user.email=fm84@localhost", "-c", "commit.gpgsign=false"])
.args(arguments)
.output()
.is_ok_and(|output| output.status.success())
};
if !run(&["init", "--quiet"]) {
let _ = std::fs::remove_dir_all(&dir);
return;
}
let long = format!("{}.txt", "a".repeat(80));
std::fs::write(dir.join("kept.txt"), "one").unwrap();
std::fs::write(dir.join(&long), "one").unwrap();
assert!(run(&["add", "."]) && run(&["commit", "--quiet", "-m", "first"]));
std::fs::write(dir.join("kept.txt"), "two").unwrap();
std::fs::write(dir.join(&long), "two").unwrap();
std::fs::write(dir.join("new.txt"), "").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.options.column_ext = false;
app_state.open_dir(true, dir.clone(), None);
let started = std::time::Instant::now();
while app_state.git_status(true).is_none() {
assert!(started.elapsed() < std::time::Duration::from_secs(10), "git never answered");
std::thread::sleep(std::time::Duration::from_millis(5));
app_state.poll_git_marks();
}
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
render_ui(&mut terminal, &mut app_state);
let buffer = terminal.backend().buffer();
let rows: Vec<String> = (0..buffer.area.height).map(|y| (0..60).map(|x| buffer[(x, y)].symbol()).collect()).collect();
let ends_with = |name: &str, mark: &str| {
rows.iter().any(|row| {
let before = row.split('│').nth(1).unwrap_or_default();
before.contains(name) && before.trim_end().ends_with(&format!(" {mark}"))
})
};
assert!(ends_with("kept.txt", "M"), "{rows:#?}");
assert!(ends_with("new.txt", "?"), "{rows:#?}");
assert!(ends_with("aaaa", "M"), "the long name cut for its mark: {rows:#?}");
app_state.options.git_marks = false;
app_state.reload_panel(true, None);
assert!(app_state.git_status(true).is_none() && app_state.git_left.dir == dir);
render_ui(&mut terminal, &mut app_state);
let buffer = terminal.backend().buffer();
let marked = (0..buffer.area.height).any(|y| {
let row: String = (0..60).map(|x| buffer[(x, y)].symbol()).collect();
row.split('│').nth(1).unwrap_or_default().trim_end().ends_with(" M")
});
assert!(!marked);
std::fs::remove_dir_all(&dir).unwrap();
}