use super::*;
use crate::markdown::{CellSpans, MermaidBlockId, TableBlock, TableBlockId, TextBlockId};
use crate::mermaid::{DEFAULT_MERMAID_HEIGHT, MermaidEntry};
use crate::ui::editor::{CommandOutcome, dispatch_command};
use crate::ui::markdown_view::TableLayout;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::time::Instant;
use crossterm::event::MouseEvent;
use ratatui::text::{Line, Span, Text};
use std::cell::Cell;
fn make_text_block(lines: &[&str]) -> DocBlock {
let text_lines: Vec<Line<'static>> = lines
.iter()
.map(|l| Line::from(Span::raw(l.to_string())))
.collect();
let n = text_lines.len();
let source_lines: Vec<u32> = (0..crate::cast::u32_sat(n)).collect();
let mut h = DefaultHasher::new();
source_lines.hash(&mut h);
n.hash(&mut h);
let id = TextBlockId(h.finish());
DocBlock::Text {
id,
text: Text::from(text_lines),
links: Vec::new(),
heading_anchors: Vec::new(),
source_lines,
wrapped_height: std::cell::Cell::new(crate::cast::u32_sat(n)),
}
}
fn str_cell(s: &str) -> CellSpans {
vec![Span::raw(s.to_string())]
}
fn make_table_block(id: u64, headers: &[&str], rows: &[&[&str]]) -> DocBlock {
let h: Vec<CellSpans> = headers.iter().map(|s| str_cell(s)).collect();
let r: Vec<Vec<CellSpans>> = rows
.iter()
.map(|row| row.iter().map(|s| str_cell(s)).collect())
.collect();
let num_cols = h.len();
let natural_widths = vec![10usize; num_cols];
let row_source_lines: Vec<u32> = std::iter::once(0)
.chain((2u32..).take(rows.len()))
.collect();
DocBlock::Table(TableBlock {
id: TableBlockId(id),
headers: h,
rows: r,
alignments: vec![pulldown_cmark::Alignment::None; num_cols],
natural_widths,
rendered_height: 4,
source_line: 0,
row_source_lines,
})
}
fn make_cached_layout(lines: &[&str]) -> TableLayout {
let text_lines: Vec<Line<'static>> = lines
.iter()
.map(|l| Line::from(Span::raw(l.to_string())))
.collect();
let n = text_lines.len();
TableLayout {
text: Text::from(text_lines),
physical_to_source: vec![0u32; n],
}
}
fn empty_mermaid_cache() -> MermaidCache {
MermaidCache::new()
}
fn source_only_cache(id: u64) -> MermaidCache {
let mut cache = MermaidCache::new();
cache.insert(
MermaidBlockId(id),
MermaidEntry::SourceOnly {
reason: "test".to_string(),
styled_text_cache: std::cell::RefCell::new(None),
},
);
cache
}
fn ready_cache(id: u64) -> MermaidCache {
let mut cache = MermaidCache::new();
cache.insert(
MermaidBlockId(id),
MermaidEntry::Failed {
msg: "irrelevant".to_string(),
styled_text_cache: std::cell::RefCell::new(None),
},
);
cache
}
fn empty_text_layouts() -> HashMap<TextBlockId, crate::ui::markdown_view::WrappedTextLayout> {
HashMap::new()
}
fn make_text_layouts_for(
block: &DocBlock,
width: u16,
) -> HashMap<TextBlockId, crate::ui::markdown_view::WrappedTextLayout> {
let mut layouts = HashMap::new();
crate::markdown::update_text_layouts(std::slice::from_ref(block), &mut layouts, width);
layouts
}
#[test]
fn collect_matches_text_block() {
let block = make_text_block(&["hello world", "no match", "world again"]);
let text_layouts = make_text_layouts_for(&block, 80);
let blocks = vec![block];
let table_layouts = HashMap::new();
let cache = empty_mermaid_cache();
let result = collect_match_lines(&blocks, &text_layouts, &table_layouts, &cache, "world");
assert_eq!(result, vec![0, 2]);
}
#[test]
fn collect_matches_table_with_layout_cache() {
let block_text = make_text_block(&["intro"]);
let text_layouts = make_text_layouts_for(&block_text, 80);
let blocks = vec![
block_text,
make_table_block(1, &["Header"], &[&["alpha"], &["beta needle"]]),
];
let mut table_layouts = HashMap::new();
table_layouts.insert(
TableBlockId(1),
make_cached_layout(&[
"\u{250c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2510}",
"\u{2502} Header \u{2502}",
"\u{251c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2524}",
"\u{2502} alpha \u{2502}",
"\u{2502} beta needle \u{2502}",
"\u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2518}",
]),
);
let cache = empty_mermaid_cache();
let result = collect_match_lines(&blocks, &text_layouts, &table_layouts, &cache, "needle");
assert_eq!(result, vec![5]);
}
#[test]
fn collect_matches_table_fallback_no_layout() {
let blocks = vec![make_table_block(2, &["Col"], &[&["findme"], &["nothing"]])];
let text_layouts = empty_text_layouts();
let table_layouts = HashMap::new();
let cache = empty_mermaid_cache();
let result = collect_match_lines(&blocks, &text_layouts, &table_layouts, &cache, "findme");
assert_eq!(result, vec![2]);
}
#[test]
fn collect_matches_mermaid_source_only() {
let source = "graph LR\n A --> needle\n B --> C";
let mermaid_id = MermaidBlockId(99);
let text_block = make_text_block(&["before"]);
let text_layouts = make_text_layouts_for(&text_block, 80);
let blocks = vec![
text_block,
DocBlock::Mermaid {
id: mermaid_id,
source: source.to_string(),
cell_height: Cell::new(DEFAULT_MERMAID_HEIGHT),
source_line: 0,
},
];
let cache = source_only_cache(99);
let table_layouts = HashMap::new();
let result = collect_match_lines(&blocks, &text_layouts, &table_layouts, &cache, "needle");
assert_eq!(result, vec![2]);
}
#[test]
fn collect_matches_mermaid_failed_shows_source() {
let mermaid_id = MermaidBlockId(42);
let blocks = vec![DocBlock::Mermaid {
id: mermaid_id,
source: "graph LR\n find_this".to_string(),
cell_height: Cell::new(DEFAULT_MERMAID_HEIGHT),
source_line: 0,
}];
let cache = ready_cache(42);
let text_layouts = empty_text_layouts();
let table_layouts = HashMap::new();
let result = collect_match_lines(&blocks, &text_layouts, &table_layouts, &cache, "find_this");
assert_eq!(result, vec![1]);
}
#[test]
fn collect_matches_mermaid_absent_shows_source() {
let mermaid_id = MermaidBlockId(7);
let blocks = vec![DocBlock::Mermaid {
id: mermaid_id,
source: "sequenceDiagram\n A ->> match_me: call".to_string(),
cell_height: Cell::new(DEFAULT_MERMAID_HEIGHT),
source_line: 0,
}];
let text_layouts = empty_text_layouts();
let table_layouts = HashMap::new();
let cache = empty_mermaid_cache();
let result = collect_match_lines(&blocks, &text_layouts, &table_layouts, &cache, "match_me");
assert_eq!(result, vec![1]);
}
fn make_app_with_modal(natural_widths: Vec<usize>, h_scroll: u16, v_scroll: u16) -> App {
let mut app = App::new(std::path::PathBuf::from("."), None);
app.table_modal = Some(TableModalState {
tab_id: crate::ui::tabs::TabId(0),
h_scroll,
v_scroll,
headers: vec![],
rows: vec![],
alignments: vec![],
natural_widths,
});
app.focus = Focus::TableModal;
app
}
#[test]
fn h_key_snaps_to_prev_column_boundary() {
let mut app = make_app_with_modal(vec![10, 20, 15], 17, 0);
app.handle_table_modal_key(KeyCode::Char('h'));
assert_eq!(app.table_modal.as_ref().unwrap().h_scroll, 13);
}
#[test]
fn l_key_snaps_to_next_column_boundary() {
let mut app = make_app_with_modal(vec![10, 20, 15], 0, 0);
app.handle_table_modal_key(KeyCode::Char('l'));
assert_eq!(app.table_modal.as_ref().unwrap().h_scroll, 13);
}
#[test]
fn capital_h_half_page_left() {
let mut app = make_app_with_modal(vec![10, 20, 15], 50, 0);
app.table_modal_rect = Some(ratatui::layout::Rect {
x: 0,
y: 0,
width: 42,
height: 20,
});
app.handle_table_modal_key(KeyCode::Char('H'));
assert_eq!(app.table_modal.as_ref().unwrap().h_scroll, 30);
}
#[test]
fn scroll_wheel_in_modal_scrolls_vertically() {
let mut app = make_app_with_modal(vec![10, 20, 15], 0, 0);
app.table_modal_rect = Some(ratatui::layout::Rect {
x: 5,
y: 5,
width: 80,
height: 30,
});
let m = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 10,
row: 10,
modifiers: KeyModifiers::empty(),
};
app.handle_table_modal_mouse(m);
assert_eq!(app.table_modal.as_ref().unwrap().v_scroll, 3);
}
#[test]
fn shift_scroll_in_modal_pans_column() {
let mut app = make_app_with_modal(vec![10, 20, 15], 0, 0);
app.table_modal_rect = Some(ratatui::layout::Rect {
x: 5,
y: 5,
width: 80,
height: 30,
});
let m = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 10,
row: 10,
modifiers: KeyModifiers::SHIFT,
};
app.handle_table_modal_mouse(m);
assert_eq!(app.table_modal.as_ref().unwrap().h_scroll, 13);
}
#[test]
fn click_outside_modal_closes_it() {
let mut app = make_app_with_modal(vec![10, 20, 15], 0, 0);
app.table_modal_rect = Some(ratatui::layout::Rect {
x: 10,
y: 10,
width: 60,
height: 20,
});
let m = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 5,
row: 5,
modifiers: KeyModifiers::empty(),
};
app.handle_table_modal_mouse(m);
assert!(
app.table_modal.is_none(),
"modal should close on outside click"
);
}
#[test]
fn click_inside_modal_does_not_close_it() {
let mut app = make_app_with_modal(vec![10, 20, 15], 5, 2);
app.table_modal_rect = Some(ratatui::layout::Rect {
x: 10,
y: 10,
width: 60,
height: 20,
});
let m = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 15,
row: 15,
modifiers: KeyModifiers::empty(),
};
app.handle_table_modal_mouse(m);
assert!(
app.table_modal.is_some(),
"modal should stay open on inside click"
);
let s = app.table_modal.as_ref().unwrap();
assert_eq!(s.h_scroll, 5);
assert_eq!(s.v_scroll, 2);
}
#[test]
fn collect_matches_absolute_offsets_across_blocks() {
let text_block0 = make_text_block(&["line0", "line1", "line2"]);
let text_block2 = make_text_block(&["after"]);
let mut text_layouts = make_text_layouts_for(&text_block0, 80);
text_layouts.extend(make_text_layouts_for(&text_block2, 80));
let blocks = vec![
text_block0,
make_table_block(5, &["H"], &[&["row0"], &["row1 target"]]),
text_block2,
];
let mut table_layouts = HashMap::new();
table_layouts.insert(
TableBlockId(5),
make_cached_layout(&[
"\u{250c}\u{2500}\u{2510}",
"\u{2502}H\u{2502}",
"\u{251c}\u{2500}\u{2524}",
"\u{2502}row0\u{2502}",
"\u{2502}row1 target\u{2502}",
"\u{2514}\u{2500}\u{2518}",
]),
);
let cache = empty_mermaid_cache();
let result = collect_match_lines(&blocks, &text_layouts, &table_layouts, &cache, "target");
assert_eq!(result, vec![7]);
}
fn make_app_with_tab(content: &str) -> (App, PathBuf) {
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/test.md");
app.tabs.open_or_focus(&path, true);
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.content = content.to_string();
tab.view.current_path = Some(path.clone());
tab.view.file_name = "test.md".to_string();
}
app.focus = Focus::Viewer;
(app, path)
}
#[test]
fn enter_edit_mode_initializes_editor_from_view_content() {
let (mut app, _path) = make_app_with_tab("# Hello\n\nworld");
app.enter_edit_mode();
let tab = app.tabs.active_tab().expect("tab must exist");
let editor = tab
.editor
.as_ref()
.expect("editor must be Some after enter_edit_mode");
assert_eq!(editor.baseline, "# Hello\n\nworld");
assert!(!editor.is_dirty());
assert_eq!(app.focus, Focus::Editor);
}
#[test]
fn q_with_no_dirty_returns_to_viewer() {
let (mut app, _path) = make_app_with_tab("clean content");
app.enter_edit_mode();
{
let tab = app.tabs.active_tab_mut().unwrap();
let editor = tab.editor.as_mut().unwrap();
let outcome = dispatch_command(editor, "q");
assert_eq!(outcome, CommandOutcome::Close);
}
app.close_editor();
assert!(app.tabs.active_tab().unwrap().editor.is_none());
assert_eq!(app.focus, Focus::Viewer);
}
#[test]
fn q_with_dirty_blocks_and_sets_status_message() {
let (mut app, _path) = make_app_with_tab("original");
app.enter_edit_mode();
{
let tab = app.tabs.active_tab_mut().unwrap();
let editor = tab.editor.as_mut().unwrap();
editor.baseline = "something different".to_string();
let outcome = dispatch_command(editor, "q");
assert_eq!(
outcome,
CommandOutcome::Handled,
":q on dirty buffer must return Handled (not Close)"
);
assert!(
editor.status_message.is_some(),
"a status message must be set when :q is blocked"
);
}
assert!(app.tabs.active_tab().unwrap().editor.is_some());
}
#[test]
fn q_bang_with_dirty_discards_and_returns_to_viewer() {
let (mut app, _path) = make_app_with_tab("original");
app.enter_edit_mode();
{
let tab = app.tabs.active_tab_mut().unwrap();
let editor = tab.editor.as_mut().unwrap();
editor.baseline = "something different".to_string();
let outcome = dispatch_command(editor, "q!");
assert_eq!(
outcome,
CommandOutcome::Close,
":q! must always close even when dirty"
);
}
app.close_editor();
assert!(app.tabs.active_tab().unwrap().editor.is_none());
assert_eq!(app.focus, Focus::Viewer);
}
#[test]
fn command_line_captures_chars_until_enter() {
use crossterm::event::{KeyCode as KC, KeyEvent, KeyModifiers};
let (mut app, _path) = make_app_with_tab("text");
app.enter_edit_mode();
app.focus = Focus::Editor;
app.handle_editor_key(KeyEvent::new(KC::Char(':'), KeyModifiers::NONE));
{
let tab = app.tabs.active_tab().unwrap();
let editor = tab.editor.as_ref().unwrap();
assert!(
editor.command_line.is_some(),
"':' in Normal mode must start command-line capture"
);
assert_eq!(editor.command_line.as_deref(), Some(""));
}
app.handle_editor_key(KeyEvent::new(KC::Char('w'), KeyModifiers::NONE));
{
let tab = app.tabs.active_tab().unwrap();
let editor = tab.editor.as_ref().unwrap();
assert_eq!(editor.command_line.as_deref(), Some("w"));
}
}
#[test]
fn mouse_events_ignored_while_editing() {
use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
let (mut app, _path) = make_app_with_tab("content");
app.enter_edit_mode();
assert_eq!(app.focus, Focus::Editor);
let selection_before = app.tree.list_state.selected();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 5,
row: 5,
modifiers: KeyModifiers::NONE,
};
app.handle_mouse(click);
assert_eq!(app.focus, Focus::Editor, "focus must stay Editor");
assert_eq!(
app.tree.list_state.selected(),
selection_before,
"tree selection must not change during edit mode"
);
assert!(
app.tabs.active_tab().unwrap().editor.is_some(),
"editor must remain open"
);
}
#[test]
fn enter_edit_mode_uses_cursor_for_source_line() {
use crate::markdown::{DocBlock, HeadingAnchor, LinkInfo};
use ratatui::text::{Line, Span, Text};
let mut app = App::new(std::path::PathBuf::from("."), None);
let content: String = {
use std::fmt::Write as _;
let mut s = String::new();
for i in 0..12usize {
let _ = writeln!(s, "source line {i}");
}
s
};
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
std::fs::write(&path, &content).unwrap();
let (_, _) = app.tabs.open_or_focus(&path, true);
let palette = crate::theme::Palette::from_theme(crate::theme::Theme::Default);
let tab = app.tabs.active_tab_mut().unwrap();
tab.view.load(
path.clone(),
"test.md".into(),
content,
&palette,
crate::theme::Theme::Default,
);
let src_lines = vec![10u32, 11, 12];
let text_lines: Vec<Line<'static>> = src_lines
.iter()
.map(|i| Line::from(Span::raw(format!("line {i}"))))
.collect();
let n = src_lines.len();
let block_id = {
let mut h = DefaultHasher::new();
src_lines.hash(&mut h);
n.hash(&mut h);
TextBlockId(h.finish())
};
let block = DocBlock::Text {
id: block_id,
text: Text::from(text_lines),
links: Vec::<LinkInfo>::new(),
heading_anchors: Vec::<HeadingAnchor>::new(),
source_lines: src_lines,
wrapped_height: std::cell::Cell::new(3),
};
crate::markdown::update_text_layouts(
std::slice::from_ref(&block),
&mut tab.view.text_layouts,
80,
);
tab.view.rendered = vec![block];
tab.view.total_lines = 3;
tab.view.cursor_line = 1;
app.focus = Focus::Viewer;
app.enter_edit_mode();
assert_eq!(app.focus, Focus::Editor, "focus should switch to Editor");
let tab = app.tabs.active_tab().unwrap();
let editor = tab.editor.as_ref().expect("editor should be set");
assert_eq!(
editor.state.cursor.row, 11,
"editor cursor row should be the mapped source line (11)"
);
}
fn make_app_with_view(total_lines: u32, view_height: u32) -> App {
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/nav_test.md");
app.tabs.open_or_focus(&path, true);
app.tabs.view_height = view_height;
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.total_lines = total_lines;
tab.view.cursor_line = 0;
tab.view.scroll_offset = 0;
}
app.focus = Focus::Viewer;
app
}
#[test]
fn d_key_moves_cursor_half_page_down() {
let mut app = make_app_with_view(100, 30);
app.handle_key(KeyCode::Char('d'), KeyModifiers::NONE);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(
tab.view.cursor_line, 15,
"`d` should move the cursor half a page (vh/2 = 15)"
);
}
#[test]
fn u_key_moves_cursor_half_page_up() {
let mut app = make_app_with_view(100, 30);
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.cursor_line = 50;
tab.view.scroll_offset = 35;
}
app.handle_key(KeyCode::Char('u'), KeyModifiers::NONE);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(tab.view.cursor_line, 35, "`u` should move cursor up vh/2");
}
#[test]
fn gg_chord_jumps_cursor_to_top() {
let mut app = make_app_with_view(100, 30);
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.cursor_line = 50;
tab.view.scroll_offset = 35;
}
app.handle_key(KeyCode::Char('g'), KeyModifiers::NONE);
app.handle_key(KeyCode::Char('g'), KeyModifiers::NONE);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(tab.view.cursor_line, 0, "`gg` should jump cursor to 0");
assert_eq!(tab.view.scroll_offset, 0, "`gg` should reset scroll");
}
#[test]
fn shift_g_jumps_cursor_to_bottom() {
let mut app = make_app_with_view(100, 30);
app.handle_key(KeyCode::Char('G'), KeyModifiers::SHIFT);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(
tab.view.cursor_line, 99,
"`G` should land cursor on last line"
);
}
#[test]
fn try_open_table_modal_picks_table_under_cursor() {
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/tables.md");
app.tabs.open_or_focus(&path, true);
app.tabs.view_height = 30;
app.focus = Focus::Viewer;
let blocks = vec![
make_text_block(&["intro", "text", "here"]),
make_table_block(10, &["A"], &[&["a-row-0"]]),
make_text_block(&["middle", "text", "here"]),
make_table_block(20, &["B"], &[&["b-row-0"]]),
];
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.total_lines = blocks.iter().map(DocBlock::height).sum();
tab.view.rendered = blocks;
tab.view.scroll_offset = 0;
tab.view.cursor_line = 12; }
app.try_open_table_modal();
let modal = app.table_modal.as_ref().expect("modal must open");
assert_eq!(
modal.headers.len(),
1,
"expected table B's single header, got {:?}",
modal.headers
);
assert_eq!(
modal.rows[0][0]
.iter()
.map(|s| s.content.as_ref())
.collect::<String>(),
"b-row-0",
"modal should carry table B's data, not table A's",
);
}
#[test]
fn try_open_table_modal_falls_back_to_first_visible_table() {
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/tables.md");
app.tabs.open_or_focus(&path, true);
app.tabs.view_height = 30;
app.focus = Focus::Viewer;
let blocks = vec![
make_text_block(&["intro"]),
make_table_block(10, &["A"], &[&["a-row-0"]]),
make_table_block(20, &["B"], &[&["b-row-0"]]),
];
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.total_lines = blocks.iter().map(DocBlock::height).sum();
tab.view.rendered = blocks;
tab.view.scroll_offset = 0;
tab.view.cursor_line = 0; }
app.try_open_table_modal();
let modal = app.table_modal.as_ref().expect("modal must open");
assert_eq!(
modal.rows[0][0]
.iter()
.map(|s| s.content.as_ref())
.collect::<String>(),
"a-row-0",
"modal should open table A (first visible) when cursor is on prose",
);
}
#[test]
fn d_key_moves_cursor_with_real_loaded_content() {
use crate::theme::{Palette, Theme};
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/nav_test.md");
app.tabs.open_or_focus(&path, true);
let content: String = {
use std::fmt::Write as _;
let mut s = String::new();
for i in 0..60usize {
let _ = write!(s, "paragraph {i}\n\n");
}
s
};
let palette = Palette::from_theme(Theme::Default);
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.load(
path.clone(),
"nav_test.md".to_string(),
content,
&palette,
Theme::Default,
);
}
app.focus = Focus::Viewer;
app.tabs.view_height = 30;
let before_cursor = app.tabs.active_tab().unwrap().view.cursor_line;
let before_total = app.tabs.active_tab().unwrap().view.total_lines;
let before_vh = app.tabs.view_height;
app.handle_key(KeyCode::Char('d'), KeyModifiers::NONE);
let after_cursor = app.tabs.active_tab().unwrap().view.cursor_line;
assert!(
before_total > 0,
"total_lines must be populated (got {before_total})"
);
assert!(
before_vh > 0,
"view_height must be positive (got {before_vh})"
);
assert_ne!(
before_cursor, after_cursor,
"`d` should move the cursor (before={before_cursor} after={after_cursor} \
total_lines={before_total} view_height={before_vh})",
);
}
fn make_app_with_doc_search(match_lines: Vec<u32>, current_match: usize, total_lines: u32) -> App {
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/ds_test.md");
app.tabs.open_or_focus(&path, true);
app.tabs.view_height = 20;
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.total_lines = total_lines;
tab.view.cursor_line = 0;
tab.view.scroll_offset = 0;
tab.doc_search.match_lines = match_lines;
tab.doc_search.current_match = current_match;
}
app
}
#[test]
fn doc_search_next_updates_cursor_and_scroll() {
let mut app = make_app_with_doc_search(vec![5, 20, 35], 0, 100);
{
let tab = app.tabs.active_tab_mut().unwrap();
tab.view.cursor_line = 5;
}
app.doc_search_next();
let tab = app.tabs.active_tab().unwrap();
assert_eq!(tab.doc_search.current_match, 1);
assert_eq!(
tab.view.cursor_line, 20,
"cursor must move to match line 20"
);
assert_eq!(tab.view.scroll_offset, 1);
}
#[test]
fn doc_search_prev_wraps_to_last_match() {
let mut app = make_app_with_doc_search(vec![5, 20, 35], 0, 100);
app.doc_search_prev();
let tab = app.tabs.active_tab().unwrap();
assert_eq!(tab.doc_search.current_match, 2);
assert_eq!(tab.view.cursor_line, 35, "cursor must wrap to last match");
}
#[test]
fn doc_search_empty_matches_no_op() {
let mut app = make_app_with_doc_search(vec![], 0, 100);
{
let tab = app.tabs.active_tab_mut().unwrap();
tab.view.cursor_line = 7;
tab.view.scroll_offset = 3;
}
app.doc_search_next();
let tab = app.tabs.active_tab().unwrap();
assert_eq!(tab.view.cursor_line, 7, "cursor must not change");
assert_eq!(tab.view.scroll_offset, 3, "scroll must not change");
}
#[test]
fn perform_doc_search_first_match_moves_cursor() {
let lines: Vec<&str> = (0..10)
.map(|i| if i == 4 { "hello world" } else { "other" })
.collect();
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/search_test.md");
app.tabs.open_or_focus(&path, true);
app.tabs.view_height = 20;
if let Some(tab) = app.tabs.active_tab_mut() {
let block = make_text_block(lines.as_slice());
let total = block.height();
tab.view.rendered = vec![block];
tab.view.total_lines = total;
tab.view.cursor_line = 0;
tab.view.scroll_offset = 0;
tab.doc_search.active = true;
tab.doc_search.query = "hello".to_string();
}
app.focus = Focus::Viewer;
app.perform_doc_search();
let tab = app.tabs.active_tab().unwrap();
assert_eq!(
tab.view.cursor_line, 4,
"cursor must jump to first match at line 4"
);
}
#[test]
fn watcher_suppresses_reload_within_grace_window() {
let (mut app, path) = make_app_with_tab("content");
app.last_file_save_at = Some((path.clone(), Instant::now()));
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Action>();
app.action_tx = Some(tx);
app.reload_changed_tabs(std::slice::from_ref(&path));
assert!(
rx.try_recv().is_err(),
"no FileReloaded should be sent when within the grace window"
);
}
#[test]
fn reload_with_unchanged_content_preserves_cursor() {
use crate::theme::{Palette, Theme};
let palette = Palette::from_theme(Theme::Default);
let content: String = {
use std::fmt::Write as _;
let mut s = String::new();
for i in 0..20usize {
let _ = write!(s, "line {i}\n\n");
}
s
};
let path = PathBuf::from("/fake/unchanged.md");
let mut app = App::new(PathBuf::from("."), None);
app.tabs.open_or_focus(&path, true);
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.load(
path.clone(),
"unchanged.md".to_string(),
content.clone(),
&palette,
Theme::Default,
);
tab.view.cursor_line = 10;
tab.view.scroll_offset = 5;
}
app.apply_file_reloaded(path.clone(), content);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(
tab.view.cursor_line, 10,
"cursor must not reset on spurious reload (unchanged content)"
);
assert_eq!(
tab.view.scroll_offset, 5,
"scroll must not reset on spurious reload (unchanged content)"
);
}
#[test]
fn reload_with_changed_content_restores_cursor_when_in_range() {
use crate::theme::{Palette, Theme};
let palette = Palette::from_theme(Theme::Default);
let content_v1: String = {
use std::fmt::Write as _;
let mut s = String::new();
for i in 0..20usize {
let _ = write!(s, "line {i}\n\n");
}
s
};
let path = PathBuf::from("/fake/changed.md");
let mut app = App::new(PathBuf::from("."), None);
app.tabs.open_or_focus(&path, true);
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.load(
path.clone(),
"changed.md".to_string(),
content_v1,
&palette,
Theme::Default,
);
tab.view.cursor_line = 10;
tab.view.scroll_offset = 5;
}
let content_v2: String = {
use std::fmt::Write as _;
let mut s = String::new();
for i in 0..20usize {
let _ = write!(s, "edited {i}\n\n");
}
s
};
app.apply_file_reloaded(path.clone(), content_v2);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(
tab.view.cursor_line, 10,
"cursor must be restored after a genuine reload when still in range"
);
}
#[test]
fn build_yank_text_single_line() {
let content = "alpha\nbeta\ngamma";
assert_eq!(build_yank_text(content, 1, 1), "beta");
}
#[test]
fn build_yank_text_multi_line() {
let content = "line0\nline1\nline2\nline3";
assert_eq!(build_yank_text(content, 1, 3), "line1\nline2\nline3");
}
#[test]
fn build_yank_text_reversed_range() {
let content = "a\nb\nc";
assert_eq!(build_yank_text(content, 2, 0), "a\nb\nc");
}
#[test]
fn build_yank_text_past_eof() {
let content = "x\ny";
let result = build_yank_text(content, 0, 10);
assert_eq!(result, "x\ny");
}
#[test]
fn build_yank_text_empty_content() {
assert_eq!(build_yank_text("", 0, 0), "");
}
fn make_rendered_app(content: &str) -> (App, PathBuf) {
use crate::theme::{Palette, Theme};
let palette = Palette::from_theme(Theme::Default);
let path = PathBuf::from("/fake/yank_test.md");
let mut app = App::new(PathBuf::from("."), None);
app.tabs.open_or_focus(&path, true);
app.tabs.view_height = 20;
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.load(
path.clone(),
"yank_test.md".to_string(),
content.to_string(),
&palette,
Theme::Default,
);
}
app.focus = Focus::Viewer;
(app, path)
}
fn line_vrange(anchor: u32, cursor: u32) -> crate::ui::markdown_view::VisualRange {
use crate::ui::markdown_view::{VisualMode, VisualRange};
VisualRange {
mode: VisualMode::Line,
anchor_line: anchor,
anchor_col: 0,
cursor_line: cursor,
cursor_col: 0,
}
}
#[test]
fn capital_v_enters_line_visual_mode() {
use crate::ui::markdown_view::{VisualMode, VisualRange};
let (mut app, _path) = make_rendered_app("line0\nline1\nline2");
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.cursor_line = 2;
}
app.handle_key(KeyCode::Char('V'), KeyModifiers::NONE);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(
tab.view.visual_mode,
Some(VisualRange {
mode: VisualMode::Line,
anchor_line: 2,
anchor_col: 0,
cursor_line: 2,
cursor_col: 0,
}),
"V must enter line visual mode at current cursor"
);
}
#[test]
fn lowercase_v_enters_char_visual_mode() {
use crate::ui::markdown_view::{VisualMode, VisualRange};
let (mut app, _path) = make_rendered_app("line0\nline1\nline2");
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.cursor_line = 1;
tab.view.cursor_col = 3;
}
app.handle_key(KeyCode::Char('v'), KeyModifiers::NONE);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(
tab.view.visual_mode,
Some(VisualRange {
mode: VisualMode::Char,
anchor_line: 1,
anchor_col: 3,
cursor_line: 1,
cursor_col: 3,
}),
"v must enter char visual mode at current cursor/col"
);
}
#[test]
fn v_in_visual_mode_exits_visual_mode() {
let (mut app, _path) = make_rendered_app("line0\nline1\nline2");
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.visual_mode = Some(line_vrange(1, 2));
}
app.handle_key(KeyCode::Char('V'), KeyModifiers::NONE);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(
tab.view.visual_mode, None,
"V in line visual mode must exit it"
);
}
#[test]
fn esc_in_visual_mode_exits_visual_mode() {
let (mut app, _path) = make_rendered_app("line0\nline1");
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.visual_mode = Some(line_vrange(0, 1));
}
app.handle_key(KeyCode::Esc, KeyModifiers::NONE);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(tab.view.visual_mode, None, "Esc must exit visual mode");
}
#[test]
fn j_in_visual_mode_extends_range() {
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/visual_j.md");
app.tabs.open_or_focus(&path, true);
app.tabs.view_height = 20;
if let Some(tab) = app.tabs.active_tab_mut() {
let block = make_text_block(&["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]);
let total = block.height();
tab.view.rendered = vec![block];
tab.view.total_lines = total;
tab.view.cursor_line = 2;
tab.view.visual_mode = Some(line_vrange(2, 2));
}
app.focus = Focus::Viewer;
app.handle_key(KeyCode::Char('j'), KeyModifiers::NONE);
let tab = app.tabs.active_tab().unwrap();
let range = tab
.view
.visual_mode
.expect("visual mode must still be active");
assert_eq!(range.anchor_line, 2, "anchor must stay at 2");
assert_eq!(range.cursor_line, 3, "cursor must extend to 3 after j");
}
#[test]
fn y_in_visual_mode_yanks_and_exits() {
let content = "alpha\nbeta\ngamma\ndelta";
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/visual_yank.md");
app.tabs.open_or_focus(&path, true);
app.tabs.view_height = 20;
if let Some(tab) = app.tabs.active_tab_mut() {
let block = make_text_block(&["alpha", "beta", "gamma", "delta"]);
let total = block.height();
crate::markdown::update_text_layouts(
std::slice::from_ref(&block),
&mut tab.view.text_layouts,
80,
);
tab.view.rendered = vec![block];
tab.view.total_lines = total;
tab.view.content = content.to_string();
tab.view.current_path = Some(path.clone());
tab.view.cursor_line = 1;
tab.view.visual_mode = Some(line_vrange(1, 2));
}
app.focus = Focus::Viewer;
app.handle_key(KeyCode::Char('y'), KeyModifiers::NONE);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(
tab.view.visual_mode, None,
"y in visual mode must exit visual mode"
);
let tl = &tab.view.text_layouts;
let top_source = crate::markdown::source_line_at(&tab.view.rendered, 1, tl, &HashMap::new());
let bottom_source = crate::markdown::source_line_at(&tab.view.rendered, 2, tl, &HashMap::new());
let expected = build_yank_text(content, top_source, bottom_source);
assert_eq!(
expected, "beta\ngamma",
"yank text must span visual selection"
);
}
#[test]
fn h_moves_cursor_col_left() {
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/hl_test.md");
app.tabs.open_or_focus(&path, true);
app.tabs.view_height = 20;
if let Some(tab) = app.tabs.active_tab_mut() {
let block = make_text_block(&["hello world"]);
tab.view.rendered = vec![block];
tab.view.total_lines = 1;
tab.view.cursor_col = 5;
}
app.focus = Focus::Viewer;
app.handle_key(KeyCode::Char('h'), KeyModifiers::NONE);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(tab.view.cursor_col, 4, "h must decrement cursor_col");
}
#[test]
fn l_moves_cursor_col_right_clamped() {
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/hl_clamp.md");
app.tabs.open_or_focus(&path, true);
app.tabs.view_height = 20;
if let Some(tab) = app.tabs.active_tab_mut() {
let block = make_text_block(&["abc"]);
crate::markdown::update_text_layouts(
std::slice::from_ref(&block),
&mut tab.view.text_layouts,
80,
);
tab.view.rendered = vec![block];
tab.view.total_lines = 1;
tab.view.cursor_col = 2; }
app.focus = Focus::Viewer;
app.handle_key(KeyCode::Char('l'), KeyModifiers::NONE);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(
tab.view.cursor_col, 2,
"l at end of line must not exceed line_width-1"
);
}
#[test]
fn pending_jump_cleared_after_apply() {
let path = PathBuf::from("/fake/jump_test.md");
let content = "line0\nline1\nline2\nline3\nline4";
let mut app = App::new(PathBuf::from("."), None);
app.tabs.open_or_focus(&path, true);
app.pending_jump = Some((path.clone(), 2));
app.apply_file_loaded(path.clone(), content.to_string(), true);
assert!(
app.pending_jump.is_none(),
"pending_jump must be cleared after apply_file_loaded"
);
}
#[test]
fn confirm_search_filename_result_no_jump() {
use crate::ui::search_modal::{SearchMode, SearchResult};
let mut app = App::new(PathBuf::from("."), None);
let path = PathBuf::from("/fake/fn_result.md");
app.search.active = true;
app.search.mode = SearchMode::FileName;
app.search.results = vec![SearchResult {
path: path.clone(),
name: "fn_result.md".to_string(),
match_count: 0,
preview: String::new(),
first_match_line: None,
}];
app.search.selected_index = 0;
app.confirm_search();
assert!(
app.pending_jump.is_none(),
"filename result must not set pending_jump"
);
}
#[test]
fn apply_file_loaded_jumps_cursor_to_source_line() {
let content = "alpha\nbeta\ngamma\ndelta\nepsilon";
let path = PathBuf::from("/fake/jump_cursor.md");
let mut app = App::new(PathBuf::from("."), None);
app.tabs.open_or_focus(&path, true);
app.tabs.view_height = 20;
if let Some(tab) = app.tabs.active_tab_mut() {
let block = make_text_block(&["alpha", "beta", "gamma", "delta", "epsilon"]);
let total = block.height();
tab.view.rendered = vec![block];
tab.view.total_lines = total;
tab.view.content = content.to_string();
tab.view.current_path = Some(path.clone());
}
let expected_logical = {
let tab = app.tabs.active_tab().unwrap();
crate::markdown::logical_line_at_source(&tab.view.rendered, 2, &HashMap::new())
.expect("controlled block must map source 2 to logical 2")
};
assert_eq!(
expected_logical, 2,
"make_text_block must yield source_line == logical_line"
);
app.pending_jump = Some((path.clone(), 2));
app.apply_file_loaded(path.clone(), content.to_string(), true);
let tab = app.tabs.active_tab().unwrap();
assert_eq!(
tab.view.cursor_line, expected_logical,
"cursor_line must land on logical line {expected_logical} for source line 2"
);
assert!(app.pending_jump.is_none(), "pending_jump must be consumed");
}
#[test]
fn pending_jump_cleared_on_file_load_failure() {
let path = PathBuf::from("/fake/nonexistent.md");
let mut app = App::new(PathBuf::from("."), None);
app.pending_jump = Some((path.clone(), 5));
app.handle_action(Action::FileLoadFailed { path: path.clone() });
assert!(
app.pending_jump.is_none(),
"pending_jump must be cleared when the matching file fails to load"
);
}
#[test]
fn pending_jump_not_cleared_on_different_path_failure() {
let path = PathBuf::from("/fake/target.md");
let other = PathBuf::from("/fake/other.md");
let mut app = App::new(PathBuf::from("."), None);
app.pending_jump = Some((path.clone(), 3));
app.handle_action(Action::FileLoadFailed { path: other });
assert!(
app.pending_jump.is_some(),
"pending_jump must be preserved when a different file fails to load"
);
}
#[test]
#[ignore] fn open_link_picker_real_doc_repro() {
use crate::markdown::renderer::render_markdown;
use crate::theme::{Palette, Theme};
let path = "/Users/leboiko/Documents/temp/temp2/temp3/intuition-v2/.claude/worktrees/agent-a177c0d2/.planning/backlog/recommendation-engine-v1/personal_notes.md";
let Ok(src) = std::fs::read_to_string(path) else {
eprintln!("file not found, skip");
return;
};
let palette = Palette::from_theme(Theme::Default);
let blocks = render_markdown(&src, &palette, Theme::Default);
let mut app = App::new(PathBuf::from("."), None);
app.tabs
.open_or_focus(&PathBuf::from("/fake/personal_notes.md"), true);
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.total_lines = blocks.iter().map(DocBlock::height).sum();
tab.view.rendered = blocks;
tab.view.recompute_positions();
}
app.focus = Focus::Viewer;
let mut expected: Vec<(String, String)> = Vec::new();
let mut chars = src.char_indices().peekable();
while let Some((_, c)) = chars.next() {
if c != '[' {
continue;
}
let mut text = String::new();
for (_, c) in chars.by_ref() {
if c == ']' {
break;
}
text.push(c);
}
if let Some(&(_, '(')) = chars.peek() {
chars.next();
if let Some(&(_, '#')) = chars.peek() {
chars.next();
let mut anchor = String::new();
for (_, c) in chars.by_ref() {
if c == ')' {
break;
}
anchor.push(c);
}
expected.push((text, anchor));
}
}
}
app.open_link_picker();
let picker = app.link_picker.expect("picker must open");
eprintln!("\n=== PICKER (first 30) ===");
for (i, item) in picker.items.iter().take(30).enumerate() {
eprintln!(" [{i:2}] {} -> #{}", item.text, item.anchor);
}
eprintln!("\n=== SOURCE LINKS (first 30, deduped first-occurrence) ===");
let mut seen = std::collections::HashSet::new();
let mut shown = 0;
for (text, anchor) in &expected {
if seen.insert(anchor.clone()) {
eprintln!(" [{shown:2}] {text} -> #{anchor}");
shown += 1;
if shown >= 30 {
break;
}
}
}
}
#[test]
fn open_link_picker_dedup_after_target_check() {
use crate::markdown::renderer::render_markdown;
use crate::theme::{Palette, Theme};
let src = r"# Top
See [BadFirst](#real) and [GoodSecond](#real).
## Real
.
";
let palette = Palette::from_theme(Theme::Default);
let blocks = render_markdown(src, &palette, Theme::Default);
let mut app = App::new(PathBuf::from("."), None);
app.tabs
.open_or_focus(&PathBuf::from("/fake/dedup.md"), true);
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.total_lines = blocks.iter().map(DocBlock::height).sum();
tab.view.rendered = blocks;
tab.view.recompute_positions();
}
app.focus = Focus::Viewer;
app.open_link_picker();
let picker = app.link_picker.expect("picker must open");
let labels: Vec<&str> = picker.items.iter().map(|i| i.text.as_str()).collect();
assert_eq!(
labels,
vec!["BadFirst"],
"first occurrence wins for dedup; both link to a real anchor here so just one entry",
);
}
#[test]
fn open_link_picker_handles_lists_and_mixed_structures() {
use crate::markdown::renderer::render_markdown;
use crate::theme::{Palette, Theme};
let src = r"# Top
- First, see [Apple](#apple).
- Second, see [Banana](#banana).
Then prose with [Cherry](#cherry).
1. Numbered: [Date](#date).
2. Numbered: [Elderberry](#elderberry).
Final prose link: [Fig](#fig).
## Apple
.
## Banana
.
## Cherry
.
## Date
.
## Elderberry
.
## Fig
.
";
let palette = Palette::from_theme(Theme::Default);
let blocks = render_markdown(src, &palette, Theme::Default);
let mut app = App::new(PathBuf::from("."), None);
app.tabs
.open_or_focus(&PathBuf::from("/fake/lists.md"), true);
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.total_lines = blocks.iter().map(DocBlock::height).sum();
tab.view.rendered = blocks;
tab.view.recompute_positions();
}
app.focus = Focus::Viewer;
app.open_link_picker();
let picker = app.link_picker.expect("picker must open");
let labels: Vec<&str> = picker.items.iter().map(|i| i.text.as_str()).collect();
assert_eq!(
labels,
vec!["Apple", "Banana", "Cherry", "Date", "Elderberry", "Fig"],
"picker must list links in source order even across lists, got: {labels:?}",
);
}
#[test]
fn open_link_picker_intro_links_to_end_sort_to_bottom() {
use crate::markdown::renderer::render_markdown;
use crate::theme::{Palette, Theme};
let src = r"# Top
Skim [System overview](#system-overview) first. End-of-doc has [appendix](#appendix) and [last section](#last-section).
## System overview
.
## Middle section
.
## Appendix
.
## Last section
.
";
let palette = Palette::from_theme(Theme::Default);
let blocks = render_markdown(src, &palette, Theme::Default);
let mut app = App::new(PathBuf::from("."), None);
app.tabs
.open_or_focus(&PathBuf::from("/fake/intro_links.md"), true);
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.total_lines = blocks.iter().map(DocBlock::height).sum();
tab.view.rendered = blocks;
tab.view.recompute_positions();
}
app.focus = Focus::Viewer;
app.open_link_picker();
let picker = app.link_picker.expect("picker must open");
let labels: Vec<&str> = picker.items.iter().map(|i| i.text.as_str()).collect();
assert_eq!(
labels,
vec!["System overview", "appendix", "last section"],
"picker must put appendix/last-section links AFTER section-2 entries, got: {labels:?}",
);
}
#[test]
fn open_link_picker_lists_links_by_target_position() {
use crate::markdown::renderer::render_markdown;
use crate::theme::{Palette, Theme};
let src = r"# Top
See [Apple](#apple) and [Zebra](#zebra) at the top.
## Middle
Then [Banana](#banana) and [Yellow](#yellow) here.
### Sub
Finally [Cherry](#cherry).
## Apple
.
## Banana
.
## Cherry
.
## Yellow
.
## Zebra
.
";
let palette = Palette::from_theme(Theme::Default);
let blocks = render_markdown(src, &palette, Theme::Default);
let mut app = App::new(PathBuf::from("."), None);
app.tabs
.open_or_focus(&PathBuf::from("/fake/links.md"), true);
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.total_lines = blocks.iter().map(DocBlock::height).sum();
tab.view.rendered = blocks;
tab.view.recompute_positions();
}
app.focus = Focus::Viewer;
app.open_link_picker();
let picker = app.link_picker.expect("picker must open");
let labels: Vec<&str> = picker.items.iter().map(|i| i.text.as_str()).collect();
assert_eq!(
labels,
vec!["Apple", "Banana", "Cherry", "Yellow", "Zebra"],
"picker must list links in TARGET-heading order, got: {labels:?}",
);
}
fn make_mermaid_block(id: u64, source: &str, height: u32) -> DocBlock {
DocBlock::Mermaid {
id: MermaidBlockId(id),
source: source.to_string(),
cell_height: Cell::new(height),
source_line: 0,
}
}
#[test]
fn try_open_mermaid_modal_picks_block_under_cursor() {
let mut app = App::new(PathBuf::from("."), None);
app.tabs
.open_or_focus(&PathBuf::from("/fake/diagrams.md"), true);
app.tabs.view_height = 30;
app.focus = Focus::Viewer;
let blocks = vec![
make_text_block(&["intro", "text", "here"]),
make_mermaid_block(1, "graph LR\n A --> B", 5),
make_text_block(&["middle", "text", "here"]),
make_mermaid_block(2, "sequenceDiagram\n A->>B: hi", 5),
];
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.total_lines = blocks.iter().map(DocBlock::height).sum();
tab.view.rendered = blocks;
tab.view.scroll_offset = 0;
tab.view.cursor_line = 13; }
app.try_open_mermaid_modal();
let modal = app.mermaid_modal.as_ref().expect("modal must open");
assert_eq!(modal.block_id, MermaidBlockId(2));
assert_eq!(modal.source, "sequenceDiagram\n A->>B: hi");
assert_eq!(app.focus, Focus::MermaidModal);
}
#[test]
fn try_open_mermaid_modal_falls_back_to_first_visible_block() {
let mut app = App::new(PathBuf::from("."), None);
app.tabs
.open_or_focus(&PathBuf::from("/fake/diagrams.md"), true);
app.tabs.view_height = 30;
app.focus = Focus::Viewer;
let blocks = vec![
make_text_block(&["intro"]),
make_mermaid_block(1, "graph LR\n A --> B", 5),
make_mermaid_block(2, "sequenceDiagram\n A->>B: hi", 5),
];
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.total_lines = blocks.iter().map(DocBlock::height).sum();
tab.view.rendered = blocks;
tab.view.scroll_offset = 0;
tab.view.cursor_line = 0; }
app.try_open_mermaid_modal();
let modal = app.mermaid_modal.as_ref().expect("modal must open");
assert_eq!(
modal.block_id,
MermaidBlockId(1),
"should fall back to first visible mermaid",
);
}
#[test]
fn try_open_mermaid_modal_noop_when_no_blocks() {
let mut app = App::new(PathBuf::from("."), None);
app.tabs
.open_or_focus(&PathBuf::from("/fake/no_diagrams.md"), true);
app.tabs.view_height = 30;
app.focus = Focus::Viewer;
if let Some(tab) = app.tabs.active_tab_mut() {
tab.view.rendered = vec![make_text_block(&["just prose"])];
tab.view.total_lines = 1;
tab.view.cursor_line = 0;
}
app.try_open_mermaid_modal();
assert!(app.mermaid_modal.is_none());
assert_eq!(app.focus, Focus::Viewer);
}
#[test]
fn handle_mermaid_modal_key_close() {
for code in [
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyCode::Esc,
crossterm::event::KeyCode::Enter,
] {
let mut app = App::new(PathBuf::from("."), None);
app.mermaid_modal = Some(MermaidModalState {
tab_id: crate::ui::tabs::TabId(0),
block_id: MermaidBlockId(1),
source: "graph LR\nA --> B".to_string(),
h_scroll: 0,
v_scroll: 0,
text_zoom: 0,
});
app.focus = Focus::MermaidModal;
app.handle_mermaid_modal_key(code);
assert!(app.mermaid_modal.is_none(), "key {code:?} must close modal");
assert_eq!(app.focus, Focus::Viewer);
}
}
#[test]
fn handle_mermaid_modal_key_scroll() {
use crossterm::event::KeyCode;
let mut app = App::new(PathBuf::from("."), None);
app.mermaid_modal = Some(MermaidModalState {
tab_id: crate::ui::tabs::TabId(0),
block_id: MermaidBlockId(1),
source: "graph LR\nA --> B".to_string(),
h_scroll: 5,
v_scroll: 5,
text_zoom: 0,
});
app.focus = Focus::MermaidModal;
app.handle_mermaid_modal_key(KeyCode::Char('j'));
app.handle_mermaid_modal_key(KeyCode::Char('l'));
let s = app.mermaid_modal.as_ref().unwrap();
assert_eq!(s.v_scroll, 6);
assert_eq!(s.h_scroll, 6);
app.handle_mermaid_modal_key(KeyCode::Char('k'));
app.handle_mermaid_modal_key(KeyCode::Char('h'));
let s = app.mermaid_modal.as_ref().unwrap();
assert_eq!(s.v_scroll, 5);
assert_eq!(s.h_scroll, 5);
app.handle_mermaid_modal_key(KeyCode::Char('0'));
assert_eq!(app.mermaid_modal.as_ref().unwrap().h_scroll, 0);
app.handle_mermaid_modal_key(KeyCode::Char('g'));
app.handle_mermaid_modal_key(KeyCode::Char('g'));
let s = app.mermaid_modal.as_ref().unwrap();
assert_eq!(s.v_scroll, 0);
assert_eq!(s.h_scroll, 0);
}
#[test]
fn handle_mermaid_modal_key_scroll_saturating() {
use crossterm::event::KeyCode;
let mut app = App::new(PathBuf::from("."), None);
app.mermaid_modal = Some(MermaidModalState {
tab_id: crate::ui::tabs::TabId(0),
block_id: MermaidBlockId(1),
source: "x".to_string(),
h_scroll: 0,
v_scroll: 0,
text_zoom: 0,
});
app.focus = Focus::MermaidModal;
for _ in 0..3 {
app.handle_mermaid_modal_key(KeyCode::Char('k'));
app.handle_mermaid_modal_key(KeyCode::Char('h'));
}
let s = app.mermaid_modal.as_ref().unwrap();
assert_eq!(s.v_scroll, 0);
assert_eq!(s.h_scroll, 0);
}
#[test]
fn handle_mermaid_modal_key_zoom_adjusts_text_zoom() {
use crossterm::event::KeyCode;
let mut app = App::new(PathBuf::from("."), None);
app.mermaid_modal = Some(MermaidModalState {
tab_id: crate::ui::tabs::TabId(0),
block_id: MermaidBlockId(1),
source: "graph LR\nA --> B".to_string(),
h_scroll: 7,
v_scroll: 3,
text_zoom: 0,
});
app.focus = Focus::MermaidModal;
app.handle_mermaid_modal_key(KeyCode::Char('+'));
app.handle_mermaid_modal_key(KeyCode::Char('+'));
let s = app.mermaid_modal.as_ref().unwrap();
assert_eq!(s.text_zoom, 2, "two `+` presses bump zoom to +2");
assert_eq!(s.h_scroll, 0, "zoom should reset h_scroll");
assert_eq!(s.v_scroll, 0, "zoom should reset v_scroll");
app.handle_mermaid_modal_key(KeyCode::Char('-'));
app.handle_mermaid_modal_key(KeyCode::Char('-'));
app.handle_mermaid_modal_key(KeyCode::Char('-'));
let s = app.mermaid_modal.as_ref().unwrap();
assert_eq!(s.text_zoom, -1, "three `-` presses leave zoom at -1");
app.handle_mermaid_modal_key(KeyCode::Char('='));
let s = app.mermaid_modal.as_ref().unwrap();
assert_eq!(s.text_zoom, 0, "= resets zoom to 0");
}